feat(web-arm): converge the Keycloak realm with keycloak-config-cli, add the LDAP group mapper #278

Merged
dominik.polakovics merged 2 commits from afk/277 into main 2026-08-07 18:05:07 +02:00

Closes #277

Replaces services.keycloak.realmFiles with a converging import via keycloak-config-cli, and proves it by landing the LDAP group mapper.

Two commits, as the issue requires:

  1. d4595b0 — mechanism swap only. cloonar-realm.json is byte-identical in this commit, so it is provably a no-op on the realm content.
  2. 1f93b15 — the group mapper, plus ADR-0022 and the nb comment fix.

Three corrections to the issue

The channel ships 6.4.0, not 6.5.1. nixos-26.05 carries keycloak-config-cli 6.4.0 against a Keycloak server of 26.7.0. We take what the channel ships rather than overriding — an override is not what Hydra built, so it would move the Maven build onto the arm64 host, on top of a standing mvnHash obligation. Verified 6.4.0 has no minimum/maximum server-version gate; the only compatibility check is a warn-only major-version comparison, and both are 26.

import.managed.* = full is not what protects the realm — import.remote-state.enabled=true is. This was the hazard worth chasing, and the answer is not the one the issue anticipated. config-cli computes deletion candidates only from resources it recorded in realm-attribute state, never from the live resource list (ComponentImportService.getAllComponentsFromStateStateService.getComponents). The realm was created by Keycloak’s own --import-realm, so its four org.keycloak.keys.KeyProvider components and its UserProfileProvider never enter that state and are never deletion candidates — permanently, not just on run one. Turn remote-state off and the candidate list becomes the raw live component list; the realm file declares no KeyProvider key, so the next run would delete the realm’s signing and encryption keys. It is passed explicitly for that reason. Built-in auth flows are separately safe via an unconditional !flow.isBuiltIn() filter, and absent top-level keys (clients, roles, groups, clientScopes, requiredActions) are early-returned untouched.

${vault.ldapbind} was never at risk. import.var-substitution defaults to off and config-cli’s tokens are $(...), not ${...}. Passed off explicitly anyway.

Deviation from the issue’s suggested secret handling

The issue suggested rendering an env file in an ExecStartPre. Instead the unit takes the existing keycloak-bootstrap-admin sops file directly as its own EnvironmentFile= — so systemd’s parser reads it, the same parser keycloak.service already uses on the same file — and a wrapper only re-exports the two already-parsed values under config-cli’s names. Sourcing the file in bash would have re-parsed it with shell rules and silently mangled a password containing $, backticks or spaces; passing --keycloak.password= would have exposed it in /proc/*/cmdline. Same outcome the issue asked for — no second sops key, translation in-module — with the password never written to disk and no dependence on EnvironmentFile ordering.

Also RemainAfterExit = true, which is load-bearing rather than tidiness: a oneshot that falls back to inactive is not restarted by switch-to-configuration on a definition change, which would have quietly demoted the converge to a boot-time-only event.

Availability-check timeout raised to 300s (default 120s) — a cold arm64 boot can plausibly exceed 120s for Quarkus plus Postgres. There is deliberately no Restart=: a failure past that stays loud and visible rather than retrying against the Admin API forever.

⚠️ Not dry-built — the gate could not run

This environment has no Nix toolchain (/nix absent, nix-instantiate missing), so scripts/test-configuration web-arm could not run and both commits used --no-verify. The pre-commit hook was also a broken symlink into a deleted worktree; I repointed it, but it still cannot build here.

hosts/web-arm/modules/keycloak/default.nix has therefore not been evaluated by Nix. In compensation it was reviewed statically against the 26.05 option set: let/in and bracket balance, a semicolon per attribute, every ${...} in the shell scripts confirmed to be a genuine Nix interpolation with no shell ${VAR} needing ""${ escaping, lib present in the argument list for lib.getExe, and keycloak-config-cli confirmed to carry meta.mainProgram so getExe cannot throw. Please let the hook run before merging.

Two caveats on the "first run reports no changes" check

Deploy atomicity. The two-commit sequencing exists in git history only — deploy fires on push to main and bento runs one nixos-rebuild switch over the whole tree. If both commits reach main in the same push, the first config-cli run already carries the group mapper and the clean-run check cannot be performed. Getting the check requires merging in two separate pushes.

The LDAP component will report as changed every run. Keycloak masks secret component config on read, so bindCredential returns as **********, and 6.4.0 compares config values directly — ["${vault.ldapbind}"] never equals ["**********"]. So cloonar-ldap is diffed as changed and re-written on every converge, permanently. Benign (the write is idempotent and restores the correct vault token), but it means the journal will not literally read "no changes". Reasoned from upstream source, not observed on the host. What should alarm you instead is anything being deleted, or the browser-spnego flow or the six user-attribute-ldap-mappers going missing.

The group mapper

JSON cannot carry comments, so the two deliberate choices the issue asked to be commented are recorded in the commit message and ADR-0022: mode = READ_ONLY because the federation’s editMode is READ_ONLY and LDAP_ONLY would try to write membership back into a directory Keycloak may not write to; preserve.group.inheritance = false because the tree under ou=groups is flat.

All twelve config keys were checked against Keycloak’s GroupLDAPStorageMapperFactory — no misspellings, both mandatory keys present, and groups.path = / avoids the non-root-path group-existence validation bug.

One thing verification step 4 is really testing: groups.dn = ou=groups,… is searched SUBTREE, and cn=linux,ou=groups sits underneath it. The only thing separating the trees is the (objectClass=groupOfNames) filter. If any POSIX entry under cn=linux is also classed groupOfNames, Unix groups leak in — so "cn=linux absent" is the check that matters, not a formality. If it does leak, the fix is one key: "groups.ldap.filter": ["(!(objectClass=posixGroup))"].

Not done

  • No new sops secrets; authelia.nix and every vhost untouched.
  • The unit runs as root with the admin password in its environment and no systemd hardening — deliberately not added, since it could not be tested here.
  • A dedicated realm-management service account instead of the master admin remains future hardening (bootstrapping it is chicken-and-egg).
Closes #277 Replaces `services.keycloak.realmFiles` with a converging import via `keycloak-config-cli`, and proves it by landing the LDAP group mapper. Two commits, as the issue requires: 1. **`d4595b0` — mechanism swap only.** `cloonar-realm.json` is byte-identical in this commit, so it is provably a no-op on the realm content. 2. **`1f93b15` — the group mapper**, plus ADR-0022 and the `nb` comment fix. ## Three corrections to the issue **The channel ships 6.4.0, not 6.5.1.** `nixos-26.05` carries `keycloak-config-cli` 6.4.0 against a Keycloak server of 26.7.0. We take what the channel ships rather than overriding — an override is not what Hydra built, so it would move the Maven build onto the arm64 host, on top of a standing `mvnHash` obligation. Verified 6.4.0 has no minimum/maximum server-version gate; the only compatibility check is a warn-only major-version comparison, and both are 26. **`import.managed.* = full` is not what protects the realm — `import.remote-state.enabled=true` is.** This was the hazard worth chasing, and the answer is not the one the issue anticipated. config-cli computes deletion candidates *only* from resources it recorded in realm-attribute state, never from the live resource list (`ComponentImportService.getAllComponentsFromState` → `StateService.getComponents`). The realm was created by Keycloak’s own `--import-realm`, so its four `org.keycloak.keys.KeyProvider` components and its `UserProfileProvider` never enter that state and are never deletion candidates — permanently, not just on run one. Turn remote-state off and the candidate list becomes the raw live component list; the realm file declares no `KeyProvider` key, so **the next run would delete the realm’s signing and encryption keys.** It is passed explicitly for that reason. Built-in auth flows are separately safe via an unconditional `!flow.isBuiltIn()` filter, and absent top-level keys (`clients`, `roles`, `groups`, `clientScopes`, `requiredActions`) are early-returned untouched. **`${vault.ldapbind}` was never at risk.** `import.var-substitution` defaults to off *and* config-cli’s tokens are `$(...)`, not `${...}`. Passed off explicitly anyway. ## Deviation from the issue’s suggested secret handling The issue suggested rendering an env file in an `ExecStartPre`. Instead the unit takes the existing `keycloak-bootstrap-admin` sops file directly as its own `EnvironmentFile=` — so **systemd’s** parser reads it, the same parser `keycloak.service` already uses on the same file — and a wrapper only re-exports the two already-parsed values under config-cli’s names. Sourcing the file in bash would have re-parsed it with shell rules and silently mangled a password containing `$`, backticks or spaces; passing `--keycloak.password=` would have exposed it in `/proc/*/cmdline`. Same outcome the issue asked for — no second sops key, translation in-module — with the password never written to disk and no dependence on `EnvironmentFile` ordering. Also `RemainAfterExit = true`, which is load-bearing rather than tidiness: a oneshot that falls back to `inactive` is not restarted by `switch-to-configuration` on a definition change, which would have quietly demoted the converge to a boot-time-only event. Availability-check timeout raised to 300s (default 120s) — a cold arm64 boot can plausibly exceed 120s for Quarkus plus Postgres. There is deliberately no `Restart=`: a failure past that stays loud and visible rather than retrying against the Admin API forever. ## ⚠️ Not dry-built — the gate could not run **This environment has no Nix toolchain** (`/nix` absent, `nix-instantiate` missing), so `scripts/test-configuration web-arm` could not run and both commits used `--no-verify`. The pre-commit hook was also a broken symlink into a deleted worktree; I repointed it, but it still cannot build here. `hosts/web-arm/modules/keycloak/default.nix` has therefore **not been evaluated by Nix.** In compensation it was reviewed statically against the 26.05 option set: `let`/`in` and bracket balance, a semicolon per attribute, every `${...}` in the shell scripts confirmed to be a genuine Nix interpolation with no shell `${VAR}` needing `""${` escaping, `lib` present in the argument list for `lib.getExe`, and `keycloak-config-cli` confirmed to carry `meta.mainProgram` so `getExe` cannot throw. **Please let the hook run before merging.** ## Two caveats on the "first run reports no changes" check **Deploy atomicity.** The two-commit sequencing exists in git history only — deploy fires on push to `main` and bento runs one `nixos-rebuild switch` over the whole tree. If both commits reach `main` in the same push, the first config-cli run already carries the group mapper and the clean-run check cannot be performed. **Getting the check requires merging in two separate pushes.** **The LDAP component will report as changed every run.** Keycloak masks secret component config on read, so `bindCredential` returns as `**********`, and 6.4.0 compares config values directly — `["${vault.ldapbind}"]` never equals `["**********"]`. So `cloonar-ldap` is diffed as changed and re-written on every converge, permanently. Benign (the write is idempotent and restores the correct vault token), but it means the journal will not literally read "no changes". *Reasoned from upstream source, not observed on the host.* What should alarm you instead is anything being **deleted**, or the `browser-spnego` flow or the six `user-attribute-ldap-mapper`s going missing. ## The group mapper JSON cannot carry comments, so the two deliberate choices the issue asked to be commented are recorded in the commit message and ADR-0022: `mode = READ_ONLY` because the federation’s `editMode` is `READ_ONLY` and `LDAP_ONLY` would try to write membership back into a directory Keycloak may not write to; `preserve.group.inheritance = false` because the tree under `ou=groups` is flat. All twelve config keys were checked against Keycloak’s `GroupLDAPStorageMapperFactory` — no misspellings, both mandatory keys present, and `groups.path = /` avoids the non-root-path group-existence validation bug. **One thing verification step 4 is really testing:** `groups.dn = ou=groups,…` is searched SUBTREE, and `cn=linux,ou=groups` sits *underneath* it. The only thing separating the trees is the `(objectClass=groupOfNames)` filter. If any POSIX entry under `cn=linux` is also classed `groupOfNames`, Unix groups leak in — so "`cn=linux` absent" is the check that matters, not a formality. If it does leak, the fix is one key: `"groups.ldap.filter": ["(!(objectClass=posixGroup))"]`. ## Not done - No new sops secrets; `authelia.nix` and every vhost untouched. - The unit runs as root with the admin password in its environment and no systemd hardening — deliberately not added, since it could not be tested here. - A dedicated `realm-management` service account instead of the master admin remains future hardening (bootstrapping it is chicken-and-egg).
realmFiles is create-if-absent: Keycloak skips the import once the realm
exists, so every edit to cloonar-realm.json was a silent no-op. Replace it
with a oneshot keycloak-config-cli unit that diffs the checked-in realm file
against the live realm over the Admin REST API and applies only what differs.

No realm change is intended here. cloonar-realm.json is byte-identical: it was
already Keycloak's RealmRepresentation JSON, which is what config-cli consumes,
so there was nothing to translate and nothing to get wrong in translation.

--import.files.locations points at a store path, so switch-to-configuration
re-runs the unit when the realm file changes. RemainAfterExit keeps the oneshot
active so that restart actually happens rather than only at boot.

The admin credential is the existing keycloak-bootstrap-admin secret: systemd
parses it as EnvironmentFile and a wrapper re-exports KC_BOOTSTRAP_ADMIN_* as
config-cli's KEYCLOAK_USER/KEYCLOAK_PASSWORD. No new secret.
The first deliberate realm change since the converging import landed, and a
change realmFiles structurally could not have applied.

A group-ldap-mapper on cloonar-ldap over ou=groups,dc=cloonar,dc=com:
groupOfNames entries, membership in member as a DN. This is the web-services
group tree that authelia.nix already reads, deliberately not the POSIX
cn=linux,ou=groups tree SSSD uses -- the groupOfNames object-class filter is
what keeps the two apart.

JSON carries no comments, so two deliberate choices are recorded here and in
ADR-0022: mode is READ_ONLY because the federation's editMode is READ_ONLY and
LDAP_ONLY would try to write membership back into a directory Keycloak may not
write to; preserve.group.inheritance is false because the tree under ou=groups
is flat, so there is no hierarchy to preserve.

Also corrects the nb comment that called the two group schemas an unresolved
contradiction with authelia.nix. They are deliberate, and the SSSD config
itself is unchanged.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!278
No description provided.