From aea91c1098fb95f5a2bf30df27223f5bf0911f08 Mon Sep 17 00:00:00 2001 From: Peng Peng Date: Sun, 31 May 2026 13:19:33 +0800 Subject: [PATCH 1/5] fix(test): repair pre-existing cargo test build The mocks for OpaqueHandler/BackendHandler were missing the registration_password method, and two test-only User initializers were missing the user_index/initialized fields, so `cargo test` failed to compile even before this branch's changes. Add the missing pieces so the test target builds. Co-authored-by: Cursor --- server/src/domain/opaque_handler.rs | 5 +++++ server/src/domain/types.rs | 1 + server/src/infra/ldap_handler.rs | 2 ++ server/src/infra/test_utils.rs | 5 +++++ 4 files changed, 13 insertions(+) diff --git a/server/src/domain/opaque_handler.rs b/server/src/domain/opaque_handler.rs index 93d444de4..f77699c99 100644 --- a/server/src/domain/opaque_handler.rs +++ b/server/src/domain/opaque_handler.rs @@ -48,5 +48,10 @@ mockall::mock! { &self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>; + async fn registration_password( + &self, + username: &UserId, + password: String + ) -> Result<()>; } } diff --git a/server/src/domain/types.rs b/server/src/domain/types.rs index 00c5b509e..8240134c2 100644 --- a/server/src/domain/types.rs +++ b/server/src/domain/types.rs @@ -432,6 +432,7 @@ impl Default for User { creation_date: epoch, uuid: Uuid::from_name_and_date("", &epoch), attributes: Vec::new(), + initialized: false, } } } diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 510a33f90..7e036d214 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -1254,6 +1254,8 @@ mod tests { .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) .unwrap() .naive_utc(), + user_index: 0, + initialized: false, }, groups: None, }, diff --git a/server/src/infra/test_utils.rs b/server/src/infra/test_utils.rs index d023277f9..2f525b19e 100644 --- a/server/src/infra/test_utils.rs +++ b/server/src/infra/test_utils.rs @@ -71,6 +71,11 @@ mockall::mock! { &self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>; + async fn registration_password( + &self, + username: &UserId, + password: String + ) -> Result<()>; } } From 0ea5f3acdcf2eee0d21e2e46586188c49bcc5546 Mon Sep 17 00:00:00 2001 From: Peng Peng Date: Sun, 31 May 2026 13:19:33 +0800 Subject: [PATCH 2/5] feat(server): add opt-in unauthenticated read-only snapshot endpoint Serve GET /readonly/snapshot on a separate, opt-in HTTP listener (LLDAP_HTTP_READONLY_PORT) with no authentication, so a network-gated consumer (the Go SDK) can fetch a full users/groups snapshot whose JSON mirrors the GraphQL shape. User and group attributes are both exposed, filtered by an optional denylist (LLDAP_HTTP_READONLY_DENY_ATTRIBUTES); errors return a generic 500 to avoid leaking internals. Document the new options in the config template. Co-authored-by: Cursor --- lldap_config.docker_template.toml | 14 ++ server/src/infra/cli.rs | 13 ++ server/src/infra/configuration.rs | 23 ++ server/src/infra/mod.rs | 1 + server/src/infra/readonly_server.rs | 322 ++++++++++++++++++++++++++++ server/src/infra/tcp_server.rs | 78 +++++++ server/src/main.rs | 7 + 7 files changed, 458 insertions(+) create mode 100644 server/src/infra/readonly_server.rs diff --git a/lldap_config.docker_template.toml b/lldap_config.docker_template.toml index 75b36aa91..926db33da 100644 --- a/lldap_config.docker_template.toml +++ b/lldap_config.docker_template.toml @@ -30,6 +30,20 @@ ## administration. #http_port = 17170 +## Optional unauthenticated read-only snapshot endpoint (GET /readonly/snapshot). +## Disabled unless "http_readonly_port" is set. It has NO authentication; access +## control must be enforced by the surrounding network (e.g. a K8s NetworkPolicy +## or a reverse proxy). The JSON mirrors the GraphQL snapshot of users and groups. +#http_readonly_host = "0.0.0.0" +#http_readonly_port = 17171 +## Comma-separated attribute names to omit from that snapshot (applies to both +## user and group attributes), e.g. to hide large or sensitive values. +## Recommended: "avatar" (a base64 image blob, useless for account sync). Note +## that denying "first_name"/"last_name" also blanks the derived +## firstName/lastName fields, since those are derived from the attributes. +## Empty (default) exposes every attribute. +#http_readonly_deny_attributes = "avatar" + ## The public URL of the server, for password reset links. #http_url = "http://localhost" diff --git a/server/src/infra/cli.rs b/server/src/infra/cli.rs index 1b2ecbe6e..fdfd5a699 100644 --- a/server/src/infra/cli.rs +++ b/server/src/infra/cli.rs @@ -83,6 +83,19 @@ pub struct RunOpts { #[clap(long, env = "LLDAP_HTTP_PORT")] pub http_port: Option, + /// Change the read-only snapshot HTTP host. Default: "0.0.0.0" + #[clap(long, env = "LLDAP_HTTP_READONLY_HOST")] + pub http_readonly_host: Option, + + /// Optional unauthenticated read-only snapshot port (GET /readonly/snapshot). + /// Disabled unless set; protect it at the network layer. + #[clap(long, env = "LLDAP_HTTP_READONLY_PORT")] + pub http_readonly_port: Option, + + /// Comma-separated user attribute names to omit from the read-only snapshot. + #[clap(long, env = "LLDAP_HTTP_READONLY_DENY_ATTRIBUTES")] + pub http_readonly_deny_attributes: Option, + /// URL of the server, for password reset links. #[clap(long, env = "LLDAP_HTTP_URL")] pub http_url: Option, diff --git a/server/src/infra/configuration.rs b/server/src/infra/configuration.rs index c384d3dfd..40b9d9f80 100644 --- a/server/src/infra/configuration.rs +++ b/server/src/infra/configuration.rs @@ -80,6 +80,17 @@ pub struct Configuration { pub http_host: String, #[builder(default = "17170")] pub http_port: u16, + #[builder(default = r#"String::from("0.0.0.0")"#)] + pub http_readonly_host: String, + // Optional unauthenticated read-only snapshot port. Disabled (None) unless + // set; access control is expected to be enforced by the surrounding network + // (e.g. K8s NetworkPolicy / a proxy), not by LLDAP. + #[builder(default)] + pub http_readonly_port: Option, + // Comma-separated user attribute names to omit from the read-only snapshot + // (e.g. "jpegPhoto,foo"). Empty (default) exposes every attribute. + #[builder(default)] + pub http_readonly_deny_attributes: String, #[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)] pub jwt_secret: SecUtf8, #[builder(default = r#"String::from("dc=example,dc=com")"#)] @@ -414,6 +425,18 @@ impl ConfigOverrider for RunOpts { config.http_port = port; } + if let Some(host) = self.http_readonly_host.as_ref() { + config.http_readonly_host = host.clone(); + } + + if let Some(port) = self.http_readonly_port { + config.http_readonly_port = Some(port); + } + + if let Some(deny) = self.http_readonly_deny_attributes.as_ref() { + config.http_readonly_deny_attributes = deny.clone(); + } + if let Some(url) = self.http_url.as_ref() { config.http_url = url.clone(); } diff --git a/server/src/infra/mod.rs b/server/src/infra/mod.rs index 38301ad34..1caa14962 100644 --- a/server/src/infra/mod.rs +++ b/server/src/infra/mod.rs @@ -11,6 +11,7 @@ pub mod ldap_handler; pub mod ldap_server; pub mod logging; pub mod mail; +pub mod readonly_server; pub mod sql_backend_handler; pub mod tcp_backend_handler; pub mod tcp_server; diff --git a/server/src/infra/readonly_server.rs b/server/src/infra/readonly_server.rs new file mode 100644 index 000000000..2076e2954 --- /dev/null +++ b/server/src/infra/readonly_server.rs @@ -0,0 +1,322 @@ +//! Unauthenticated read-only snapshot endpoint. +//! +//! This serves `GET /readonly/snapshot` on a separate, opt-in HTTP listener +//! (see `build_readonly_server` in `tcp_server.rs`). It deliberately has **no** +//! authentication: access control is expected to be enforced by the surrounding +//! network (e.g. a K8s NetworkPolicy or a reverse proxy). The JSON shape mirrors +//! the GraphQL `snapshot` query, and the Go SDK consumes this endpoint directly. + +use actix_web::{web, HttpResponse}; +use chrono::TimeZone; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; + +use crate::{ + domain::handler::BackendHandler, + infra::{ + access_control::{ + AccessControlledBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler, + }, + graphql::query::serialize_attribute, + tcp_server::TcpResult, + }, +}; + +/// Minimal state for the read-only listener: the backend handler plus the set +/// of attribute names to omit from the snapshot (applied to both user and group +/// attributes). Unlike `AppState`, it carries no JWT keys, mail options or +/// server URL, since this endpoint never authenticates or sends mail. +pub(crate) struct ReadonlyState { + pub backend_handler: AccessControlledBackendHandler, + pub deny_attributes: HashSet, +} + +impl ReadonlyState { + fn get_readonly_handler(&self) -> &impl ReadonlyBackendHandler { + self.backend_handler.unsafe_get_handler() + } +} + +#[derive(Serialize)] +struct SnapshotDto { + users: Vec, + groups: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UserDto { + id: String, + email: String, + display_name: String, + first_name: String, + last_name: String, + creation_date: String, + uuid: String, + groups: Vec, + attributes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UserGroupDto { + display_name: String, +} + +#[derive(Serialize)] +struct AttributeDto { + name: String, + value: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GroupDto { + id: i32, + display_name: String, + users: Vec, + attributes: Vec, +} + +#[derive(Serialize)] +struct GroupUserDto { + id: String, +} + +/// Registers the read-only routes. Mounted by `build_readonly_server` on a +/// listener with no auth middleware. +pub(crate) fn configure_readonly_endpoint(cfg: &mut web::ServiceConfig) +where + Backend: BackendHandler + 'static, +{ + cfg.service( + web::resource("/readonly/snapshot").route(web::get().to(snapshot_handler::)), + ); +} + +async fn snapshot_handler(data: web::Data>) -> HttpResponse +where + Backend: BackendHandler + 'static, +{ + match build_snapshot(&data).await { + Ok(dto) => HttpResponse::Ok().json(dto), + Err(e) => { + // Avoid leaking internal error details to unauthenticated callers. + tracing::error!("read-only snapshot failed: {}", e); + HttpResponse::InternalServerError().finish() + } + } +} + +async fn build_snapshot(data: &ReadonlyState) -> TcpResult +where + Backend: BackendHandler + 'static, +{ + let handler = data.get_readonly_handler(); + let schema = handler.get_schema().await?; + let user_attributes = &schema.get_schema().user_attributes; + let group_attributes = &schema.get_schema().group_attributes; + let users = handler.list_users(None, true).await?; + let groups = handler.list_groups(None).await?; + + // Invert user->groups into group_id->members so we don't issue a query per + // group. `list_users(.., get_groups = true)` already carries each user's + // groups, mirroring what the GraphQL `group.users` resolver returns. + let mut members: HashMap> = HashMap::new(); + + let mut user_dtos = Vec::with_capacity(users.len()); + for entry in users { + let user = entry.user; + let attributes: Vec = user + .attributes + .iter() + .filter(|a| !data.deny_attributes.contains(a.name.as_str())) + .filter_map(|a| { + user_attributes.get_attribute_schema(&a.name).map(|s| AttributeDto { + name: a.name.to_string(), + value: serialize_attribute(a, s), + }) + }) + .collect(); + let scalar = |name: &str| -> String { + attributes + .iter() + .find(|a| a.name == name) + .and_then(|a| a.value.first().cloned()) + .unwrap_or_default() + }; + let groups = entry.groups.unwrap_or_default(); + let mut group_dtos = Vec::with_capacity(groups.len()); + for g in groups { + members + .entry(g.group_id.0) + .or_default() + .push(GroupUserDto { id: user.user_id.as_str().to_string() }); + group_dtos.push(UserGroupDto { display_name: g.display_name.to_string() }); + } + user_dtos.push(UserDto { + id: user.user_id.as_str().to_string(), + email: user.email.as_str().to_string(), + display_name: user.display_name.clone().unwrap_or_default(), + first_name: scalar("first_name"), + last_name: scalar("last_name"), + creation_date: chrono::Utc.from_utc_datetime(&user.creation_date).to_rfc3339(), + uuid: user.uuid.as_str().to_string(), + groups: group_dtos, + attributes, + }); + } + + let group_dtos = groups + .into_iter() + .map(|g| { + let attributes: Vec = g + .attributes + .iter() + .filter(|a| !data.deny_attributes.contains(a.name.as_str())) + .filter_map(|a| { + group_attributes.get_attribute_schema(&a.name).map(|s| AttributeDto { + name: a.name.to_string(), + value: serialize_attribute(a, s), + }) + }) + .collect(); + GroupDto { + id: g.id.0, + display_name: g.display_name.to_string(), + users: members.remove(&g.id.0).unwrap_or_default(), + attributes, + } + }) + .collect(); + + Ok(SnapshotDto { users: user_dtos, groups: group_dtos }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + domain::{ + handler::{AttributeList, AttributeSchema, Schema}, + types::{ + AttributeType, AttributeValue, Group, GroupDetails, GroupId, Serialized, User, + UserAndGroups, UserId, + }, + }, + infra::test_utils::MockTestBackendHandler, + }; + use chrono::TimeZone; + + fn string_attr(name: &str) -> AttributeSchema { + AttributeSchema { + name: name.into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + } + } + + fn handler_with_alice() -> AccessControlledBackendHandler { + let mut mock = MockTestBackendHandler::new(); + mock.expect_get_schema().returning(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: vec![string_attr("first_name")], + }, + group_attributes: AttributeList { + attributes: vec![string_attr("club_name")], + }, + extra_user_object_classes: Vec::new(), + extra_group_object_classes: Vec::new(), + }) + }); + mock.expect_list_users().returning(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("alice"), + email: "alice@example.com".into(), + display_name: Some("Administrator".to_owned()), + attributes: vec![AttributeValue { + name: "first_name".into(), + value: Serialized::from("Alice"), + }], + ..Default::default() + }, + groups: Some(vec![GroupDetails { + group_id: GroupId(1), + display_name: "lldap_admin".into(), + creation_date: chrono::Utc.timestamp_nanos(0).naive_utc(), + uuid: crate::uuid!("00000000000000000000000000000001"), + attributes: Vec::new(), + }]), + }]) + }); + mock.expect_list_groups().returning(|_| { + Ok(vec![Group { + id: GroupId(1), + display_name: "lldap_admin".into(), + creation_date: chrono::Utc.timestamp_nanos(0).naive_utc(), + uuid: crate::uuid!("00000000000000000000000000000001"), + users: vec![UserId::new("alice")], + attributes: vec![AttributeValue { + name: "club_name".into(), + value: Serialized::from("Gang of Four"), + }], + }]) + }); + AccessControlledBackendHandler::new(mock) + } + + #[tokio::test] + async fn maps_users_groups_and_membership() { + let state = ReadonlyState { + backend_handler: handler_with_alice(), + deny_attributes: HashSet::new(), + }; + let snap = build_snapshot(&state).await.unwrap(); + + assert_eq!(snap.users.len(), 1); + let user = &snap.users[0]; + assert_eq!(user.id, "alice"); + assert_eq!(user.first_name, "Alice"); + assert_eq!(user.attributes.len(), 1); + assert_eq!(user.attributes[0].name, "first_name"); + assert_eq!(user.attributes[0].value, vec!["Alice".to_owned()]); + assert_eq!(user.groups.len(), 1); + assert_eq!(user.groups[0].display_name, "lldap_admin"); + + // The group's member list is inverted from the user's groups. + let admin = snap + .groups + .iter() + .find(|g| g.display_name == "lldap_admin") + .unwrap(); + assert_eq!(admin.id, 1); + assert_eq!(admin.users.len(), 1); + assert_eq!(admin.users[0].id, "alice"); + + // Group attributes are serialized just like user attributes. + assert_eq!(admin.attributes.len(), 1); + assert_eq!(admin.attributes[0].name, "club_name"); + assert_eq!(admin.attributes[0].value, vec!["Gang of Four".to_owned()]); + } + + #[tokio::test] + async fn denylist_filters_attribute_and_derived_field() { + let state = ReadonlyState { + backend_handler: handler_with_alice(), + deny_attributes: HashSet::from(["first_name".to_owned(), "club_name".to_owned()]), + }; + let snap = build_snapshot(&state).await.unwrap(); + + let user = &snap.users[0]; + assert!(user.attributes.is_empty()); + // firstName is derived from the (now-filtered) attributes, so it blanks. + assert_eq!(user.first_name, ""); + // The denylist applies to group attributes too. + assert!(snap.groups[0].attributes.is_empty()); + } +} diff --git a/server/src/infra/tcp_server.rs b/server/src/infra/tcp_server.rs index 8fd6d8bb3..938c19db0 100644 --- a/server/src/infra/tcp_server.rs +++ b/server/src/infra/tcp_server.rs @@ -248,3 +248,81 @@ where ) }) } + +/// Configures the minimal app served on the read-only listener: only the +/// `/readonly/*` routes and a health check, with no auth middleware and no JWT +/// handling. Uses a minimal `ReadonlyState` (no JWT keys / mail / server URL). +fn readonly_http_config( + cfg: &mut web::ServiceConfig, + backend_handler: Backend, + deny_attributes: HashSet, +) where + Backend: BackendHandler + Clone + 'static, +{ + cfg.app_data(web::Data::new( + super::readonly_server::ReadonlyState:: { + backend_handler: AccessControlledBackendHandler::new(backend_handler), + deny_attributes, + }, + )) + .route( + "/health", + web::get().to(|| async { HttpResponse::Ok().finish() }), + ) + .configure(super::readonly_server::configure_readonly_endpoint::); +} + +/// Binds an optional, unauthenticated read-only listener exposing only +/// `GET /readonly/snapshot`. It is bound only when `http_readonly_port` is set; +/// otherwise the server builder is returned unchanged. Access control is left to +/// the surrounding network (e.g. K8s NetworkPolicy / a proxy). +pub async fn build_readonly_server( + config: &Configuration, + backend_handler: Backend, + server_builder: ServerBuilder, +) -> Result +where + Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static, +{ + let port = match config.http_readonly_port { + Some(port) => port, + None => return Ok(server_builder), + }; + let host = config.http_readonly_host.clone(); + let verbose = config.verbose; + let deny_attributes: HashSet = config + .http_readonly_deny_attributes + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + info!( + "Starting the unauthenticated read-only API server on port {}", + port + ); + server_builder + .bind("readonly", (host, port), move || { + let backend_handler = backend_handler.clone(); + let deny_attributes = deny_attributes.clone(); + HttpServiceBuilder::default() + .finish(map_config( + App::new() + .wrap(actix_web::middleware::Condition::new( + verbose, + tracing_actix_web::TracingLogger::::new(), + )) + .configure(move |cfg| { + readonly_http_config(cfg, backend_handler, deny_attributes) + }), + |_| AppConfig::default(), + )) + .tcp() + }) + .with_context(|| { + format!( + "While bringing up the read-only TCP server with port {}", + port + ) + }) +} diff --git a/server/src/main.rs b/server/src/main.rs index db0aabea3..c0d7c50be 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -192,6 +192,13 @@ async fn set_up_server(config: Configuration) -> Result { actix_server::Server::build(), ) .context("while binding the LDAP server")?; + let server_builder = infra::tcp_server::build_readonly_server( + &config, + backend_handler.clone(), + server_builder, + ) + .await + .context("while binding the read-only server")?; let server_builder = infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder) .await From 440458de4d343bd6e48f081e68de8c50885a78e4 Mon Sep 17 00:00:00 2001 From: Peng Peng Date: Sun, 31 May 2026 13:19:42 +0800 Subject: [PATCH 3/5] feat(sdk): add Go SDK for event-driven LLDAP account sync Add a Go client that reconciles against LLDAP's read-only snapshot endpoint and consumes Olares' os.users / os.groups NATS triggers, delivering diffs (users, groups, memberships, and their attributes) to an idempotent OnChanges handler. User.IsAdmin() reports lldap_admin membership; ships with an example consumer and unit/smoke tests. Co-authored-by: Cursor --- clients/go/README.md | 96 ++++++++++++ clients/go/client/client.go | 179 +++++++++++++++++++++++ clients/go/client/client_test.go | 129 +++++++++++++++++ clients/go/client/diff.go | 166 +++++++++++++++++++++ clients/go/client/diff_test.go | 176 ++++++++++++++++++++++ clients/go/client/events.go | 30 ++++ clients/go/client/events_test.go | 26 ++++ clients/go/client/handler.go | 17 +++ clients/go/client/run.go | 225 +++++++++++++++++++++++++++++ clients/go/client/run_test.go | 113 +++++++++++++++ clients/go/client/types.go | 104 +++++++++++++ clients/go/example/main.go | 134 +++++++++++++++++ clients/go/go.mod | 14 ++ clients/go/go.sum | 14 ++ clients/go/test/README.md | 32 ++++ clients/go/test/docker-compose.yml | 54 +++++++ clients/go/test/smoke_test.sh | 158 ++++++++++++++++++++ 17 files changed, 1667 insertions(+) create mode 100644 clients/go/README.md create mode 100644 clients/go/client/client.go create mode 100644 clients/go/client/client_test.go create mode 100644 clients/go/client/diff.go create mode 100644 clients/go/client/diff_test.go create mode 100644 clients/go/client/events.go create mode 100644 clients/go/client/events_test.go create mode 100644 clients/go/client/handler.go create mode 100644 clients/go/client/run.go create mode 100644 clients/go/client/run_test.go create mode 100644 clients/go/client/types.go create mode 100644 clients/go/example/main.go create mode 100644 clients/go/go.mod create mode 100644 clients/go/go.sum create mode 100644 clients/go/test/README.md create mode 100644 clients/go/test/docker-compose.yml create mode 100755 clients/go/test/smoke_test.sh diff --git a/clients/go/README.md b/clients/go/README.md new file mode 100644 index 000000000..cf333d652 --- /dev/null +++ b/clients/go/README.md @@ -0,0 +1,96 @@ +# LLDAP Go SDK + +A small Go SDK for syncing accounts from LLDAP into platform components +(e.g. a file server that provisions Samba/POSIX accounts), replacing the need +to watch a Kubernetes User CRD. + +It implements the "event trigger + full snapshot + idempotent reconcile" model. +The whole SDK lives in one package, `client`: + +- a `Client` that reads the snapshot from LLDAP's **unauthenticated read-only + port** (`GET /readonly/snapshot`) — no login, no credentials, +- a blocking `Init` for the startup reconcile, +- a `Run` loop that consumes Olares' `os.users` / `os.groups` NATS JetStream + events as triggers, diffs each new snapshot against the previous one, and + delivers the `Changes` to a single `OnChanges` handler, plus a periodic + safety-net resync. + +The read-only port carries no auth: access must be enforced by the network layer +(e.g. a K8s NetworkPolicy). The server must have it enabled +(`LLDAP_HTTP_READONLY_PORT`), and `BaseURL` must point at it. + +In Olares these events are published by app-service today (LLDAP itself does not +emit them). One current limitation: the owner cannot be distinguished from an +admin, since both map to the `lldap_admin` group and the role lives only in the +K8s User CR. + +Both `Init` (return value) and `Run` (`OnChanges`) speak the same `Changes` +type, so you write one apply function and reuse it for startup and streaming. + +## Usage + +Call `Init` with your store's current users/groups to do the blocking startup +reconcile; it returns the `Changes` to apply and records the baseline. Then call +`Run`, handing the same apply function to `OnChanges`: + +```go +c := client.New(client.Config{ + BaseURL: "http://lldap:17171", // LLDAP's read-only port +}) + +// apply applies one batch of changes to your store (create/remove accounts, +// u.Attributes["uidNumber"], ...). Return nil ONLY when the whole batch is +// applied; returning an error makes Run re-deliver the same (and newer) changes +// next reconcile, so apply MUST be idempotent. +apply := func(ctx context.Context, ch client.Changes) error { + for _, u := range ch.UsersAdded { /* ... */ } + for _, m := range ch.MembersRemoved { /* ... */ } + // ... UsersUpdated / UsersRemoved / Groups* / MembersAdded ... + return nil +} + +// 1. Startup: reconcile LLDAP against your own store. Pass the users/groups you +// already have (nil/empty when your store starts empty); Init returns the +// changes to apply and records the baseline. +changes, err := c.Init(ctx, dbUsers, dbGroups) +if err != nil { /* ... */ } +if err := apply(ctx, changes); err != nil { /* ... */ } + +// 2. Stream subsequent changes through the same apply function. +err = c.Run(ctx, client.Options{ + NATSURL: "nats://nats.os-platform:4222", + NATSUser: "lldap", + NATSPass: "secret", + Durable: "files-samba", + ResyncInterval: 5 * time.Minute, + OnChanges: apply, +}) +``` + +## Notes + +- Events are lightweight triggers (the payload is never decoded); the read-only + snapshot is the source of truth. `Run` consumes `os.users`/`os.groups` on the + `os-stream` stream by default; override via `Options.StreamName`/`Subjects`. +- `Options.Durable` is required and must be unique per consuming app: two apps + sharing a durable name on the same stream would split each other's triggers. +- `User.IsAdmin()` reports membership in the built-in `lldap_admin` group. In + Olares both owner and admin map to that group, so the owner cannot yet be + distinguished (it lives only in the K8s User CR, not LLDAP). +- `OnChanges` must return nil only when the whole batch is applied. Returning an + error keeps the baseline and re-delivers the same (and newer) `Changes` on the + next reconcile, so the handler **must be idempotent** (delivery is at-least-once + and failed batches are retried). +- A safe apply order within a batch: create users, then groups, then memberships; + remove memberships, then groups, then users. Updates can go anywhere. +- A user's group membership change surfaces in `MembersAdded/MembersRemoved`, not + `UsersUpdated` (which only covers the user's own fields/attributes). The members + of a newly added/removed group appear there too, so a batch is self-contained. +- The first `Run` reconcile diffs against the baseline recorded by `Init`, so + changes that happened during startup are delivered too. Without a prior `Init`, + the entire current state is reported as additions. +- POSIX `uidNumber`/`gidNumber` are expected as LLDAP user attributes and are + surfaced in `User.Attributes`. + +See [example](example) for a runnable program and [test](test) for a local +docker-compose smoke test. diff --git a/clients/go/client/client.go b/clients/go/client/client.go new file mode 100644 index 000000000..957a45e08 --- /dev/null +++ b/clients/go/client/client.go @@ -0,0 +1,179 @@ +// Package client is the LLDAP Go SDK. It fetches a full account snapshot for +// reconciliation and (via Run) consumes Olares' os.users / os.groups NATS +// JetStream events as reconcile triggers, dispatching the diffs to the +// OnChanges handler. The NATS conventions live in events.go. +// +// The snapshot is read from LLDAP's unauthenticated read-only port +// (GET /readonly/snapshot): no login, no Authorization header. Access control +// is expected to be enforced by the network layer (e.g. a K8s NetworkPolicy), +// and the port must be enabled on the server (LLDAP_HTTP_READONLY_PORT). +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Config configures a Client. +type Config struct { + // BaseURL is LLDAP's read-only HTTP base, e.g. "http://lldap:17171". It must + // point at the unauthenticated read-only port (LLDAP_HTTP_READONLY_PORT). + BaseURL string + // HTTPClient is optional; a sensible default is used when nil. + HTTPClient *http.Client +} + +// Client talks to a single LLDAP instance. +// +// Snapshot is safe to call from multiple goroutines, but Init/Run drive one +// consumer loop: a Client records a single baseline (Init) that Run reconciles +// against, so use one Client instance per consumer loop and do not run Run more +// than once concurrently on the same Client. +type Client struct { + cfg Config + http *http.Client + + mu sync.Mutex + baseline Snapshot + initialized bool +} + +// Init does the blocking startup reconcile: it fetches a full snapshot, diffs +// it against the state the consumer already has (users/groups loaded from its +// own store), and returns the Changes to apply. It also records the fetched +// snapshot as the baseline that Run diffs its first reconcile against, so +// changes that happen between Init and Run are delivered as Handler callbacks. +// +// Pass nil/empty slices when the consumer's store starts empty; the entire +// current state is then returned as additions. +func (c *Client) Init(ctx context.Context, users []User, groups []Group) (Changes, error) { + snap, err := c.Snapshot(ctx) + if err != nil { + return Changes{}, err + } + changes := computeChanges(Snapshot{Users: users, Groups: groups}, snap) + c.mu.Lock() + c.baseline = snap + c.initialized = true + c.mu.Unlock() + return changes, nil +} + +// New creates a Client. +func New(cfg Config) *Client { + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + return &Client{cfg: cfg, http: httpClient} +} + +// snapshotData is the read-only endpoint's JSON body: the same users/groups +// fields the GraphQL snapshot exposes. +type snapshotData struct { + Users []struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + CreationDate string `json:"creationDate"` + UUID string `json:"uuid"` + Groups []struct { + DisplayName string `json:"displayName"` + } `json:"groups"` + Attributes []struct { + Name string `json:"name"` + Value []string `json:"value"` + } `json:"attributes"` + } `json:"users"` + Groups []struct { + ID int `json:"id"` + DisplayName string `json:"displayName"` + Users []struct { + ID string `json:"id"` + } `json:"users"` + Attributes []struct { + Name string `json:"name"` + Value []string `json:"value"` + } `json:"attributes"` + } `json:"groups"` +} + +// Snapshot fetches all users and groups in a single GET against the read-only +// port. No credentials are sent. +func (c *Client) Snapshot(ctx context.Context) (Snapshot, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.BaseURL+"/readonly/snapshot", nil) + if err != nil { + return Snapshot{}, err + } + resp, err := c.http.Do(req) + if err != nil { + return Snapshot{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return Snapshot{}, err + } + if resp.StatusCode != http.StatusOK { + return Snapshot{}, fmt.Errorf("lldap readonly http %d: %s", resp.StatusCode, string(body)) + } + var data snapshotData + if err := json.Unmarshal(body, &data); err != nil { + return Snapshot{}, err + } + return buildSnapshot(data), nil +} + +func buildSnapshot(resp snapshotData) Snapshot { + snap := Snapshot{ + Users: make([]User, 0, len(resp.Users)), + Groups: make([]Group, 0, len(resp.Groups)), + } + for _, u := range resp.Users { + groups := make([]string, 0, len(u.Groups)) + for _, g := range u.Groups { + groups = append(groups, g.DisplayName) + } + attrs := make(map[string][]string, len(u.Attributes)) + for _, a := range u.Attributes { + attrs[a.Name] = a.Value + } + snap.Users = append(snap.Users, User{ + ID: u.ID, + Email: u.Email, + DisplayName: u.DisplayName, + FirstName: u.FirstName, + LastName: u.LastName, + CreationDate: u.CreationDate, + UUID: u.UUID, + Groups: groups, + Attributes: attrs, + }) + } + for _, g := range resp.Groups { + members := make([]string, 0, len(g.Users)) + for _, m := range g.Users { + members = append(members, m.ID) + } + attrs := make(map[string][]string, len(g.Attributes)) + for _, a := range g.Attributes { + attrs[a.Name] = a.Value + } + snap.Groups = append(snap.Groups, Group{ + ID: g.ID, + DisplayName: g.DisplayName, + Members: members, + Attributes: attrs, + }) + } + return snap +} diff --git a/clients/go/client/client_test.go b/clients/go/client/client_test.go new file mode 100644 index 000000000..875d22db3 --- /dev/null +++ b/clients/go/client/client_test.go @@ -0,0 +1,129 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// snapshotJSON is the read-only endpoint body: users/groups at the top level +// (the same fields GraphQL exposes, without the GraphQL `data` envelope). +const snapshotJSON = `{ + "users": [ + { + "id": "alice", + "email": "alice@example.com", + "displayName": "Alice", + "firstName": "Al", + "lastName": "Ice", + "creationDate": "2026-01-01T00:00:00Z", + "uuid": "uuid-1", + "groups": [{"displayName": "admin"}, {"displayName": "staff"}], + "attributes": [{"name": "uidNumber", "value": ["1001"]}] + } + ], + "groups": [ + {"id": 3, "displayName": "admin", "users": [{"id": "alice"}, {"id": "bob"}], + "attributes": [{"name": "club", "value": ["chess"]}]} + ] +}` + +// newServer returns an httptest server serving the read-only snapshot endpoint. +func newServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/readonly/snapshot", handler) + return httptest.NewServer(mux) +} + +func TestSnapshotFlattening(t *testing.T) { + srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(snapshotJSON)) + }) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL}) + snap, err := c.Snapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + + if len(snap.Users) != 1 { + t.Fatalf("want 1 user, got %d", len(snap.Users)) + } + u := snap.Users[0] + if u.ID != "alice" || u.Email != "alice@example.com" || u.UUID != "uuid-1" { + t.Fatalf("scalar user fields wrong: %+v", u) + } + if len(u.Groups) != 2 || u.Groups[0] != "admin" || u.Groups[1] != "staff" { + t.Fatalf("group displayNames not flattened: %+v", u.Groups) + } + if got := u.Attributes["uidNumber"]; len(got) != 1 || got[0] != "1001" { + t.Fatalf("attributes not flattened: %+v", u.Attributes) + } + if len(snap.Groups) != 1 { + t.Fatalf("want 1 group, got %d", len(snap.Groups)) + } + g := snap.Groups[0] + if g.ID != 3 || g.DisplayName != "admin" { + t.Fatalf("group scalar fields wrong: %+v", g) + } + if len(g.Members) != 2 || g.Members[0] != "alice" || g.Members[1] != "bob" { + t.Fatalf("group members not flattened: %+v", g.Members) + } + if got := g.Attributes["club"]; len(got) != 1 || got[0] != "chess" { + t.Fatalf("group attributes not flattened: %+v", g.Attributes) + } +} + +func TestInitReturnsDiffAndSetsBaseline(t *testing.T) { + srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(snapshotJSON)) + }) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL}) + + // The store already has alice exactly as LLDAP has her, so Init should only + // report the admin group (id 3) as new, with no user changes. + existing := User{ + ID: "alice", + Email: "alice@example.com", + DisplayName: "Alice", + FirstName: "Al", + LastName: "Ice", + Groups: []string{"admin", "staff"}, + Attributes: map[string][]string{"uidNumber": {"1001"}}, + } + changes, err := c.Init(context.Background(), []User{existing}, nil) + if err != nil { + t.Fatalf("init: %v", err) + } + if len(changes.UsersAdded) != 0 || len(changes.UsersUpdated) != 0 || len(changes.UsersRemoved) != 0 { + t.Fatalf("expected no user changes, got %+v", changes) + } + if len(changes.GroupsAdded) != 1 || changes.GroupsAdded[0].ID != 3 { + t.Fatalf("expected admin group added, got %+v", changes.GroupsAdded) + } + + c.mu.Lock() + baseline := c.baseline + c.mu.Unlock() + if len(baseline.Users) != 1 || len(baseline.Groups) != 1 { + t.Fatalf("baseline not recorded: %+v", baseline) + } +} + +func TestSnapshotHTTPErrorReturned(t *testing.T) { + srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("nope")) + }) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL}) + if _, err := c.Snapshot(context.Background()); err == nil { + t.Fatal("expected error from non-200 response") + } +} diff --git a/clients/go/client/diff.go b/clients/go/client/diff.go new file mode 100644 index 000000000..1dd1910dc --- /dev/null +++ b/clients/go/client/diff.go @@ -0,0 +1,166 @@ +package client + +import "sort" + +// computeChanges diffs prev -> next. Membership changes are emitted for every +// group: members of a newly added group surface as MembersAdded and members of +// a removed group as MembersRemoved, in addition to the group appearing in +// GroupsAdded/GroupsRemoved. +func computeChanges(prev, next Snapshot) Changes { + var c Changes + + prevUsers := indexUsers(prev.Users) + nextUsers := indexUsers(next.Users) + for id, nu := range nextUsers { + if pu, ok := prevUsers[id]; !ok { + c.UsersAdded = append(c.UsersAdded, nu) + } else if userChanged(pu, nu) { + c.UsersUpdated = append(c.UsersUpdated, UserUpdate{Old: pu, New: nu}) + } + } + for id, pu := range prevUsers { + if _, ok := nextUsers[id]; !ok { + c.UsersRemoved = append(c.UsersRemoved, pu) + } + } + + prevGroups := indexGroups(prev.Groups) + nextGroups := indexGroups(next.Groups) + for id, ng := range nextGroups { + pg, ok := prevGroups[id] + if !ok { + c.GroupsAdded = append(c.GroupsAdded, ng) + // A brand-new group's members are all new memberships. + for _, uid := range ng.Members { + c.MembersAdded = append(c.MembersAdded, GroupMembership{Group: ng, UserID: uid}) + } + continue + } + if groupChanged(pg, ng) { + c.GroupsUpdated = append(c.GroupsUpdated, GroupUpdate{Old: pg, New: ng}) + } + added, removed := diffMembers(pg.Members, ng.Members) + for _, uid := range added { + c.MembersAdded = append(c.MembersAdded, GroupMembership{Group: ng, UserID: uid}) + } + for _, uid := range removed { + c.MembersRemoved = append(c.MembersRemoved, GroupMembership{Group: ng, UserID: uid}) + } + } + for id, pg := range prevGroups { + if _, ok := nextGroups[id]; !ok { + c.GroupsRemoved = append(c.GroupsRemoved, pg) + // A removed group's members are all lost memberships. + for _, uid := range pg.Members { + c.MembersRemoved = append(c.MembersRemoved, GroupMembership{Group: pg, UserID: uid}) + } + } + } + + c.sort() + return c +} + +// sort makes the output deterministic (maps iterate in random order). +func (c *Changes) sort() { + sort.Slice(c.UsersAdded, func(i, j int) bool { return c.UsersAdded[i].ID < c.UsersAdded[j].ID }) + sort.Slice(c.UsersUpdated, func(i, j int) bool { return c.UsersUpdated[i].New.ID < c.UsersUpdated[j].New.ID }) + sort.Slice(c.UsersRemoved, func(i, j int) bool { return c.UsersRemoved[i].ID < c.UsersRemoved[j].ID }) + sort.Slice(c.GroupsAdded, func(i, j int) bool { return c.GroupsAdded[i].ID < c.GroupsAdded[j].ID }) + sort.Slice(c.GroupsUpdated, func(i, j int) bool { return c.GroupsUpdated[i].New.ID < c.GroupsUpdated[j].New.ID }) + sort.Slice(c.GroupsRemoved, func(i, j int) bool { return c.GroupsRemoved[i].ID < c.GroupsRemoved[j].ID }) + sort.Slice(c.MembersAdded, membershipLess(c.MembersAdded)) + sort.Slice(c.MembersRemoved, membershipLess(c.MembersRemoved)) +} + +func membershipLess(m []GroupMembership) func(i, j int) bool { + return func(i, j int) bool { + if m[i].Group.ID != m[j].Group.ID { + return m[i].Group.ID < m[j].Group.ID + } + return m[i].UserID < m[j].UserID + } +} + +func indexUsers(users []User) map[string]User { + m := make(map[string]User, len(users)) + for _, u := range users { + m[u.ID] = u + } + return m +} + +func indexGroups(groups []Group) map[int]Group { + m := make(map[int]Group, len(groups)) + for _, g := range groups { + m[g.ID] = g + } + return m +} + +// userChanged reports whether a user's own fields changed. Group membership is +// intentionally excluded (it surfaces via the member callbacks); immutable +// CreationDate/UUID are ignored. +func userChanged(a, b User) bool { + if a.Email != b.Email || a.DisplayName != b.DisplayName || + a.FirstName != b.FirstName || a.LastName != b.LastName { + return true + } + return !attrsEqual(a.Attributes, b.Attributes) +} + +func groupChanged(a, b Group) bool { + return a.DisplayName != b.DisplayName || !attrsEqual(a.Attributes, b.Attributes) +} + +func attrsEqual(a, b map[string][]string) bool { + if len(a) != len(b) { + return false + } + for k, av := range a { + bv, ok := b[k] + if !ok || !stringSliceEqualUnordered(av, bv) { + return false + } + } + return true +} + +func stringSliceEqualUnordered(a, b []string) bool { + if len(a) != len(b) { + return false + } + ac := append([]string(nil), a...) + bc := append([]string(nil), b...) + sort.Strings(ac) + sort.Strings(bc) + for i := range ac { + if ac[i] != bc[i] { + return false + } + } + return true +} + +// diffMembers returns members added to and removed from a group (by user id). +func diffMembers(prev, next []string) (added, removed []string) { + prevSet := make(map[string]struct{}, len(prev)) + for _, u := range prev { + prevSet[u] = struct{}{} + } + nextSet := make(map[string]struct{}, len(next)) + for _, u := range next { + nextSet[u] = struct{}{} + } + for u := range nextSet { + if _, ok := prevSet[u]; !ok { + added = append(added, u) + } + } + for u := range prevSet { + if _, ok := nextSet[u]; !ok { + removed = append(removed, u) + } + } + return added, removed +} diff --git a/clients/go/client/diff_test.go b/clients/go/client/diff_test.go new file mode 100644 index 000000000..75b1b2531 --- /dev/null +++ b/clients/go/client/diff_test.go @@ -0,0 +1,176 @@ +package client + +import "testing" + +func mkUser(id, email string) User { + return User{ID: id, Email: email, DisplayName: id} +} + +func mkSnap(users []User, groups []Group) Snapshot { + return Snapshot{Users: users, Groups: groups} +} + +func TestComputeChangesUsers(t *testing.T) { + alice := mkUser("alice", "alice@example.com") + bob := mkUser("bob", "bob@example.com") + + prev := mkSnap([]User{alice}, nil) + next := mkSnap([]User{ + {ID: "alice", Email: "alice@new.com", DisplayName: "alice"}, // updated + bob, // added + }, nil) + + c := computeChanges(prev, next) + + if len(c.UsersAdded) != 1 || c.UsersAdded[0].ID != "bob" { + t.Fatalf("expected bob added, got %+v", c.UsersAdded) + } + if len(c.UsersUpdated) != 1 || c.UsersUpdated[0].New.Email != "alice@new.com" { + t.Fatalf("expected alice updated, got %+v", c.UsersUpdated) + } + if len(c.UsersRemoved) != 0 { + t.Fatalf("expected no removals, got %+v", c.UsersRemoved) + } +} + +func TestComputeChangesUserRemoved(t *testing.T) { + c := computeChanges( + mkSnap([]User{mkUser("alice", "a@x"), mkUser("bob", "b@x")}, nil), + mkSnap([]User{mkUser("alice", "a@x")}, nil), + ) + if len(c.UsersRemoved) != 1 || c.UsersRemoved[0].ID != "bob" { + t.Fatalf("expected bob removed, got %+v", c.UsersRemoved) + } + if len(c.UsersAdded) != 0 || len(c.UsersUpdated) != 0 { + t.Fatalf("unexpected add/update: %+v", c) + } +} + +func TestComputeChangesUserAttributesUpdate(t *testing.T) { + a := mkUser("alice", "a@x") + a.Attributes = map[string][]string{"uidNumber": {"1001"}} + b := mkUser("alice", "a@x") + b.Attributes = map[string][]string{"uidNumber": {"1002"}} + + c := computeChanges(mkSnap([]User{a}, nil), mkSnap([]User{b}, nil)) + if len(c.UsersUpdated) != 1 { + t.Fatalf("expected attribute change to be an update, got %+v", c.UsersUpdated) + } +} + +func TestComputeChangesGroupOnlyChangeIsNotUserUpdate(t *testing.T) { + // Same user fields, only the User.Groups slice differs: must NOT be a user + // update (membership surfaces via group member callbacks instead). + a := mkUser("alice", "a@x") + a.Groups = []string{"staff"} + b := mkUser("alice", "a@x") + b.Groups = []string{"staff", "admin"} + + c := computeChanges(mkSnap([]User{a}, nil), mkSnap([]User{b}, nil)) + if len(c.UsersUpdated) != 0 { + t.Fatalf("group-only change must not be a user update, got %+v", c.UsersUpdated) + } +} + +func TestComputeChangesGroups(t *testing.T) { + prev := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff"}}) + next := mkSnap(nil, []Group{ + {ID: 1, DisplayName: "staff-renamed"}, // updated + {ID: 2, DisplayName: "admin"}, // added + }) + + c := computeChanges(prev, next) + if len(c.GroupsAdded) != 1 || c.GroupsAdded[0].ID != 2 { + t.Fatalf("expected group 2 added, got %+v", c.GroupsAdded) + } + if len(c.GroupsUpdated) != 1 || c.GroupsUpdated[0].New.DisplayName != "staff-renamed" { + t.Fatalf("expected group 1 renamed, got %+v", c.GroupsUpdated) + } +} + +func TestComputeChangesGroupAttributesUpdate(t *testing.T) { + prev := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Attributes: map[string][]string{"club": {"a"}}}}) + next := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Attributes: map[string][]string{"club": {"b"}}}}) + + c := computeChanges(prev, next) + if len(c.GroupsUpdated) != 1 { + t.Fatalf("expected group attribute change to be an update, got %+v", c.GroupsUpdated) + } +} + +func TestComputeChangesGroupRemoved(t *testing.T) { + c := computeChanges( + mkSnap(nil, []Group{{ID: 1, DisplayName: "staff"}}), + mkSnap(nil, nil), + ) + if len(c.GroupsRemoved) != 1 || c.GroupsRemoved[0].ID != 1 { + t.Fatalf("expected group 1 removed, got %+v", c.GroupsRemoved) + } +} + +func TestComputeChangesMembership(t *testing.T) { + prev := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Members: []string{"alice"}}}) + next := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Members: []string{"bob"}}}) + + c := computeChanges(prev, next) + if len(c.MembersAdded) != 1 || c.MembersAdded[0].UserID != "bob" || c.MembersAdded[0].Group.ID != 1 { + t.Fatalf("expected bob added to group 1, got %+v", c.MembersAdded) + } + if len(c.MembersRemoved) != 1 || c.MembersRemoved[0].UserID != "alice" { + t.Fatalf("expected alice removed from group 1, got %+v", c.MembersRemoved) + } + if len(c.GroupsUpdated) != 0 { + t.Fatalf("membership change must not be a group update, got %+v", c.GroupsUpdated) + } +} + +func TestComputeChangesNoChange(t *testing.T) { + s := mkSnap( + []User{mkUser("alice", "a@x")}, + []Group{{ID: 1, DisplayName: "staff", Members: []string{"alice"}}}, + ) + if c := computeChanges(s, s); !c.IsEmpty() { + t.Fatalf("expected no changes, got %+v", c) + } +} + +func TestComputeChangesBaselineEmptyReportsAllAdded(t *testing.T) { + next := mkSnap( + []User{mkUser("alice", "a@x")}, + []Group{{ID: 1, DisplayName: "staff"}}, + ) + c := computeChanges(Snapshot{}, next) + if len(c.UsersAdded) != 1 || len(c.GroupsAdded) != 1 { + t.Fatalf("empty baseline should report everything as added, got %+v", c) + } +} + +func TestComputeChangesAddedGroupReportsMembers(t *testing.T) { + // A brand-new group with members must surface those members via + // MembersAdded, so a consumer wiring only the member callbacks sees them. + prev := mkSnap(nil, nil) + next := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Members: []string{"alice", "bob"}}}) + + c := computeChanges(prev, next) + if len(c.GroupsAdded) != 1 { + t.Fatalf("expected group added, got %+v", c.GroupsAdded) + } + if len(c.MembersAdded) != 2 || + c.MembersAdded[0].UserID != "alice" || c.MembersAdded[1].UserID != "bob" { + t.Fatalf("expected alice+bob memberships, got %+v", c.MembersAdded) + } +} + +func TestComputeChangesRemovedGroupReportsMembers(t *testing.T) { + prev := mkSnap(nil, []Group{{ID: 1, DisplayName: "staff", Members: []string{"alice", "bob"}}}) + next := mkSnap(nil, nil) + + c := computeChanges(prev, next) + if len(c.GroupsRemoved) != 1 { + t.Fatalf("expected group removed, got %+v", c.GroupsRemoved) + } + if len(c.MembersRemoved) != 2 || + c.MembersRemoved[0].UserID != "alice" || c.MembersRemoved[1].UserID != "bob" { + t.Fatalf("expected alice+bob memberships removed, got %+v", c.MembersRemoved) + } +} diff --git a/clients/go/client/events.go b/clients/go/client/events.go new file mode 100644 index 000000000..816a8b167 --- /dev/null +++ b/clients/go/client/events.go @@ -0,0 +1,30 @@ +package client + +// This file defines the NATS conventions the SDK consumes. In Olares the +// account event bus uses the os.users / os.groups subjects on the os-stream +// JetStream stream (published by app-service today). +// +// Events are treated purely as a trigger: receiving any message merely tells +// Run to re-fetch a full LLDAP snapshot and reconcile, so the payload is never +// decoded and its schema does not affect correctness. The snapshot is the +// source of truth. + +// Default JetStream conventions, overridable via Options. +const ( + // DefaultStreamName is the Olares system stream that carries os.* events. + DefaultStreamName = "os-stream" +) + +// Olares account event subjects consumed as reconcile triggers. +const ( + SubjectUsers = "os.users" + SubjectGroups = "os.groups" +) + +// DefaultSubjects are the subjects the consumer filters on by default. +var DefaultSubjects = []string{SubjectUsers, SubjectGroups} + +// DefaultStreamSubjects is used only when the SDK has to create the stream +// itself (e.g. local dev where no os-stream exists yet). It is a superset of +// DefaultSubjects so future os.* events are captured too. +var DefaultStreamSubjects = []string{"os.>"} diff --git a/clients/go/client/events_test.go b/clients/go/client/events_test.go new file mode 100644 index 000000000..b4bf63bf6 --- /dev/null +++ b/clients/go/client/events_test.go @@ -0,0 +1,26 @@ +package client + +import "testing" + +func TestDefaultSubjectsAreOlaresConvention(t *testing.T) { + if SubjectUsers != "os.users" || SubjectGroups != "os.groups" { + t.Fatalf("unexpected subjects: %q %q", SubjectUsers, SubjectGroups) + } + if DefaultStreamName != "os-stream" { + t.Fatalf("unexpected stream name: %q", DefaultStreamName) + } + if len(DefaultSubjects) != 2 || DefaultSubjects[0] != SubjectUsers || DefaultSubjects[1] != SubjectGroups { + t.Fatalf("unexpected default subjects: %v", DefaultSubjects) + } + for _, s := range DefaultSubjects { + var covered bool + for _, ss := range DefaultStreamSubjects { + if ss == "os.>" { + covered = true + } + } + if !covered { + t.Fatalf("subject %q not covered by stream subjects %v", s, DefaultStreamSubjects) + } + } +} diff --git a/clients/go/client/handler.go b/clients/go/client/handler.go new file mode 100644 index 000000000..a88d3cf8d --- /dev/null +++ b/clients/go/client/handler.go @@ -0,0 +1,17 @@ +package client + +import "context" + +// ChangesHandler applies one reconcile's worth of diff to the consumer's own +// store. It is the single callback Run invokes (and the natural shape to also +// hand the Changes that Init returns), so a consumer writes one apply function +// and reuses it for both startup and streaming. +// +// Contract: +// - Return nil ONLY when the entire batch has been applied successfully. +// - Returning an error makes Run keep its baseline and re-deliver the SAME +// (and any newer) changes on the next reconcile, instead of dropping them. +// - Therefore the handler MUST be idempotent: applying the same Changes more +// than once must be safe (delivery is at-least-once and failed batches are +// retried). +type ChangesHandler func(ctx context.Context, c Changes) error diff --git a/clients/go/client/run.go b/clients/go/client/run.go new file mode 100644 index 000000000..c4eaf0b95 --- /dev/null +++ b/clients/go/client/run.go @@ -0,0 +1,225 @@ +package client + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// Options configures Run. +type Options struct { + // NATSURL e.g. "nats://nats.os-platform:4222". + NATSURL string + NATSUser string + NATSPass string + // Durable names the JetStream consumer. Required, and must be a stable name + // unique per app: two apps sharing a durable name on the same stream would + // split each other's triggers. + Durable string + // StreamName is the JetStream stream to consume. Defaults to + // DefaultStreamName ("os-stream"). + StreamName string + // Subjects are the subjects to filter on. Defaults to DefaultSubjects + // (os.users, os.groups). Events are only triggers, so the payload is + // irrelevant; any message on these subjects causes a reconcile. + Subjects []string + // ResyncInterval is the safety-net full reconcile period. Defaults to 5m. + ResyncInterval time.Duration + // OnChanges receives the diff for each reconcile and must be set. See + // ChangesHandler for the nil/error and idempotency contract. + OnChanges ChangesHandler + // Logger is optional. + Logger *log.Logger +} + +// Run blocks until ctx is cancelled. It connects to NATS JetStream, binds a +// durable consumer to the configured stream (default os-stream) filtering the +// configured subjects (default os.users, os.groups), then on every event +// (coalesced) and on a periodic timer fetches a full snapshot, diffs it against +// the previous one, and dispatches the changes to opts.OnChanges. +// +// Events are only triggers: the payload is never decoded, so the snapshot is +// the single source of truth and any schema is tolerated. +// +// The first reconcile diffs against the baseline recorded by Init, so changes +// during startup are delivered to OnChanges. Without a prior Init the baseline +// is empty and the entire current state is reported as additions. +// +// Run drives a single consumer loop and advances the Client's baseline as it +// goes, so call it once per Client; do not run it concurrently on the same +// Client instance. +func (c *Client) Run(ctx context.Context, opts Options) error { + if opts.OnChanges == nil { + return fmt.Errorf("client: Options.OnChanges must be set") + } + if opts.Durable == "" { + return fmt.Errorf("client: Options.Durable must be set (a stable name unique per app)") + } + if opts.ResyncInterval <= 0 { + opts.ResyncInterval = 5 * time.Minute + } + if opts.StreamName == "" { + opts.StreamName = DefaultStreamName + } + if len(opts.Subjects) == 0 { + opts.Subjects = DefaultSubjects + } + logger := opts.Logger + if logger == nil { + logger = log.Default() + } + + // Keep retrying both the initial connect and any later drop: the periodic + // resync is the safety net, but events should resume on their own once NATS + // is reachable again instead of requiring a restart. + natsOpts := []nats.Option{ + nats.RetryOnFailedConnect(true), + nats.MaxReconnects(-1), + nats.ReconnectWait(2 * time.Second), + nats.DisconnectErrHandler(func(_ *nats.Conn, err error) { + logger.Printf("client: nats disconnected: %v", err) + }), + nats.ReconnectHandler(func(nc *nats.Conn) { + logger.Printf("client: nats reconnected to %s", nc.ConnectedUrl()) + }), + } + if opts.NATSUser != "" { + natsOpts = append(natsOpts, nats.UserInfo(opts.NATSUser, opts.NATSPass)) + } + nc, err := nats.Connect(opts.NATSURL, natsOpts...) + if err != nil { + return fmt.Errorf("client: connect nats: %w", err) + } + defer nc.Drain() + + js, err := jetstream.New(nc) + if err != nil { + return fmt.Errorf("client: jetstream: %w", err) + } + + // With RetryOnFailedConnect the connection may still be establishing, so the + // JetStream setup can fail until NATS is reachable. Retry it (respecting ctx) + // instead of giving up, so a NATS that is not yet up at startup is tolerated. + cons, err := setupConsumer(ctx, js, opts, logger) + if err != nil { + return err + } + + // trigger is a coalescing signal: bursts collapse into a single reconcile. + trigger := make(chan struct{}, 1) + signal := func() { + select { + case trigger <- struct{}{}: + default: + } + } + + cc, err := cons.Consume( + func(msg jetstream.Msg) { + _ = msg.Ack() + logger.Printf("client: event %s", msg.Subject()) + signal() + }, + jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { + logger.Printf("client: consume error: %v", err) + }), + ) + if err != nil { + return fmt.Errorf("client: consume: %w", err) + } + defer cc.Stop() + + // prev starts at the baseline recorded by Init; the first reconcile thus + // reports what changed since the consumer's startup snapshot. + c.mu.Lock() + prev := c.baseline + initialized := c.initialized + c.mu.Unlock() + if !initialized { + logger.Printf("client: Run started without Init; the first reconcile reports the entire current state as additions") + } + step := func() { prev = c.reconcile(ctx, prev, opts.OnChanges, logger) } + + // Initial reconcile so consumers catch up from the baseline. + step() + + ticker := time.NewTicker(opts.ResyncInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + step() + case <-trigger: + step() + } + } +} + +// reconcile fetches a fresh snapshot, diffs it against prev, and applies the +// changes via onChanges. It returns the snapshot to use as the next baseline: +// next on success (or an empty diff), and prev unchanged when the snapshot +// fetch or onChanges fails, so the diff is retried on the following reconcile. +func (c *Client) reconcile(ctx context.Context, prev Snapshot, onChanges ChangesHandler, logger *log.Logger) Snapshot { + next, err := c.Snapshot(ctx) + if err != nil { + logger.Printf("client: snapshot error: %v", err) + return prev + } + changes := computeChanges(prev, next) + if changes.IsEmpty() { + return next + } + // Only advance the baseline when the whole batch was applied; otherwise keep + // prev so the next reconcile re-delivers the same diff (onChanges is + // idempotent), instead of silently dropping a failed change. + if err := onChanges(ctx, changes); err != nil { + logger.Printf("client: OnChanges error: %v", err) + return prev + } + return next +} + +// setupConsumer binds a durable consumer to the configured stream, retrying +// until it succeeds or ctx is cancelled (NATS may still be coming up at +// startup). The stream is looked up by name and only created when missing, so a +// production stream owned by another component (e.g. tapr's os-stream) is left +// untouched while a fresh local environment can still bootstrap its own. +func setupConsumer(ctx context.Context, js jetstream.JetStream, opts Options, logger *log.Logger) (jetstream.Consumer, error) { + for { + stream, err := js.Stream(ctx, opts.StreamName) + if errors.Is(err, jetstream.ErrStreamNotFound) { + stream, err = js.CreateStream(ctx, jetstream.StreamConfig{ + Name: opts.StreamName, + Subjects: DefaultStreamSubjects, + Retention: jetstream.LimitsPolicy, + MaxAge: 24 * time.Hour, + }) + } + if err == nil { + var cons jetstream.Consumer + cons, err = stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{ + Durable: opts.Durable, + AckPolicy: jetstream.AckExplicitPolicy, + DeliverPolicy: jetstream.DeliverNewPolicy, + FilterSubjects: opts.Subjects, + }) + if err == nil { + return cons, nil + } + } + + logger.Printf("client: nats setup pending (%v), retrying", err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } +} diff --git a/clients/go/client/run_test.go b/clients/go/client/run_test.go new file mode 100644 index 000000000..579108f2f --- /dev/null +++ b/clients/go/client/run_test.go @@ -0,0 +1,113 @@ +package client + +import ( + "context" + "errors" + "io" + "log" + "net/http" + "testing" +) + +var quietLogger = log.New(io.Discard, "", 0) + +func newClientServingSnapshot(t *testing.T, body string) *Client { + t.Helper() + srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + }) + t.Cleanup(srv.Close) + return New(Config{BaseURL: srv.URL}) +} + +// newClientFailingSnapshot returns a client whose read-only endpoint always +// responds with an error status, so Snapshot fails. +func newClientFailingSnapshot(t *testing.T) *Client { + t.Helper() + srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + t.Cleanup(srv.Close) + return New(Config{BaseURL: srv.URL}) +} + +func TestRunValidation(t *testing.T) { + c := New(Config{BaseURL: "http://127.0.0.1:1"}) + noop := func(context.Context, Changes) error { return nil } + + // Missing OnChanges and missing Durable must both fail fast, before any + // NATS connection is attempted. + if err := c.Run(context.Background(), Options{Durable: "app"}); err == nil { + t.Fatal("expected error when OnChanges is nil") + } + if err := c.Run(context.Background(), Options{OnChanges: noop}); err == nil { + t.Fatal("expected error when Durable is empty") + } +} + +func TestReconcileAdvancesOnSuccess(t *testing.T) { + c := newClientServingSnapshot(t, snapshotJSON) + + called := 0 + next := c.reconcile(context.Background(), Snapshot{}, func(context.Context, Changes) error { + called++ + return nil + }, quietLogger) + + if called != 1 { + t.Fatalf("onChanges should run once, got %d", called) + } + if len(next.Users) != 1 || len(next.Groups) != 1 { + t.Fatalf("baseline should advance to the fetched snapshot, got %+v", next) + } +} + +func TestReconcileKeepsBaselineOnHandlerError(t *testing.T) { + c := newClientServingSnapshot(t, snapshotJSON) + + got := c.reconcile(context.Background(), Snapshot{}, func(context.Context, Changes) error { + return errors.New("apply failed") + }, quietLogger) + + if len(got.Users) != 0 || len(got.Groups) != 0 { + t.Fatalf("baseline must stay at prev when the handler fails, got %+v", got) + } +} + +func TestReconcileSkipsHandlerOnEmptyDiff(t *testing.T) { + c := newClientServingSnapshot(t, snapshotJSON) + + // First reconcile from empty establishes the baseline. + base := c.reconcile(context.Background(), Snapshot{}, func(context.Context, Changes) error { return nil }, quietLogger) + + called := 0 + got := c.reconcile(context.Background(), base, func(context.Context, Changes) error { + called++ + return nil + }, quietLogger) + + if called != 0 { + t.Fatalf("onChanges must not run on an empty diff, got %d calls", called) + } + if len(got.Users) != 1 || len(got.Groups) != 1 { + t.Fatalf("baseline should still advance on an empty diff, got %+v", got) + } +} + +func TestReconcileKeepsBaselineOnSnapshotError(t *testing.T) { + c := newClientFailingSnapshot(t) + + prev := Snapshot{Users: []User{mkUser("keep", "k@x")}} + called := 0 + got := c.reconcile(context.Background(), prev, func(context.Context, Changes) error { + called++ + return nil + }, quietLogger) + + if called != 0 { + t.Fatalf("onChanges must not run when the snapshot fetch fails, got %d", called) + } + if len(got.Users) != 1 || got.Users[0].ID != "keep" { + t.Fatalf("baseline must stay at prev on snapshot error, got %+v", got) + } +} diff --git a/clients/go/client/types.go b/clients/go/client/types.go new file mode 100644 index 000000000..924bc4a14 --- /dev/null +++ b/clients/go/client/types.go @@ -0,0 +1,104 @@ +package client + +// This file holds the SDK's data model: the flattened LLDAP entities (User, +// Group, Snapshot) and the diff types delivered to consumers (Changes and its +// parts). The client transport lives in client.go and the diff logic in diff.go. + +// AdminGroup is the built-in LLDAP group whose members are administrators. +// +// In Olares both the owner and admins are added to this group, so the SDK can +// tell an admin from a normal user but cannot yet identify the owner: the +// owner/admin/normal distinction lives only in the K8s User CR annotation +// bytetrade.io/owner-role and is never synced to LLDAP. +const AdminGroup = "lldap_admin" + +// User is a flattened view of an LLDAP user for reconciliation. +type User struct { + ID string + Email string + DisplayName string + FirstName string + LastName string + CreationDate string + UUID string + Groups []string + // Attributes holds user-defined attributes (e.g. uidNumber/gidNumber). + Attributes map[string][]string +} + +// IsAdmin reports whether the user is an administrator, i.e. a member of the +// built-in AdminGroup. Note this is true for both Olares owners and admins; +// owners cannot currently be distinguished (see AdminGroup). +// +// Admin status comes from group membership, not a user field, so granting or +// revoking it surfaces as MembersAdded/MembersRemoved on AdminGroup, NOT as a +// UsersUpdated entry. Consumers that care about admin changes must re-check +// IsAdmin() from the member callbacks, not only on user updates. +func (u User) IsAdmin() bool { + for _, g := range u.Groups { + if g == AdminGroup { + return true + } + } + return false +} + +// Group is a flattened view of an LLDAP group. +type Group struct { + ID int + DisplayName string + Members []string + // Attributes holds group-defined attributes (the group analogue of + // User.Attributes). + Attributes map[string][]string +} + +// Snapshot is a point-in-time view of all users and groups. +type Snapshot struct { + Users []User + Groups []Group +} + +// UserUpdate is a user whose own fields changed, with the before/after values. +type UserUpdate struct{ Old, New User } + +// GroupUpdate is a group whose own fields changed, with before/after values. +type GroupUpdate struct{ Old, New Group } + +// GroupMembership identifies a user's membership in a group. +type GroupMembership struct { + Group Group + UserID string +} + +// Changes is the diff between two snapshots, grouped by kind. It is returned by +// Init and delivered to Options.OnChanges by Run. +// +// When applying a batch, a safe order is: create users, then groups, then add +// memberships; remove memberships, then groups, then users. That way a member +// is never added before its group exists, nor a group removed while it still +// has members. Updates can be applied at any point. +type Changes struct { + UsersAdded []User + UsersUpdated []UserUpdate + UsersRemoved []User + + GroupsAdded []Group + GroupsUpdated []GroupUpdate + GroupsRemoved []Group + + // MembersAdded/Removed cover users joining/leaving a group. Members of a + // newly added group are reported as MembersAdded, and members of a removed + // group as MembersRemoved, so a consumer that only wires the member + // callbacks still sees every membership change (the group itself is also + // reported via GroupsAdded/GroupsRemoved). + MembersAdded []GroupMembership + MembersRemoved []GroupMembership +} + +// IsEmpty reports whether there are no changes. +func (c Changes) IsEmpty() bool { + return len(c.UsersAdded) == 0 && len(c.UsersUpdated) == 0 && len(c.UsersRemoved) == 0 && + len(c.GroupsAdded) == 0 && len(c.GroupsUpdated) == 0 && len(c.GroupsRemoved) == 0 && + len(c.MembersAdded) == 0 && len(c.MembersRemoved) == 0 +} diff --git a/clients/go/example/main.go b/clients/go/example/main.go new file mode 100644 index 000000000..08161fa6a --- /dev/null +++ b/clients/go/example/main.go @@ -0,0 +1,134 @@ +// Command example is a runnable reference for consuming the LLDAP Go SDK. +// +// It mirrors what an app like `files` would do instead of watching a user CRD: +// +// 1. Startup (blocking): client.Init reconciles the current LLDAP state +// against the app's own store and returns the changes to apply. Here we +// start from an empty store; a real app would load its store first. +// 2. Streaming: client.Run consumes Olares' os.users / os.groups NATS events +// as triggers (the payload is never decoded) and delivers each subsequent +// diff to the same OnChanges handler (where files would incrementally +// rebuild its Samba/POSIX state). +// +// Both steps speak the same client.Changes type, so the consumer writes a +// single apply function and reuses it for startup and streaming. +// +// Admin vs normal is derived from membership in client.AdminGroup +// (lldap_admin). The owner cannot yet be distinguished from an admin: that +// distinction lives only in the K8s User CR and is not synced to LLDAP. +// +// Configure via environment variables: +// +// LLDAP_URL LLDAP read-only port (default http://localhost:17171) +// NATS_URL (default nats://localhost:4222) +// NATS_USERNAME, NATS_PASSWORD (optional) +// DURABLE (default example-app) +// RESYNC (default 30s) +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/beclab/lldap/clients/go/client" +) + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// apply applies one batch of changes to the consumer's store. A real app would +// create/remove accounts here (e.g. files rebuilding Samba/POSIX state). +// +// Contract: return nil ONLY when the whole batch is applied. Returning an error +// makes the SDK keep its baseline and re-deliver the same (and newer) changes +// next reconcile, so this function MUST be idempotent — applying the same +// Changes twice must be safe. This example only logs, so it always succeeds. +func apply(_ context.Context, ch client.Changes) error { + for _, u := range ch.UsersAdded { + // IsAdmin (membership in client.AdminGroup) is how an app decides + // whether to provision admin-only resources. Note owner and admin both + // report true; the owner cannot be told apart here. + if u.IsAdmin() { + log.Printf("admin user added: %s (would provision with admin privileges)", u.ID) + } else { + log.Printf("user added: %s", u.ID) + } + } + for _, p := range ch.UsersUpdated { + // A user's own fields changed. Re-check IsAdmin so the app keeps the + // account's privileges in sync. + log.Printf("user updated: %s (admin=%t)", p.New.ID, p.New.IsAdmin()) + } + for _, g := range ch.GroupsAdded { + log.Printf("group added: %s (id=%d, attrs=%v)", g.DisplayName, g.ID, g.Attributes) + } + for _, p := range ch.GroupsUpdated { + log.Printf("group updated: %s (id=%d, attrs=%v)", p.New.DisplayName, p.New.ID, p.New.Attributes) + } + for _, m := range ch.MembersAdded { + log.Printf("group member added: %s -> %s", m.Group.DisplayName, m.UserID) + if m.Group.DisplayName == client.AdminGroup { + log.Printf("admin granted: %s", m.UserID) + } + } + for _, m := range ch.MembersRemoved { + log.Printf("group member removed: %s -> %s", m.Group.DisplayName, m.UserID) + } + for _, g := range ch.GroupsRemoved { + log.Printf("group removed: %s (id=%d)", g.DisplayName, g.ID) + } + for _, u := range ch.UsersRemoved { + log.Printf("user removed: %s", u.ID) + } + return nil +} + +func main() { + resync, err := time.ParseDuration(env("RESYNC", "30s")) + if err != nil { + log.Fatalf("invalid RESYNC: %v", err) + } + + baseURL := env("LLDAP_URL", "http://localhost:17171") + c := client.New(client.Config{ + BaseURL: baseURL, + }) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Step 1: blocking startup reconcile. A real app passes its store's current + // users/groups; Init returns the diff to apply. Here the store starts empty, + // so everything comes back as additions. + changes, err := c.Init(ctx, nil, nil) + if err != nil { + log.Fatalf("example: init: %v", err) + } + log.Printf("init: %d users, %d groups", len(changes.UsersAdded), len(changes.GroupsAdded)) + if err := apply(ctx, changes); err != nil { + log.Fatalf("example: apply init: %v", err) + } + + // Step 2: stream subsequent changes through the same apply function. + opts := client.Options{ + NATSURL: env("NATS_URL", "nats://localhost:4222"), + NATSUser: os.Getenv("NATS_USERNAME"), + NATSPass: os.Getenv("NATS_PASSWORD"), + Durable: env("DURABLE", "example-app"), + ResyncInterval: resync, + OnChanges: apply, + } + + log.Printf("example: starting, lldap=%s nats=%s", baseURL, opts.NATSURL) + if err := c.Run(ctx, opts); err != nil && err != context.Canceled { + log.Fatalf("example: %v", err) + } +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 000000000..8e2124d75 --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,14 @@ +module github.com/beclab/lldap/clients/go + +go 1.21 + +require github.com/nats-io/nats.go v1.37.0 + +require ( + github.com/klauspost/compress v1.17.2 // indirect + github.com/nats-io/nkeys v0.4.7 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/clients/go/go.sum b/clients/go/go.sum new file mode 100644 index 000000000..f28013d68 --- /dev/null +++ b/clients/go/go.sum @@ -0,0 +1,14 @@ +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= +github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= +github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/clients/go/test/README.md b/clients/go/test/README.md new file mode 100644 index 000000000..a3ca1fd11 --- /dev/null +++ b/clients/go/test/README.md @@ -0,0 +1,32 @@ +# Local smoke test + +End-to-end verification of the LLDAP Go SDK against a real LLDAP + NATS stack. + +## What it does + +1. `docker compose up -d --build` starts: + - `nats` with JetStream enabled (`-js`) and user/pass auth. + - `lldap` built from this repo, with the unauthenticated read-only port + (`17171`) enabled and `NATS_*` env vars pointed at the `nats` service. +2. Runs the SDK [example](../example) as a consumer, reading the snapshot from + the read-only port (`17171`, no credentials). +3. Drives changes via the admin GraphQL API (`createUser`, `createGroup`, + `addUserToGroup`, `deleteUser`) and publishes the matching `os.*` NATS + triggers, then asserts the example's reconcile output reflects each change. + +## Run + +```bash +./smoke_test.sh +``` + +Requirements: `docker` (compose v2), `curl`, and `go` on PATH. The first run is +slow because it compiles the LLDAP server image from source (the read-only port +ships only in this checkout, not in any published image). + +## Notes + +- The read-only port is what the SDK consumes, so this test must build LLDAP from + source; a published image without it won't work. +- Ports 17170 (HTTP/GraphQL), 17171 (read-only), 3890 (LDAP) and 4222 (NATS) are + published to the host; make sure they are free. diff --git a/clients/go/test/docker-compose.yml b/clients/go/test/docker-compose.yml new file mode 100644 index 000000000..d9d74d2df --- /dev/null +++ b/clients/go/test/docker-compose.yml @@ -0,0 +1,54 @@ +services: + nats: + image: nats:2.10 + command: ["-js", "--user", "lldap", "--pass", "secret"] + ports: + - "4222:4222" + + # Postgres instead of SQLite: the repo's v12 migration adds an auto-increment + # column via ALTER TABLE, which SQLite rejects ("near AUTOINCREMENT"). + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: lldap + POSTGRES_PASSWORD: lldap + POSTGRES_DB: lldap + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lldap -d lldap"] + interval: 2s + timeout: 3s + retries: 30 + + lldap: + # The SDK consumer reads the unauthenticated read-only port, which ships only + # in this checkout (not in any published image), so build from source. + build: + context: ../.. # repo root + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + nats: + condition: service_started + environment: + LLDAP_JWT_SECRET: smoke_jwt_secret_please_change + LLDAP_KEY_SEED: smoke_key_seed + LLDAP_LDAP_BASE_DN: dc=example,dc=com + LLDAP_LDAP_USER_PASS: password + LLDAP_DATABASE_URL: "postgres://lldap:lldap@postgres/lldap" + LLDAP_VERBOSE: "true" + # Unauthenticated read-only snapshot port the SDK example consumes. + LLDAP_HTTP_READONLY_PORT: "17171" + NATS_HOST: nats + NATS_PORT: "4222" + NATS_USERNAME: lldap + NATS_PASSWORD: secret + ports: + - "17170:17170" + - "17171:17171" + - "3890:3890" + +# Named so the test can publish via a nats-box container joined to this network. +networks: + default: + name: lldap-smoke diff --git a/clients/go/test/smoke_test.sh b/clients/go/test/smoke_test.sh new file mode 100755 index 000000000..fc61dcf8e --- /dev/null +++ b/clients/go/test/smoke_test.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Local end-to-end smoke test for the LLDAP Go SDK. +# +# LLDAP itself does not publish events; in Olares app-service does. So this test +# mutates LLDAP via the admin GraphQL API and, like lldapctl/app-service, +# publishes the matching os.users / os.groups NATS trigger itself (via `nats pub` +# in a nats-box container). The SDK example consumes the unauthenticated +# read-only port; the test asserts its reconcile output reflects each change. +# +# Requirements: docker (with compose v2), curl, and Go on PATH. The first run is +# slow because it compiles the LLDAP server image from source. +# +# Usage: ./smoke_test.sh + +set -euo pipefail +cd "$(dirname "$0")" + +# LLDAP_URL is the admin GraphQL API the harness mutates (acting as app-service); +# READONLY_URL is the unauthenticated read-only port the SDK example consumes. +LLDAP_URL=${LLDAP_URL:-http://localhost:17170} +READONLY_URL=${READONLY_URL:-http://localhost:17171} +NATS_URL=${NATS_URL:-nats://localhost:4222} +ADMIN_USER=${ADMIN_USER:-admin} +ADMIN_PASS=${ADMIN_PASS:-password} +USER_ID="smoke-$(date +%s)" +LOG="$(pwd)/example.log" +EXAMPLE_PID="" + +cleanup() { + [[ -n "$EXAMPLE_PID" ]] && kill "$EXAMPLE_PID" 2>/dev/null || true + docker compose down -v >/dev/null 2>&1 || true + rm -f "$LOG" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + echo "--- example.log ---" >&2 + cat "$LOG" >&2 2>/dev/null || true + exit 1 +} + +token="" +gql() { + curl -fsS -X POST "$LLDAP_URL/api/graphql" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$1" +} + +# pub SUBJECT [TOPIC NAME] - emit an os.* reconcile trigger, mimicking +# app-service. Only SUBJECT matters; the SDK treats events as triggers, so the +# payload (and any TOPIC/NAME args, kept for call-site readability) is ignored. +# Runs `nats pub` in a nats-box container on the compose network. +pub() { + docker run --rm --network lldap-smoke natsio/nats-box:latest \ + nats pub --server nats://nats:4222 --user lldap --password secret \ + "$1" '{"topic":"trigger"}' >/dev/null 2>&1 || fail "failed to publish to $1" +} + +# wait_for_log NEEDLE TIMEOUT_SECONDS +wait_for_log() { + local needle="$1" timeout="$2" + for _ in $(seq 1 "$timeout"); do + grep -q -- "$needle" "$LOG" 2>/dev/null && return 0 + sleep 1 + done + return 1 +} + +echo "==> Bringing up nats + lldap (building lldap from source)" +docker compose up -d --build + +echo "==> Waiting for lldap to accept logins" +for _ in $(seq 1 90); do + token=$(curl -fsS -X POST "$LLDAP_URL/auth/simple/login" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null \ + | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' || true) + [[ -n "$token" ]] && break + sleep 2 +done +[[ -n "$token" ]] || fail "lldap did not become ready" +echo " got admin token" + +echo "==> Starting SDK example consumer" +: > "$LOG" +( + cd .. + LLDAP_URL="$READONLY_URL" \ + NATS_URL="$NATS_URL" \ + NATS_USERNAME=lldap \ + NATS_PASSWORD=secret \ + DURABLE=smoke \ + RESYNC=15s \ + go run ./example +) >"$LOG" 2>&1 & +EXAMPLE_PID=$! + +# The example does its blocking init snapshot and creates the stream/consumer. +wait_for_log "init:" 30 || fail "example never produced an initial snapshot" +echo " example is consuming" + +echo "==> Creating user $USER_ID" +gql "{\"query\":\"mutation { createUser(user: {id: \\\"$USER_ID\\\", email: \\\"$USER_ID@example.com\\\", displayName: \\\"Smoke\\\"}) { id } }\"}" >/dev/null +pub os.users Create "$USER_ID" +wait_for_log "user added: $USER_ID" 30 || fail "created user did not produce a 'user added' change" +echo " PASS: user creation observed" + +echo "==> Updating the user and asserting a 'user updated' change fires" +gql "{\"query\":\"mutation { updateUser(user: {id: \\\"$USER_ID\\\", displayName: \\\"Smoke Renamed\\\"}) { ok } }\"}" >/dev/null +pub os.users Update "$USER_ID" +wait_for_log "user updated: $USER_ID" 30 || fail "updated user did not produce a 'user updated' change" +echo " PASS: user update observed" + +echo "==> Creating a group and asserting a 'group added' change fires" +group_id=$(gql "{\"query\":\"mutation { createGroup(name: \\\"smoke-group\\\") { id } }\"}" \ + | sed -n 's/.*"id":\([0-9]*\).*/\1/p') +[[ -n "$group_id" ]] || fail "could not create group" +pub os.groups Create smoke-group +wait_for_log "group added: smoke-group (id=$group_id)" 30 || fail "created group did not produce a 'group added' change" +echo " PASS: group creation observed (group id=$group_id)" + +echo "==> Renaming the group and asserting a 'group updated' change fires" +gql "{\"query\":\"mutation { updateGroup(group: {id: $group_id, displayName: \\\"smoke-group-renamed\\\"}) { ok } }\"}" >/dev/null +pub os.groups Update smoke-group-renamed +wait_for_log "group updated: smoke-group-renamed (id=$group_id)" 30 || fail "renamed group did not produce a 'group updated' change" +echo " PASS: group update observed" + +echo "==> Adding the user to the group and asserting a 'group member added' change fires" +gql "{\"query\":\"mutation { addUserToGroup(userId: \\\"$USER_ID\\\", groupId: $group_id) { ok } }\"}" >/dev/null +pub os.groups Update "$USER_ID" +wait_for_log "group member added: smoke-group-renamed -> $USER_ID" 30 || fail "group membership did not produce a 'group member added' change" +echo " PASS: group membership observed" + +echo "==> Making the user an admin and asserting an 'admin granted' change fires" +admin_gid=$(gql "{\"query\":\"query { groups { id displayName } }\"}" \ + | sed -n 's/.*"id":\([0-9]*\),"displayName":"lldap_admin".*/\1/p') +[[ -n "$admin_gid" ]] || fail "could not resolve lldap_admin group id" +gql "{\"query\":\"mutation { addUserToGroup(userId: \\\"$USER_ID\\\", groupId: $admin_gid) { ok } }\"}" >/dev/null +pub os.groups Update "$USER_ID" +wait_for_log "admin granted: $USER_ID" 30 || fail "make-admin did not produce an 'admin granted' change" +echo " PASS: admin grant observed" + +echo "==> Deleting the user and asserting a 'user removed' change fires" +gql "{\"query\":\"mutation { deleteUser(userId: \\\"$USER_ID\\\") { ok } }\"}" >/dev/null +pub os.users Delete "$USER_ID" +wait_for_log "user removed: $USER_ID" 30 || fail "deleted user did not produce a 'user removed' change" +echo " PASS: user deletion observed" + +echo "==> Deleting the group and asserting a 'group removed' change fires" +gql "{\"query\":\"mutation { deleteGroup(groupId: $group_id) { ok } }\"}" >/dev/null +pub os.groups Delete smoke-group-renamed +wait_for_log "group removed: smoke-group-renamed (id=$group_id)" 30 || fail "deleted group did not produce a 'group removed' change" +echo " PASS: group deletion observed" + +echo "==> ALL SMOKE CHECKS PASSED" From 457fac5fa417cb368121557d46ea3d24118ff183 Mon Sep 17 00:00:00 2001 From: Peng Peng Date: Sun, 31 May 2026 13:19:42 +0800 Subject: [PATCH 4/5] chore(dev): add local LLDAP dev stack and lldapctl helper A persistent lldap + nats (JetStream) + postgres compose stack so apps can develop against LLDAP locally, plus lldapctl.sh for user/group CRUD that also publishes os.users / os.groups NATS triggers (simulating app-service). Default to the go-sync-sdk-dev branch image so the read-only port works out of the box, with avatar denylisted from the snapshot. Co-authored-by: Cursor --- clients/dev/README.md | 87 ++++++++++++ clients/dev/docker-compose.yml | 96 +++++++++++++ clients/dev/lldapctl.sh | 240 +++++++++++++++++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 clients/dev/README.md create mode 100644 clients/dev/docker-compose.yml create mode 100755 clients/dev/lldapctl.sh diff --git a/clients/dev/README.md b/clients/dev/README.md new file mode 100644 index 000000000..0211e3a2e --- /dev/null +++ b/clients/dev/README.md @@ -0,0 +1,87 @@ +# Local dev stack + +A persistent `lldap + nats` stack so an app you're building (e.g. `files`) can +connect to LLDAP and consume account events from its own `docker-compose`, with +data that survives restarts. (For the one-shot smoke test see [../go/test](../go/test).) + +## Start + +```bash +docker compose pull +docker compose up -d +``` + +On first boot LLDAP creates one admin user, **`alice`** / `password`. Add more +users with `lldapctl.sh` (below). + +| What | Address | Credentials | +| --------------- | ----------------------- | -------------------- | +| Web / GraphQL | http://localhost:17170 | `alice` / `password` | +| Read-only API | http://localhost:17171 | none (network-gated) | +| LDAP | `localhost:3890` | bind as `alice` | +| NATS | `nats://localhost:4222` | `lldap` / `secret` | + +Notes: +- The admin is `alice`, not LLDAP's default `admin`: the compose sets + `LLDAP_LDAP_USER_DN: alice`, so LLDAP bootstraps it as the admin (at least one + admin is mandatory — see `server/src/main.rs`). +- The read-only port (`17171`, `GET /readonly/snapshot`) — the endpoint the SDK + consumer reads — works out of the box: the default image is a temporary branch + build (`beclab/lldap:go-sync-sdk-dev`, see `.github/workflows/dev-image.yml`) + that includes it. Use the commented `build:` block only to test un-pushed local + changes, or override the tag with `LLDAP_IMAGE=...`. +- Data lives in the `lldap_pgdata` volume (Postgres, since SQLite rejects the v12 + migration). `docker compose down` keeps it; `down -v` wipes it for a clean DB. + +Reset to an empty DB (re-creates `alice` on next boot): + +```bash +docker compose down -v +docker compose up -d +``` + +## Writing the consumer + +The consumer reads the snapshot from LLDAP's unauthenticated read-only port +(`GET /readonly/snapshot` on `17171`) — no credentials; access is meant to be +gated by the network layer (e.g. a K8s NetworkPolicy). The default image already +exposes it (see Notes above). + +Use the single `client` package; [../go/example/main.go](../go/example/main.go) +is the template. Write one idempotent `apply(ctx, client.Changes) error` (return +nil only when the whole batch applied — see the SDK README), call +`c.Init(ctx, dbUsers, dbGroups)` at startup and apply the result, then `c.Run` +with `OnChanges: apply`. `Run` consumes `os.users` / `os.groups` as reconcile +triggers; `User.IsAdmin()` reports `lldap_admin` membership. + +Run the reference consumer on your host and watch it reconcile: + +```bash +curl -s http://localhost:17171/readonly/snapshot | jq # peek at the payload + +cd ../go +LLDAP_URL=http://localhost:17171 \ +NATS_URL=nats://localhost:4222 NATS_USERNAME=lldap NATS_PASSWORD=secret \ +go run ./example +``` + +## CRUD helper: `lldapctl.sh` + +Drives user/group changes via GraphQL and, after each mutation, publishes the +matching `os.users` / `os.groups` NATS trigger — **simulating app-service** (in +Olares app-service emits these; LLDAP itself does not). Needs only `curl`, `jq`, +and `docker`; the publish runs in a one-shot `nats-box` container and is +best-effort. + +```bash +./lldapctl.sh create-user carol # email/displayName auto-filled from the id +./lldapctl.sh make-admin carol +./lldapctl.sh remove-admin carol +./lldapctl.sh list-users +./lldapctl.sh delete-user carol +``` + +Env overrides: `LLDAP_URL`, `ADMIN_USER`, `ADMIN_PASS`, `EMAIL_DOMAIN`, +`NATS_NETWORK`, `NATS_SERVER`, `NATS_USERNAME`, `NATS_PASSWORD`, `NATS_BOX_IMAGE`. +Run `./lldapctl.sh help` for all commands. It can't set passwords (the image has +no set-password binary), but consumers only read, so test users don't need one. diff --git a/clients/dev/docker-compose.yml b/clients/dev/docker-compose.yml new file mode 100644 index 000000000..6d0962d7a --- /dev/null +++ b/clients/dev/docker-compose.yml @@ -0,0 +1,96 @@ +# Persistent local dev stack: lldap + nats (JetStream). +# +# Unlike clients/go/test (an ephemeral smoke-test stack torn down with +# `down -v`), this stack is meant to stay up so a separately developed app +# (e.g. files) can connect from its own compose via the shared `lldap-dev` +# network, and so its data survives restarts. +# +# docker compose pull && docker compose up -d + +services: + nats: + image: nats:2.10 + command: ["-js", "--user", "lldap", "--pass", "secret"] + ports: + - "4222:4222" + - "8222:8222" + networks: + - lldap-dev + restart: unless-stopped + + # Postgres instead of SQLite: the repo's v12 migration adds an auto-increment + # column via ALTER TABLE, which SQLite rejects ("near AUTOINCREMENT"). + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: lldap + POSTGRES_PASSWORD: lldap + POSTGRES_DB: lldap + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lldap -d lldap"] + interval: 2s + timeout: 3s + retries: 30 + volumes: + - lldap_pgdata:/var/lib/postgresql/data + networks: + - lldap-dev + restart: unless-stopped + + lldap: + # This is a mock LLDAP for other apps to develop against, so pull a published + # image instead of compiling Rust locally. The default is a temporary branch + # build (CI: .github/workflows/dev-image.yml) that already has the read-only + # snapshot port (17171); switch back to a released tag once the port ships in + # a real release. Override LLDAP_IMAGE to pin another tag. + image: ${LLDAP_IMAGE:-beclab/lldap:go-sync-sdk-dev} + # To test unreleased changes from this checkout instead, comment out the + # `image:` line above and uncomment this build block: + # build: + # context: ../.. # repo root, to include this branch's NATS wiring + # dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + nats: + condition: service_started + environment: + LLDAP_JWT_SECRET: dev_jwt_secret_please_change + LLDAP_KEY_SEED: dev_key_seed + LLDAP_LDAP_BASE_DN: dc=example,dc=com + # Bootstrap admin: LLDAP creates this user on first boot (when lldap_admin + # has no members), so we make it `alice` instead of the default `admin` to + # avoid a separate service account. See server/src/main.rs. + LLDAP_LDAP_USER_DN: alice + LLDAP_LDAP_USER_EMAIL: alice@example.com + LLDAP_LDAP_USER_PASS: password + LLDAP_DATABASE_URL: "postgres://lldap:lldap@postgres/lldap" + LLDAP_VERBOSE: "true" + # Unauthenticated read-only snapshot port (GET /readonly/snapshot); this is + # the endpoint the Go SDK consumes. Access control is left to the network + # layer. The default `go-sync-sdk-dev` image already has this port; an older + # released image would ignore it (use the `build:` block above instead). + LLDAP_HTTP_READONLY_PORT: "17171" + # avatar is a base64 JpegPhoto blob, useless for identity sync; keep it + # out of the snapshot. The denylist matches the stored attribute name. + LLDAP_HTTP_READONLY_DENY_ATTRIBUTES: avatar + NATS_HOST: nats + NATS_PORT: "4222" + NATS_USERNAME: lldap + NATS_PASSWORD: secret + ports: + - "17170:17170" + - "17171:17171" + - "3890:3890" + networks: + - lldap-dev + restart: unless-stopped + +volumes: + lldap_pgdata: + +# This compose owns the network and gives it a stable name; other compose +# projects join it with `external: true`. +networks: + lldap-dev: + name: lldap-dev diff --git a/clients/dev/lldapctl.sh b/clients/dev/lldapctl.sh new file mode 100755 index 000000000..b383da5c7 --- /dev/null +++ b/clients/dev/lldapctl.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# +# lldapctl.sh - small CRUD helper for the local dev LLDAP (clients/dev). +# +# It drives user/group changes via the GraphQL API so you can watch a consumer +# (the SDK example, or files) reconcile. After each mutation it also publishes +# an os.users / os.groups NATS message, simulating app-service so the SDK's +# event path works without a real cluster (best-effort: a publish failure only +# warns and never fails the mutation). +# +# The NATS publish runs `nats pub` inside a throwaway nats-box container that +# joins the dev compose network, so this script stays self-contained: it needs +# only docker + curl + jq and no LLDAP source to build. +# +# Config via env: LLDAP_URL (default http://localhost:17170), +# ADMIN_USER (default alice), ADMIN_PASS (default password), +# NATS_NETWORK (docker network to publish on, default lldap-dev), +# NATS_SERVER (NATS address within that network, default nats://nats:4222), +# NATS_USERNAME (default lldap), NATS_PASSWORD (default secret), +# NATS_BOX_IMAGE (default natsio/nats-box:latest). +# +# Focus: managing who exists and who is an admin (membership in the built-in +# lldap_admin group), not editing profile fields. +# +# Examples: +# ./lldapctl.sh create-user carol +# ./lldapctl.sh make-admin carol +# ./lldapctl.sh remove-admin carol +# ./lldapctl.sh list-users + +set -euo pipefail + +LLDAP_URL="${LLDAP_URL:-http://localhost:17170}" +ADMIN_USER="${ADMIN_USER:-alice}" +ADMIN_PASS="${ADMIN_PASS:-password}" +NATS_NETWORK="${NATS_NETWORK:-lldap-dev}" +NATS_SERVER="${NATS_SERVER:-nats://nats:4222}" +NATS_USERNAME="${NATS_USERNAME:-lldap}" +NATS_PASSWORD="${NATS_PASSWORD:-secret}" +NATS_BOX_IMAGE="${NATS_BOX_IMAGE:-natsio/nats-box:latest}" + +token="" + +need() { command -v "$1" >/dev/null 2>&1 || { echo "missing dependency: $1" >&2; exit 1; }; } + +# publish SUBJECT TOPIC [NAME] - emits an os.* trigger, mimicking app-service, +# via `nats pub` in a one-shot nats-box container on the dev compose network. +# Best-effort: a failure (docker missing, NATS/network down) only warns so the +# mutation still succeeds. The SDK treats events as triggers, so the payload is +# nominal (it mirrors app-service's {topic, payload:{...}} shape). +publish() { + local subject="$1" topic="$2" name="${3:-}" payload + command -v docker >/dev/null 2>&1 || { echo "warn: docker not found, skipping NATS publish" >&2; return 0; } + payload=$(printf '{"topic":"%s","payload":{"user":"%s","operator":"lldapctl","timestamp":"%s"}}' \ + "$topic" "$name" "$(date -u +%Y-%m-%dT%H:%M:%SZ)") + docker run --rm --network "$NATS_NETWORK" "$NATS_BOX_IMAGE" \ + nats pub --server "$NATS_SERVER" --user "$NATS_USERNAME" --password "$NATS_PASSWORD" \ + "$subject" "$payload" >/dev/null 2>&1 \ + || echo "warn: failed to publish $topic to $subject (NATS up? docker network '$NATS_NETWORK' present?)" >&2 +} + +login() { + need curl + need jq + token=$(curl -fsS -X POST "$LLDAP_URL/auth/simple/login" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg u "$ADMIN_USER" --arg p "$ADMIN_PASS" '{username:$u,password:$p}')" \ + | jq -r '.token') + [[ -n "$token" && "$token" != "null" ]] || { echo "login failed (check ADMIN_USER/ADMIN_PASS and that lldap is up)" >&2; exit 1; } +} + +ensure_login() { [[ -n "$token" ]] || login; } + +# gql QUERY [VARIABLES_JSON] -> prints the raw response JSON, exits on errors. +gql() { + local query="$1" vars="${2:-}" + [[ -n "$vars" ]] || vars='{}' + local body resp + body=$(jq -n --arg q "$query" --argjson v "$vars" '{query:$q, variables:$v}') + resp=$(curl -fsS -X POST "$LLDAP_URL/api/graphql" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body") + if echo "$resp" | jq -e '(.errors // []) | length > 0' >/dev/null 2>&1; then + echo "graphql error: $(echo "$resp" | jq -c '.errors')" >&2 + exit 1 + fi + echo "$resp" +} + +cmd_list_users() { + ensure_login + gql 'query { users { id email displayName groups { displayName } } }' | jq '.data.users' +} + +cmd_get_user() { + [[ $# -eq 1 ]] || { echo "usage: get-user " >&2; exit 1; } + ensure_login + gql 'query($id:String!){ user(userId:$id){ id email displayName firstName lastName groups{displayName} attributes{name value} } }' \ + "$(jq -n --arg id "$1" '{id:$id}')" | jq '.data.user' +} + +cmd_create_user() { + [[ $# -ge 1 ]] || { echo "usage: create-user [email] [displayName]" >&2; exit 1; } + ensure_login + local id="$1" + # Optional fields default off the id: @$EMAIL_DOMAIN and the id capitalized. + local email="${2:-${id}@${EMAIL_DOMAIN:-example.com}}" + local dn="${3:-$(printf '%s' "$id" | cut -c1 | tr '[:lower:]' '[:upper:]')$(printf '%s' "$id" | cut -c2-)}" + local vars + vars=$(jq -n --arg id "$id" --arg email "$email" --arg dn "$dn" \ + '{u: {id:$id, email:$email, displayName:$dn}}') + gql 'mutation($u:CreateUserInput!){ createUser(user:$u){ id email displayName } }' "$vars" | jq '.data.createUser' + publish os.users Create "$id" +} + +cmd_delete_user() { + [[ $# -eq 1 ]] || { echo "usage: delete-user " >&2; exit 1; } + ensure_login + gql 'mutation($id:String!){ deleteUser(userId:$id){ ok } }' "$(jq -n --arg id "$1" '{id:$id}')" | jq '.data.deleteUser' + publish os.users Delete "$1" +} + +cmd_list_groups() { + ensure_login + gql 'query { groups { id displayName users { id } } }' | jq '.data.groups' +} + +cmd_create_group() { + [[ $# -eq 1 ]] || { echo "usage: create-group " >&2; exit 1; } + ensure_login + gql 'mutation($n:String!){ createGroup(name:$n){ id displayName } }' "$(jq -n --arg n "$1" '{n:$n}')" | jq '.data.createGroup' + publish os.groups Create "$1" +} + +cmd_delete_group() { + [[ $# -eq 1 ]] || { echo "usage: delete-group " >&2; exit 1; } + require_int "$1" + ensure_login + gql 'mutation($g:Int!){ deleteGroup(groupId:$g){ ok } }' "$(jq -n --argjson g "$1" '{g:$g}')" | jq '.data.deleteGroup' + publish os.groups Delete "$1" +} + +require_int() { + [[ "$1" =~ ^[0-9]+$ ]] || { echo "groupId must be an integer, got: $1" >&2; exit 1; } +} + +cmd_add_to_group() { + [[ $# -eq 2 ]] || { echo "usage: add-to-group " >&2; exit 1; } + require_int "$2" + ensure_login + gql 'mutation($u:String!,$g:Int!){ addUserToGroup(userId:$u, groupId:$g){ ok } }' \ + "$(jq -n --arg u "$1" --argjson g "$2" '{u:$u,g:$g}')" | jq '.data.addUserToGroup' + publish os.groups Update "$1" +} + +cmd_remove_from_group() { + [[ $# -eq 2 ]] || { echo "usage: remove-from-group " >&2; exit 1; } + require_int "$2" + ensure_login + gql 'mutation($u:String!,$g:Int!){ removeUserFromGroup(userId:$u, groupId:$g){ ok } }' \ + "$(jq -n --arg u "$1" --argjson g "$2" '{u:$u,g:$g}')" | jq '.data.removeUserFromGroup' + publish os.groups Update "$1" +} + +# Resolves the id of the built-in lldap_admin group (admin role = membership). +admin_group_id() { + local gid + gid=$(gql 'query { groups { id displayName } }' \ + | jq -r '.data.groups[] | select(.displayName=="lldap_admin") | .id') + [[ -n "$gid" && "$gid" != "null" ]] || { echo "lldap_admin group not found" >&2; exit 1; } + echo "$gid" +} + +cmd_make_admin() { + [[ $# -eq 1 ]] || { echo "usage: make-admin " >&2; exit 1; } + ensure_login + cmd_add_to_group "$1" "$(admin_group_id)" +} + +cmd_remove_admin() { + [[ $# -eq 1 ]] || { echo "usage: remove-admin " >&2; exit 1; } + ensure_login + cmd_remove_from_group "$1" "$(admin_group_id)" +} + +usage() { + cat >&2 <<'EOF' +lldapctl.sh - CRUD helper for the local dev LLDAP + +Usage: ./lldapctl.sh [args] + +Users: + list-users + get-user + create-user [email] [displayName] # email/displayName default off + delete-user + +Roles (admin = membership in the built-in lldap_admin group): + make-admin + remove-admin + +Groups: + list-groups + create-group + delete-group + add-to-group + remove-from-group + +Each mutation also publishes an os.users/os.groups NATS trigger (best-effort). + +Env: LLDAP_URL (default http://localhost:17170), ADMIN_USER (alice), ADMIN_PASS (password), + EMAIL_DOMAIN (default example.com, for create-user's generated email), + NATS_NETWORK (docker network, default lldap-dev), + NATS_SERVER (default nats://nats:4222), NATS_USERNAME (lldap), NATS_PASSWORD (secret), + NATS_BOX_IMAGE (default natsio/nats-box:latest) +EOF +} + +main() { + [[ $# -ge 1 ]] || { usage; exit 1; } + local cmd="$1"; shift + case "$cmd" in + -h|--help|help) usage;; + list-users) cmd_list_users "$@";; + get-user) cmd_get_user "$@";; + create-user) cmd_create_user "$@";; + delete-user) cmd_delete_user "$@";; + make-admin) cmd_make_admin "$@";; + remove-admin) cmd_remove_admin "$@";; + list-groups) cmd_list_groups "$@";; + create-group) cmd_create_group "$@";; + delete-group) cmd_delete_group "$@";; + add-to-group) cmd_add_to_group "$@";; + remove-from-group) cmd_remove_from_group "$@";; + *) echo "unknown command: $cmd" >&2; usage; exit 1;; + esac +} + +main "$@" From fcb6862f1338925fddecf45f8711daa91574417b Mon Sep 17 00:00:00 2001 From: Peng Peng Date: Sun, 31 May 2026 13:19:42 +0800 Subject: [PATCH 5/5] ci: publish a temporary dev test image for this branch Build and push a multi-arch beclab/lldap:go-sync-sdk-dev image on pushes to this branch so the dev stack can pull an image that already has the read-only snapshot port. Remove once the port ships in a tagged release. Co-authored-by: Cursor --- .github/workflows/dev-image.yml | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/dev-image.yml diff --git a/.github/workflows/dev-image.yml b/.github/workflows/dev-image.yml new file mode 100644 index 000000000..438614319 --- /dev/null +++ b/.github/workflows/dev-image.yml @@ -0,0 +1,67 @@ +# Temporary: builds a test image for the feat/lldap-go-sync-sdk branch so the +# dev stack (clients/dev) can pull an image that already has the read-only +# snapshot port. Remove once the read-only port ships in a tagged release. +name: Build LLDAP dev test image +on: + workflow_dispatch: + push: + branches: + - "feat/lldap-go-sync-sdk" + +jobs: + publish_dockerhub_amd64: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASS }} + + - name: Build lldap and push Docker image + uses: docker/build-push-action@v3 + with: + push: true + tags: beclab/lldap:go-sync-sdk-dev-amd64 + file: Dockerfile + platforms: linux/amd64 + + publish_dockerhub_arm64: + runs-on: self-hosted + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASS }} + + - name: Build lldap and push Docker image + uses: docker/build-push-action@v3 + with: + push: true + tags: beclab/lldap:go-sync-sdk-dev-arm64 + file: Dockerfile + platforms: linux/arm64 + + publish_manifest: + needs: + - publish_dockerhub_amd64 + - publish_dockerhub_arm64 + runs-on: ubuntu-latest + steps: + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASS }} + + - name: Push manifest + run: | + docker manifest create beclab/lldap:go-sync-sdk-dev --amend beclab/lldap:go-sync-sdk-dev-amd64 --amend beclab/lldap:go-sync-sdk-dev-arm64 + docker manifest push beclab/lldap:go-sync-sdk-dev