From ad6c0054b3cccda5ecca7f691a604a29ad11f210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 11:57:56 +0200 Subject: [PATCH 01/50] scaffold keycloak service --- flake.nix | 2 ++ modules/default.nix | 1 + services/keycloak/README.md | 0 services/keycloak/checks.nix | 43 ++++++++++++++++++++++++++++++++++++ services/keycloak/lib.nix | 2 ++ services/keycloak/module.nix | 20 +++++++++++++++++ 6 files changed, 68 insertions(+) create mode 100644 services/keycloak/README.md create mode 100644 services/keycloak/checks.nix create mode 100644 services/keycloak/lib.nix create mode 100644 services/keycloak/module.nix diff --git a/flake.nix b/flake.nix index 94ffe8b..827e62b 100644 --- a/flake.nix +++ b/flake.nix @@ -34,11 +34,13 @@ # runtime state via OpenTofu after the primary unit starts. nixosModules.default = ./modules; nixosModules.forgejo = ./services/forgejo/module.nix; + nixosModules.keycloak = ./services/keycloak/module.nix; checks = forAllSystems ( { pkgs, system }: # Per-service checks (one attrset per pairing under ./services/). (import ./services/forgejo/checks.nix { inherit pkgs self; }) + // (import ./services/keycloak/checks.nix { inherit pkgs self; }) // { formatting = treefmtEval.${system}.config.build.check self; } diff --git a/modules/default.nix b/modules/default.nix index 8f20b8d..8d32553 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -3,5 +3,6 @@ { imports = [ ../services/forgejo/module.nix + ../services/keycloak/module.nix ]; } diff --git a/services/keycloak/README.md b/services/keycloak/README.md new file mode 100644 index 0000000..e69de29 diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix new file mode 100644 index 0000000..054b7b0 --- /dev/null +++ b/services/keycloak/checks.nix @@ -0,0 +1,43 @@ +{ pkgs, self }: +{ + keycloak = pkgs.testers.runNixOSTest { + name = "declarative-keycloak"; + + nodes.machine = + { config, ... }: + { + imports = [ self.nixosModules.default ]; + + # keycloak is thicc + virtualisation.memorySize = 3072; + + networking.firewall.allowedTCPPorts = [ + config.services.keycloak.settings.http-port + ]; + + # keycloak does not like store paths for db password + environment.etc."keycloak-db-password".text = "hackme"; + + services.keycloak = { + enable = true; + settings = { + hostname = "keycloak"; + http-port = 8080; + http-enabled = true; + hostname-strict = false; + }; + + database.passwordFile = "/etc/keycloak-db-password"; + + runtime.enable = true; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("keycloak.service") + machine.wait_for_open_port(8080) + machine.wait_until_succeeds("curl -sSf http://localhost:8080/realms/master") + ''; + }; +} diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/services/keycloak/lib.nix @@ -0,0 +1,2 @@ +{ +} diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix new file mode 100644 index 0000000..5f9e728 --- /dev/null +++ b/services/keycloak/module.nix @@ -0,0 +1,20 @@ +{ config, lib, ... }: +let + cfg = config.services.keycloak.runtime; +in +{ + options = { + services.keycloak.runtime = { + enable = lib.mkEnableOption "declarative keycloak runtime config"; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.services.keycloak.runtime.enable -> config.services.keycloak.enable; + message = "If the declarative keycloak runtime is enabled, the keycloak service must also be enabled."; + } + ]; + }; +} From 9e8783dbed62b641cc2d91661028fd2c8d4b9269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 12:02:18 +0200 Subject: [PATCH 02/50] refactor(modules/lib): add dynamicUser param to mkReconcileService Additive: forgejo passes nothing and keeps the original behaviour. For services using `DynamicUser=true` with no persistent state dir (e.g. Keycloak), the reconciler now sets `DynamicUser=true` and `StateDirectory=`, so systemd reuses the same hashed UID as the main unit and owns the state dir. --- modules/lib/default.nix | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index c38e65b..4b26fc8 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -36,6 +36,14 @@ rec { # user/group the base service's user/group the reconciler runs as # stateDir the base service's primary state dir; Terraform state lives in # a `declarative-terraform` subdir of it, co-located with the service + # dynamicUser set when the base service runs as systemd DynamicUser (so the + # `User=` name only exists per-unit). Pairs with `stateDirectory` + # so the reconciler is allocated the same hashed UID as the + # primary unit and writes state into a managed StateDirectory. + # stateDirectory relative path under /var/lib used as `StateDirectory=` when + # `dynamicUser` is set. Must match `stateDir` (e.g. stateDir + # `/var/lib/keycloak` with stateDirectory `keycloak/...`), so + # the script's absolute path resolves to systemd's managed dir. mkReconcileService = { name, @@ -49,6 +57,8 @@ rec { group, stateDir, credentials ? { }, + dynamicUser ? false, + stateDirectory ? null, }: let confFile = tfJsonFile name tfConfig; @@ -87,6 +97,14 @@ rec { Group = group; # Secrets stay out of the store: read from the credentials dir at runtime. LoadCredential = lib.mapAttrsToList (id: path: "${id}:${path}") allCredentials; + } + // lib.optionalAttrs dynamicUser { + # Bases like Keycloak ship no persistent state dir and run as + # systemd DynamicUser=true; the `User=` name is hashed to a stable UID + # that's reused across units, and systemd creates/owns the state dir. + DynamicUser = true; + StateDirectory = stateDirectory; + StateDirectoryMode = "0700"; }; script = '' set -euo pipefail From 3d6b63e61fdd66f73fae97384253bf31809837f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 12:26:19 +0200 Subject: [PATCH 03/50] feat(services/keycloak): declarative reconciler for realms Mirrors the forgejo pairing, with two upstream-driven differences: - Auth is OAuth2 client-credentials, so the config emits a (client_id, client_secret) pair as two sensitive tf variables fed via systemd `LoadCredential=`. - Upstream keycloak uses `DynamicUser=true` with no state dir; the reconciler uses the new dynamicUser mode of mkReconcileService and writes tfstate to /var/lib/keycloak/declarative-terraform. A companion oneshot mints a service-account oauth2 client in master with the master-realm `admin` role and saves its client_id/client_secret 0600. Operators can override via clientIdFile/clientSecretFile. First-wave resource surface is `keycloak_realm` only. The renderer already carries the full ref/secret machinery so later waves just extend `resourceTypes`. The VM test boots the whole keycloak -> bootstrap -> reconciler chain, asserts the realm via the admin API, checks that no secrets reach the generated .tf.json, exercises idempotency, and switches to a specialisation that adds a second realm. --- services/keycloak/README.md | 176 ++++++++++++++++++ services/keycloak/checks.nix | 120 +++++++++++- services/keycloak/lib.nix | 349 +++++++++++++++++++++++++++++++++++ services/keycloak/module.nix | 258 +++++++++++++++++++++++++- 4 files changed, 890 insertions(+), 13 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index e69de29..f8e57fe 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -0,0 +1,176 @@ +# Keycloak pairing + +Declaratively manage a [Keycloak](https://www.keycloak.org/) instance's +**runtime state** (realms, …) from NixOS, on top of the upstream +`services.keycloak` module. + +[provider]: https://registry.terraform.io/providers/keycloak/keycloak/latest/docs + +## Installation + +### Include the module + +Add this flake as an input and import its `nixosModules.keycloak` into your +host. Pointing the input's `nixpkgs` at your own keeps the provider build in +step with the rest of your system. + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + # This repository. + declarative-services.url = "github:youruser/terraform-providers"; + declarative-services.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { nixpkgs, declarative-services, ... }: + { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + declarative-services.nixosModules.keycloak + ./host.nix + ]; + }; + }; +} +``` + +## Authentication model + +The [`keycloak/keycloak`][provider] provider authenticates via OAuth2 +**client-credentials** grant — it needs an OIDC client in the `master` +realm whose service-account user holds the master-realm `admin` +composite role (a realm-level role that grants global admin across every +realm). The pairing offers two paths: + +1. **Self-bootstrap (default).** Set `bootstrapAdminPasswordFile` to a host + path containing the master-realm `admin` password (the same value as + `services.keycloak.initialAdminPassword`). A companion oneshot + (`declarative-keycloak-bootstrap.service`) mints a dedicated + service-account client (`clientId` = `clientName`, default + `declarative-keycloak`), grants its service account the master-realm + `admin` composite role, and persists the + resulting `client_id`/`client_secret` 0600 under + `/var/lib/declarative-keycloak-bootstrap/`. The reconciler picks them up + via systemd `LoadCredential=` on every run; the file pair is the + "already bootstrapped" marker, so the oneshot is a no-op on subsequent + boots. + +2. **Operator-supplied.** Set both `clientIdFile` and `clientSecretFile` to + host paths for a service-account client you've created externally. The + bootstrap oneshot is then not wired in at all. + +## Configuration examples + +Set `services.keycloak.enable = true` (the module asserts it) and declare +the runtime state under `services.keycloak.runtime`. Each entry's +attributes are **typed options** named after the upstream resource's +snake_case attributes (validated at `nix flake check`; an unknown name or +wrong type is a build error). + +### Self-bootstrap: one realm + +```nix +{ + services.keycloak = { + enable = true; + # Seed the master-realm admin user on first boot; the bootstrap oneshot + # uses the same password (read from the host file via LoadCredential). + initialAdminPassword = "REPLACE_ME"; + settings = { + hostname = "sso.example.com"; + }; + database.passwordFile = "/run/secrets/keycloak-db-password"; + + runtime = { + enable = true; + bootstrapAdminPasswordFile = "/run/secrets/keycloak-admin-password"; + + realms.staff = { + display_name = "Staff SSO"; + display_name_html = "Staff SSO"; + }; + }; + }; +} +``` + +### Operator-supplied client (skip bootstrap) + +Useful when the service-account client is provisioned out-of-band (e.g. by +a CI job or an existing IaC pipeline). With both files set, the pairing +does not create or modify the client and reads them directly. + +```nix +{ + services.keycloak = { + enable = true; + settings.hostname = "sso.example.com"; + database.passwordFile = "/run/secrets/keycloak-db-password"; + + runtime = { + enable = true; + clientIdFile = "/run/secrets/keycloak-tf-client-id"; + clientSecretFile = "/run/secrets/keycloak-tf-client-secret"; + + realms.staff.display_name = "Staff SSO"; + }; + }; +} +``` + +## Module options (`services.keycloak.runtime`) + +| Option | Type | Default | Purpose | +| ---------------------------- | ----------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `enable` | bool | `false` | Turn on the reconciler. | +| `baseUrl` | str | `http://localhost:` | Keycloak admin API base URL the provider targets. | +| `bootstrapAdminPasswordFile` | null or str | `null` | Host path to the master-realm admin password. Required when no client credentials are supplied. Read via `LoadCredential=`; never stored. | +| `clientName` | str | `"declarative-keycloak"` | `clientId` of the service-account client minted at bootstrap. Unused when `clientIdFile`/`clientSecretFile` are set. | +| `clientIdFile` | null or str | `null` | Host path to an externally-managed `client_id`. Set together with `clientSecretFile` to bypass bootstrap. | +| `clientSecretFile` | null or str | `null` | Host path to an externally-managed `client_secret`. Read via `LoadCredential=`; never stored. | + +Plus one collection option per provider resource (next section). + +## Resources + +Every [`keycloak/keycloak`][provider] resource is exposed as a collection +keyed by an arbitrary handle. First-wave coverage: + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| -------- | --------------------- | ------------ | ---------------- | +| `realms` | `realm` | `realm` | — | + +Subsequent waves add clients, scopes, roles, groups, users, identity +providers, and protocol mappers. + +## State directory note + +Keycloak's upstream NixOS module runs as `User = "keycloak"; DynamicUser = +true; RuntimeDirectory = "keycloak"` — i.e. it has **no persistent +host-level state directory** (database state lives in PostgreSQL, runtime +state in `/run/keycloak`). The pairing therefore maintains its own +`/var/lib/keycloak/declarative-terraform` via systemd +`StateDirectory = "keycloak/declarative-terraform"`. systemd allocates the +same dynamic UID for `User = "keycloak"` in both the primary unit and the +reconciler, so the state dir is owned coherently with the rest of the +service and persists across reboots. + +## Security note + +The admin password (`bootstrapAdminPasswordFile`), the minted or +supplied `client_secret`, and the `client_id` are **never written to the +world-readable Nix store**. They are read at runtime via systemd +`LoadCredential=` and passed to OpenTofu as `sensitive = true` Terraform +input variables. The generated `.tf.json` (under +`/var/lib/keycloak/declarative-terraform/`) contains only `${var.…}` +placeholders for these values. + +The same `` / `File` mechanism will protect secret-valued +resource attributes (e.g. `openid_client.client_secret`, +`user.initial_password`, `ldap_user_federation.bind_credential`) as those +resources land in subsequent waves; the first wave (realm only) declares +no secret attributes. diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 054b7b0..87f5926 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -1,3 +1,13 @@ +# Keycloak pairing check, returned as a single-entry attrset merged into the +# flake's per-system `checks`. +# +# keycloak — Full integration test: boots a VM with services.keycloak + +# services.keycloak.runtime and lets the pairing converge at boot with no +# manual setup. The module's own machinery bootstraps a service-account +# OIDC client via a companion oneshot, then the run-once reconciler +# applies the declared realm. A specialisation adds a second realm to +# exercise config-change reconciliation. Keycloak is a heavy JVM service, +# so the VM gets extra memory and the bootstrap probe budget is wide. { pkgs, self }: { keycloak = pkgs.testers.runNixOSTest { @@ -15,29 +25,129 @@ config.services.keycloak.settings.http-port ]; - # keycloak does not like store paths for db password + # Stand-ins for operator-managed secret files (sops/agenix in + # production): the database password Keycloak rejects as a store + # path (it expects an /etc-style host path), and the bootstrap admin + # password the reconciler oneshot reads via LoadCredential. environment.etc."keycloak-db-password".text = "hackme"; + environment.etc."keycloak-admin-password".text = "hackme"; services.keycloak = { enable = true; + # initialAdminPassword seeds the master-realm `admin` user on + # first boot. The bootstrap oneshot then logs in as that user + # (reading the same secret from a file via LoadCredential) to + # mint the service-account client. + initialAdminPassword = "hackme"; settings = { hostname = "keycloak"; http-port = 8080; + # HTTP-only test deployment: skip TLS material and the strict + # hostname URL/scheme validation that aborts startup otherwise. http-enabled = true; hostname-strict = false; }; database.passwordFile = "/etc/keycloak-db-password"; - runtime.enable = true; + # No client credentials supplied: the pairing bootstraps its own + # service-account client at boot using the admin password file. + runtime = { + enable = true; + bootstrapAdminPasswordFile = "/etc/keycloak-admin-password"; + + realms.acme = { + display_name = "ACME Corp."; + display_name_html = "ACME Corp."; + }; + }; + }; + + # Exercises that the reconciler picks up a new declared resource + # on a config switch (the bootstrap oneshot stays a no-op because + # its credentials file persists across reboots). + specialisation.addRealm.configuration = { + services.keycloak.runtime.realms.delta = { + display_name = "Delta Realm"; + }; }; }; testScript = '' + import json + + def admin_token(): + # Anonymous /realms/ only exposes a tiny field set (no + # displayName); use the master-realm admin-cli token to read the + # full realm representation via /admin/realms/. + resp = machine.succeed( + "curl --fail -s -X POST " + "http://localhost:8080/realms/master/protocol/openid-connect/token " + "-d grant_type=password -d client_id=admin-cli " + "-d username=admin -d password=hackme" + ) + return json.loads(resp)["access_token"] + + def get_realm(realm): + tok = admin_token() + return json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/{realm}" + )) + machine.start() - machine.wait_for_unit("keycloak.service") - machine.wait_for_open_port(8080) - machine.wait_until_succeeds("curl -sSf http://localhost:8080/realms/master") + + # The whole chain must converge at boot with zero manual setup: + # keycloak.service -> + # declarative-keycloak-bootstrap.service -> + # declarative-keycloak.service + # wait_for_unit blocks until the run-once reconciler has applied + # every declared resource successfully (a failed apply fails the unit). + machine.wait_for_unit("declarative-keycloak.service") + + # The realm exists and its typed attributes were applied as declared. + acme = get_realm("acme") + assert acme.get("realm") == "acme", f"realm name not applied: {acme}" + assert acme.get("displayName") == "ACME Corp.", f"display_name not applied: {acme}" + assert acme.get("displayNameHtml") == "ACME Corp.", \ + f"display_name_html not applied: {acme}" + + # Per-secret indirection: the admin password was supplied as a host + # file and the minted client_secret was written to /var/lib by the + # bootstrap. Neither must appear in the generated .tf.json. + tfjson = machine.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "hackme" not in tfjson, "admin password leaked into generated .tf.json" + client_secret = machine.succeed( + "cat /var/lib/declarative-keycloak-bootstrap/client_secret" + ).strip() + assert client_secret, "bootstrap did not write client_secret" + assert client_secret not in tfjson, "client_secret leaked into generated .tf.json" + + # State lands under /var/lib/keycloak (via systemd `StateDirectory=` + # under DynamicUser), is readable across runs (the `cat` above + # succeeded), and the reconciler exited 0 -- proving it ran as a + # working non-root UID that owns the state dir. We don't assert a + # specific owner name: DynamicUser allocates an opaque per-unit UID + # the host kernel may render as the overflow uid (65534) when + # stat'd outside the unit's mount namespace. + machine.succeed( + "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" + ) + + # Re-applying must be idempotent (a second run must also succeed). + machine.succeed("systemctl restart declarative-keycloak.service") + + # Config-change reconciliation: switching to the specialisation that + # declares a second realm must apply it without manual intervention. + machine.succeed( + "/run/current-system/specialisation/addRealm/bin/switch-to-configuration test" + ) + machine.wait_until_succeeds("curl --fail http://localhost:8080/realms/delta") + delta = get_realm("delta") + assert delta.get("displayName") == "Delta Realm", \ + f"delta realm display_name not applied: {delta}" ''; }; } diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 2c63c08..2b8818f 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -1,2 +1,351 @@ +# Keycloak-provider specifics: the keycloak-wrapped OpenTofu executor and the +# .tf.json generation for a Keycloak pairing. The provider-agnostic helpers +# (label/file/reconciler) live in modules/lib and are specialized here for the +# keycloak/keycloak provider (in nixpkgs as +# `pkgs.terraform-providers.keycloak_keycloak`). +# +# The first-wave resource surface is `keycloak_realm` only; follow-ups add +# clients, scopes, roles, groups, users, identity providers, and mappers in +# the same shape (the `resourceTypes` record is extended; the renderer is +# already provider-agnostic). +# +# Two divergences from the forgejo template, forced by upstream Keycloak's +# shape: +# +# 1. Auth is OAuth2 client-credentials, not a single API token. Two +# sensitive Terraform variables are emitted: `keycloak_client_secret` +# (the primary, threaded through the generic reconciler as `tokenVar`) +# and `keycloak_client_id` (an extra credential id flowed through the +# generic reconciler's `credentials` map). Both are fed from systemd +# `LoadCredential=` at apply time, never written to the world-readable +# store. +# +# 2. The upstream NixOS keycloak module runs as `DynamicUser=true` with no +# persistent state directory. The module therefore enables the new +# `dynamicUser`/`stateDirectory` modes of `mkReconcileService` so the +# reconciler is allocated the same hashed UID as `keycloak.service` and +# writes its Terraform state into a systemd-managed StateDirectory at +# `/var/lib/keycloak/declarative-terraform`. +# +# Imported as `import ./lib.nix { inherit pkgs; }` from the Keycloak module +# and checks. +{ pkgs }: +let + inherit (pkgs) lib; + genlib = import ../../modules/lib { inherit pkgs; }; + inherit (genlib) tfLabel; + + # In-nixpkgs provider; pin `required_providers` to its packaged version so + # the offline plugin mirror always matches the manifest. + provider = pkgs.terraform-providers.keycloak_keycloak; + providerVersion = provider.version; + + # OAuth2 client-credentials grant: two paired sensitive variables. + # `tokenVar` is the primary credential flowed through the generic + # reconciler; `clientIdVar` is exported so module.nix can wire the matching + # bootstrap-produced (or operator-supplied) file into the reconciler's + # `credentials` map. + tokenVar = "keycloak_client_secret"; + clientIdVar = "keycloak_client_id"; + + # OpenTofu wrapped with the keycloak/keycloak provider. The provider binary + # lives in the wrapper's NIX_TERRAFORM_PLUGIN_DIR, so `tofu init`/`apply` + # resolve it with no registry access. + executor = pkgs.opentofu.withPlugins (_: [ provider ]); + + ty = lib.types; + + # Per-attribute option constructors. `o*` declare an *optional* attribute + # (`nullOr T`, default null -> omitted from the generated `.tf.json` when + # unset); `r*` declare a *required* attribute (no default -> a missing value + # is an eval-time error). Each carries the exact value shape the provider + # accepts. + oStr = + description: + lib.mkOption { + type = ty.nullOr ty.str; + default = null; + inherit description; + }; + oBool = + description: + lib.mkOption { + type = ty.nullOr ty.bool; + default = null; + inherit description; + }; + + # The full keycloak/keycloak resource surface (first wave: realm only). Per + # resource: + # type the `keycloak_*` resource type + # prefix unique Terraform label prefix + # nameAttr attribute defaulted from the collection key (or null) + # scope reserved for future per-resource scoping; null under + # client-credentials auth + # refs parent links resolved to references against managed + # siblings + # secrets secret-valued attributes gaining an `File` form + # requiredSecrets secrets the provider requires (one of ``/`File`) + # attrs the settable attributes, each a typed option (no + # freeform) + resourceTypes = { + realms = { + type = "keycloak_realm"; + prefix = "realm"; + nameAttr = "realm"; + scope = null; + refs = { }; + description = "Keycloak realms, keyed by realm name."; + attrs = { + realm = oStr "Realm name. Defaults to the attribute key."; + enabled = oBool "Is the realm enabled?"; + display_name = oStr "User-facing display name."; + display_name_html = oStr "HTML-formatted display name."; + }; + }; + }; + + # One option collection per resource: an `attrsOf` strictly-typed submodule. + # The submodule's options are the resource's settable attributes, plus the + # reference inputs (resolved into Terraform references at generation) and + # one `File` input per secret. No `freeformType`: an undeclared + # attribute is a definition error. + resourceOptions = lib.mapAttrs ( + _: spec: + lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = + (spec.attrs or { }) + // lib.mapAttrs ( + _: refSpec: + if refSpec.required or false then + lib.mkOption { + type = lib.types.str; + description = refSpec.description; + } + else + lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = refSpec.description; + } + ) spec.refs + // lib.listToAttrs ( + map ( + attr: + lib.nameValuePair "${attr}File" ( + lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Runtime path to a file holding `${attr}` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `${attr}`."; + } + ) + ) (spec.secrets or [ ]) + ); + } + ); + default = { }; + description = spec.description; + } + ) resourceTypes; + + # Recursively drop null-valued attributes (unset options) and the submodule + # bookkeeping key `_module`, so the generated JSON carries only what the + # user actually set -- at every nesting level, including any typed nested + # objects added by later resources. + cleanNulls = + v: + if builtins.isAttrs v then + lib.mapAttrs (_: cleanNulls) (lib.filterAttrs (_: x: x != null) (removeAttrs v [ "_module" ])) + else if builtins.isList v then + map cleanNulls v + else + v; + + # Build the Terraform JSON config for a Keycloak pairing from the module's + # cfg, together with the (id -> host path) credential map for any + # host-file-sourced secrets. Returns { config; credentials; }. + # + # Contains NO provider secret: the `client_id`/`client_secret` pair and + # every `File` secret are supplied at apply time as sensitive input + # variables fed from systemd `LoadCredential=`, never written to the store. + # A *literal* secret attribute (e.g. a value set directly) still lands in + # the world-readable store -- use the matching `File` option to avoid + # that. + keycloakTfConfig = + cfg: + let + # Var-safe id (Terraform variable name + LoadCredential id) for a secret. + varSafe = lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_]" c != null then c else "_"); + secretId = + spec: key: attr: + "secret_${spec.prefix}_${varSafe key}_${attr}"; + + resolveRef = + refSpec: val: + let + tryTarget = + t: + let + tspec = resourceTypes.${t.collection}; + in + if (cfg.${t.collection} or { }) ? ${val} then + "\${" + tspec.type + "." + tfLabel tspec.prefix val + "." + t.field + "}" + else + null; + hits = builtins.filter (x: x != null) (map tryTarget refSpec.targets); + in + if hits != [ ] then + builtins.head hits + else if refSpec.managedOnly then + throw "services.keycloak.runtime: reference '${val}' does not match any managed ${ + lib.concatMapStringsSep " or " (t: t.collection) refSpec.targets + }" + else + val; + + # Host-file-sourced secrets of one item: [{ attr; id; path; }]. Throws if + # both the literal attribute and its `File` are set. + itemSecrets = + c: spec: key: item: + lib.concatMap ( + attr: + let + file = item.${attr + "File"} or null; + in + lib.optionals (file != null) ( + if (item.${attr} or null) != null then + throw "services.keycloak.runtime.${c}.${key}: set either '${attr}' or '${attr}File', not both" + else + [ + { + inherit attr; + id = secretId spec key attr; + path = file; + } + ] + ) + ) (spec.secrets or [ ]); + + renderItem = + c: spec: key: item: + let + secretEntries = itemSecrets c spec key item; + virtuals = builtins.attrNames spec.refs ++ map (s: "${s}File") (spec.secrets or [ ]); + base = removeAttrs item ([ "_module" ] ++ virtuals); + nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { + ${spec.nameAttr} = key; + }; + refAttrs = lib.concatMapAttrs ( + refName: refSpec: + lib.optionalAttrs (item.${refName} or null != null) { + ${refSpec.attr} = resolveRef refSpec item.${refName}; + } + ) spec.refs; + secretAttrs = lib.listToAttrs (map (e: lib.nameValuePair e.attr "\${var.${e.id}}") secretEntries); + # A required secret must be supplied via either the literal or its file. + reqSecretChecks = map ( + attr: + if (item.${attr} or null) == null && (item.${attr + "File"} or null) == null then + throw "services.keycloak.runtime.${c}.${key}: set either '${attr}' or '${attr}File' (required)" + else + null + ) (spec.requiredSecrets or [ ]); + # Required map/list attributes: the module system gives `attrsOf`/ + # `listOf` an empty-value default ({}/[]) rather than treating a + # missing value as undefined, so a "required" collection is enforced + # here. + reqAttrChecks = map ( + attr: + let + v = item.${attr} or null; + in + if v == null || v == { } || v == [ ] then + throw "services.keycloak.runtime.${c}.${key}: '${attr}' is required and must be non-empty" + else + null + ) (spec.requiredAttrs or [ ]); + in + # deepSeq forces the validation thunks (whose results are otherwise + # unused) so a violated check `throw`s here. These live in the + # generator, not in NixOS `config.assertions`, because assertions only + # fire during a full NixOS system evaluation -- whereas + # `keycloakTfConfig` is also called standalone (e.g. tests, `nix + # eval`), where assertions would be silently skipped and a malformed + # config would surface opaquely at `tofu apply`. + lib.nameValuePair (tfLabel spec.prefix key) ( + builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( + cleanNulls (base // nameInject // refAttrs // secretAttrs) + ) + ); + + nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; + resourceBlocks = lib.mapAttrs' ( + c: spec: lib.nameValuePair spec.type (lib.mapAttrs' (renderItem c spec) cfg.${c}) + ) nonEmpty; + + # Every host-file-sourced secret across the config, for the sensitive + # input variables and the (id -> host path) credential map. + allSecrets = lib.concatLists ( + lib.mapAttrsToList ( + c: spec: lib.concatLists (lib.mapAttrsToList (key: item: itemSecrets c spec key item) cfg.${c}) + ) nonEmpty + ); + secretIds = map (e: e.id) allSecrets; + + config = { + terraform.required_providers.keycloak = { + source = "keycloak/keycloak"; + version = providerVersion; + }; + variable = { + ${tokenVar} = { + type = "string"; + sensitive = true; + }; + ${clientIdVar} = { + type = "string"; + sensitive = true; + }; + } + // lib.listToAttrs ( + map ( + e: + lib.nameValuePair e.id { + type = "string"; + sensitive = true; + } + ) allSecrets + ); + provider.keycloak = { + url = cfg.baseUrl; + realm = "master"; + client_id = "\${var.${clientIdVar}}"; + client_secret = "\${var.${tokenVar}}"; + }; + } + // lib.optionalAttrs (resourceBlocks != { }) { resource = resourceBlocks; }; + + credentials = + if lib.length secretIds != lib.length (lib.unique secretIds) then + throw "services.keycloak.runtime: secret credential id collision (${toString secretIds}); rename the colliding resource keys" + else + lib.listToAttrs (map (e: lib.nameValuePair e.id e.path) allSecrets); + in + { + inherit config credentials; + }; +in { + inherit + resourceTypes + resourceOptions + keycloakTfConfig + clientIdVar + ; + + # The generic run-once reconciler, specialized with the keycloak executor + # and the keycloak_client_secret credential. + mkReconcileService = args: genlib.mkReconcileService (args // { inherit executor tokenVar; }); } diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index 5f9e728..fab6040 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -1,20 +1,262 @@ -{ config, lib, ... }: +# Keycloak <-> keycloak/keycloak provider pairing. +# +# Enables the upstream `services.keycloak` module unchanged, and adds a +# run-once reconciler that applies declarative runtime state (realms, ...) +# against the live Keycloak admin API once `keycloak.service` is up. Unless +# `clientIdFile` and `clientSecretFile` are both supplied, a companion +# oneshot (`declarative-keycloak-bootstrap.service`) mints a dedicated +# service-account OIDC client in the `master` realm and grants it +# `realm-admin` on the `realm-management` client; the reconciler then +# authenticates via OAuth2 client-credentials grant. The bootstrap relies +# on the operator providing the master-realm admin's password via +# `bootstrapAdminPasswordFile` (read with systemd `LoadCredential=`, +# never copied into the world-readable Nix store). +# +# Upstream Keycloak uses `DynamicUser=true` with no persistent state dir; +# the reconciler enables the corresponding mode of mkReconcileService so it +# is allocated the same hashed `keycloak` UID as `keycloak.service` and +# writes Terraform state into `/var/lib/keycloak/declarative-terraform` +# via systemd `StateDirectory=`. +{ + config, + lib, + pkgs, + ... +}: let + inherit (lib) + literalExpression + mkEnableOption + mkIf + mkOption + types + ; + cfg = config.services.keycloak.runtime; + keycloak = config.services.keycloak; + tflib = import ./lib.nix { inherit pkgs; }; + + defaultBaseUrl = "http://localhost:${toString keycloak.settings.http-port}"; + + # Companion oneshot that mints the reconciler's service-account OIDC + # client. Only wired in when the operator does not supply their own + # (clientIdFile, clientSecretFile) pair. + bootstrapServiceName = "declarative-keycloak-bootstrap"; + bootstrapClient = cfg.clientIdFile == null; + effectiveClientIdFile = + if bootstrapClient then "/var/lib/${bootstrapServiceName}/client_id" else cfg.clientIdFile; + effectiveClientSecretFile = + if bootstrapClient then "/var/lib/${bootstrapServiceName}/client_secret" else cfg.clientSecretFile; + + tf = tflib.keycloakTfConfig cfg; in { - options = { - services.keycloak.runtime = { - enable = lib.mkEnableOption "declarative keycloak runtime config"; + options.services.keycloak.runtime = { + enable = mkEnableOption ( + "declarative Keycloak configuration, applied via OpenTofu and the keycloak/keycloak " + + "provider after keycloak.service starts" + ); + + baseUrl = mkOption { + type = types.str; + default = defaultBaseUrl; + defaultText = literalExpression ''"http://localhost:''${toString config.services.keycloak.settings.http-port}"''; + description = "Base URL of the local Keycloak admin API the provider targets."; }; - }; - config = lib.mkIf cfg.enable { + bootstrapAdminPasswordFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/keycloak-admin-password"; + description = '' + Host path to a file containing the password of an existing + realm-admin user (default `admin`) in the `master` realm. The + path is resolved on the target host (e.g. provisioned by sops-nix + or agenix), NOT a store path: it is handed to the bootstrap + oneshot via systemd `LoadCredential=` and never copied into the + Nix store. + + Required when `clientIdFile`/`clientSecretFile` are unset (the + default), since the bootstrap uses this admin to mint a dedicated + service-account client. Ignored when both are supplied. + ''; + }; + + clientName = mkOption { + type = types.str; + default = "declarative-keycloak"; + description = '' + Service-account client `clientId` the bootstrap oneshot creates + in the `master` realm. The matching service-account user is + granted the `realm-admin` role of the `realm-management` client + so the reconciler can manage every realm. Unused when + `clientIdFile`/`clientSecretFile` are set directly. + ''; + }; + + clientIdFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/keycloak-tf-client-id"; + description = '' + Host path to a file containing the OIDC `client_id` the + reconciler authenticates as. Set together with `clientSecretFile` + to bypass the bootstrap and supply your own service-account + client. + ''; + }; + + clientSecretFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/keycloak-tf-client-secret"; + description = '' + Host path to a file containing the OIDC `client_secret` paired + with `clientIdFile`. Read via systemd `LoadCredential=`, never + copied into the store. + ''; + }; + } + // tflib.resourceOptions; + + config = mkIf cfg.enable { assertions = [ { - assertion = config.services.keycloak.runtime.enable -> config.services.keycloak.enable; - message = "If the declarative keycloak runtime is enabled, the keycloak service must also be enabled."; + assertion = keycloak.enable; + message = "services.keycloak.runtime requires services.keycloak.enable = true."; + } + { + assertion = (cfg.clientIdFile == null) == (cfg.clientSecretFile == null); + message = "services.keycloak.runtime: set both clientIdFile and clientSecretFile, or neither (self-bootstrap)."; + } + { + assertion = (cfg.clientIdFile != null) || (cfg.bootstrapAdminPasswordFile != null); + message = "services.keycloak.runtime: when no client credentials are supplied, bootstrapAdminPasswordFile is required to mint them."; } ]; + + systemd.services = { + declarative-keycloak = tflib.mkReconcileService { + name = "declarative-keycloak"; + tfConfig = tf.config; + credentials = tf.credentials // { + ${tflib.clientIdVar} = effectiveClientIdFile; + }; + afterUnits = [ + "keycloak.service" + ] + ++ lib.optional bootstrapClient "${bootstrapServiceName}.service"; + healthUrl = "${cfg.baseUrl}/realms/master"; + tokenFile = effectiveClientSecretFile; + user = "keycloak"; + group = "keycloak"; + # Upstream keycloak runs as DynamicUser=true with no persistent + # state dir; allocate a dedicated one owned by the same hashed UID + # via systemd StateDirectory=. + stateDir = "/var/lib/keycloak"; + dynamicUser = true; + stateDirectory = "keycloak/declarative-terraform"; + }; + } + // lib.optionalAttrs bootstrapClient { + ${bootstrapServiceName} = { + description = "Bootstrap the declarative-keycloak service-account OIDC client"; + after = [ "keycloak.service" ]; + requires = [ "keycloak.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + keycloak.package + pkgs.curl + pkgs.jq + pkgs.coreutils + ]; + environment = { + # kcadm.sh stashes its token cache under $HOME/.keycloak; under + # DynamicUser there is no real home, so direct it into the + # StateDirectory (writable and owned by the same hashed UID). + HOME = "/var/lib/${bootstrapServiceName}"; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + DynamicUser = true; + User = "keycloak"; + Group = "keycloak"; + StateDirectory = bootstrapServiceName; + StateDirectoryMode = "0700"; + LoadCredential = [ "admin-password:${cfg.bootstrapAdminPasswordFile}" ]; + }; + script = '' + set -euo pipefail + umask 077 + + client_id_file="$STATE_DIRECTORY/client_id" + client_secret_file="$STATE_DIRECTORY/client_secret" + + # Mint-once: the persisted credential pair is the "already + # bootstrapped" marker. /var/lib survives reboots, so this oneshot + # is no-op on every boot after the first successful run. + if [ -s "$client_id_file" ] && [ -s "$client_secret_file" ]; then + exit 0 + fi + + # `After=keycloak.service` waits for the Type=notify ready flag, + # but Quarkus keeps initialising for tens of seconds past that; + # poll the admin endpoint explicitly before talking to kcadm. + for _ in $(seq 1 90); do + if curl -fsS -o /dev/null "${cfg.baseUrl}/realms/master"; then + break + fi + sleep 2 + done + + kcadm.sh config credentials \ + --server ${lib.escapeShellArg cfg.baseUrl} \ + --realm master \ + --user admin \ + --password "$(cat "$CREDENTIALS_DIRECTORY/admin-password")" + + # Tolerate a client left over from a partial earlier bootstrap. + existing_uuid="$(kcadm.sh get clients -r master \ + -q clientId=${lib.escapeShellArg cfg.clientName} \ + | jq -r '.[0].id // empty')" + if [ -n "$existing_uuid" ]; then + client_uuid="$existing_uuid" + else + client_uuid="$(kcadm.sh create clients -r master \ + -s clientId=${lib.escapeShellArg cfg.clientName} \ + -s protocol=openid-connect \ + -s serviceAccountsEnabled=true \ + -s publicClient=false \ + -s standardFlowEnabled=false \ + -s directAccessGrantsEnabled=false \ + -s implicitFlowEnabled=false \ + -s enabled=true \ + -i)" + fi + + sa_uid="$(kcadm.sh get clients/$client_uuid/service-account-user -r master \ + | jq -r .id)" + + # The master realm's realm-level `admin` is a composite role that + # grants global admin across every realm; assigning it to the + # service-account user gives the reconciler full reach. + # Idempotent: kcadm tolerates re-grant. + kcadm.sh add-roles -r master \ + --uid "$sa_uid" \ + --rolename admin + + client_secret="$(kcadm.sh get clients/$client_uuid/client-secret -r master \ + | jq -r .value)" + + # Atomic write: rename only succeeds once both tempfiles exist, so + # the idempotency check above never observes a half-written pair. + printf '%s' ${lib.escapeShellArg cfg.clientName} > "$client_id_file.tmp" + printf '%s' "$client_secret" > "$client_secret_file.tmp" + mv "$client_id_file.tmp" "$client_id_file" + mv "$client_secret_file.tmp" "$client_secret_file" + ''; + }; + }; }; } From d7848aa98b08a52ea0c66778b18714bd09c09ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 12:45:52 +0200 Subject: [PATCH 04/50] refactor(modules/lib): derive StateDirectory from stateDir Two related tweaks to the dynamicUser path: - Drop the explicit `stateDirectory` parameter; derive it from `stateDir` so the absolute path the script cd's into and the relative path the unit declares as `StateDirectory=` can't drift. - Assert at eval time that `stateDir` lives under `/var/lib` when `dynamicUser` is set, so misconfig surfaces at `nix flake check` rather than as an opaque unit-load failure. services/keycloak now only sets stateDir; no behaviour change. --- modules/lib/default.nix | 25 +++++++++++++++---------- services/keycloak/module.nix | 4 ++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 4b26fc8..92dc8b3 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -36,14 +36,13 @@ rec { # user/group the base service's user/group the reconciler runs as # stateDir the base service's primary state dir; Terraform state lives in # a `declarative-terraform` subdir of it, co-located with the service - # dynamicUser set when the base service runs as systemd DynamicUser (so the - # `User=` name only exists per-unit). Pairs with `stateDirectory` - # so the reconciler is allocated the same hashed UID as the - # primary unit and writes state into a managed StateDirectory. - # stateDirectory relative path under /var/lib used as `StateDirectory=` when - # `dynamicUser` is set. Must match `stateDir` (e.g. stateDir - # `/var/lib/keycloak` with stateDirectory `keycloak/...`), so - # the script's absolute path resolves to systemd's managed dir. + # dynamicUser set when the base service runs as systemd DynamicUser (so + # the `User=` name only exists per-unit). The reconciler then + # runs with `DynamicUser=true` too, so systemd allocates the + # same hashed UID as the primary unit, and the work dir is + # created via `StateDirectory=` (derived from `stateDir`) + # rather than `mkdir`. Requires `stateDir` to live under + # `/var/lib`; enforced at eval time. mkReconcileService = { name, @@ -58,7 +57,6 @@ rec { stateDir, credentials ? { }, dynamicUser ? false, - stateDirectory ? null, }: let confFile = tfJsonFile name tfConfig; @@ -71,7 +69,14 @@ rec { # Terraform state is co-located with the base service: a subdir of its # primary state directory, created and owned by the service user. workDir = "${stateDir}/declarative-terraform"; + # Under DynamicUser= the absolute work dir is also expressed as a + # relative `StateDirectory=` so systemd creates and owns it. Deriving + # both from `stateDir` is the single source of truth: the path the + # script `cd`s into and the path the unit declares can never drift. + stateDirectoryRelative = lib.removePrefix "/var/lib/" workDir; in + assert lib.assertMsg (!dynamicUser || lib.hasPrefix "/var/lib/" stateDir) + "mkReconcileService: dynamicUser=true requires stateDir to live under /var/lib (got '${stateDir}'), so systemd can express the work dir as a relative StateDirectory=."; { description = "Declarative reconciliation for ${name} (OpenTofu)"; after = afterUnits; @@ -103,7 +108,7 @@ rec { # systemd DynamicUser=true; the `User=` name is hashed to a stable UID # that's reused across units, and systemd creates/owns the state dir. DynamicUser = true; - StateDirectory = stateDirectory; + StateDirectory = stateDirectoryRelative; StateDirectoryMode = "0700"; }; script = '' diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index fab6040..311b41c 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -152,10 +152,10 @@ in group = "keycloak"; # Upstream keycloak runs as DynamicUser=true with no persistent # state dir; allocate a dedicated one owned by the same hashed UID - # via systemd StateDirectory=. + # via systemd StateDirectory= (derived by mkReconcileService from + # stateDir, which must live under /var/lib for that derivation). stateDir = "/var/lib/keycloak"; dynamicUser = true; - stateDirectory = "keycloak/declarative-terraform"; }; } // lib.optionalAttrs bootstrapClient { From d5b1fb7e920d84e1c3e57cba184dcf955e49cd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 15:25:09 +0200 Subject: [PATCH 05/50] docs(services/keycloak): trim verbose inline comments First-pass cleanup across services/keycloak: shorten option descriptions, drop file-header preambles. A later commit reapplies the pass to the resources added afterwards. --- services/keycloak/README.md | 3 +- services/keycloak/checks.nix | 118 +++++++++++++---------------------- services/keycloak/lib.nix | 88 ++++---------------------- services/keycloak/module.nix | 39 +++--------- 4 files changed, 62 insertions(+), 186 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index f8e57fe..3eefc69 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -19,8 +19,7 @@ step with the rest of your system. inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - # This repository. - declarative-services.url = "github:youruser/terraform-providers"; + declarative-services.url = "github:applicative-systems/terraform-providers"; declarative-services.inputs.nixpkgs.follows = "nixpkgs"; }; diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 87f5926..70390a5 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -1,18 +1,13 @@ -# Keycloak pairing check, returned as a single-entry attrset merged into the -# flake's per-system `checks`. -# -# keycloak — Full integration test: boots a VM with services.keycloak + -# services.keycloak.runtime and lets the pairing converge at boot with no -# manual setup. The module's own machinery bootstraps a service-account -# OIDC client via a companion oneshot, then the run-once reconciler -# applies the declared realm. A specialisation adds a second realm to -# exercise config-change reconciliation. Keycloak is a heavy JVM service, -# so the VM gets extra memory and the bootstrap probe budget is wide. +# this tests provisioning of keycloak realms { pkgs, self }: +let + keycloakAdminPassword = "hackme"; +in { keycloak = pkgs.testers.runNixOSTest { name = "declarative-keycloak"; + # cannot use containers here because we use specialisations nodes.machine = { config, ... }: { @@ -25,33 +20,22 @@ config.services.keycloak.settings.http-port ]; - # Stand-ins for operator-managed secret files (sops/agenix in - # production): the database password Keycloak rejects as a store - # path (it expects an /etc-style host path), and the bootstrap admin - # password the reconciler oneshot reads via LoadCredential. + # mock agenix secrets: the module expects passwords to be supplied as files environment.etc."keycloak-db-password".text = "hackme"; - environment.etc."keycloak-admin-password".text = "hackme"; + environment.etc."keycloak-admin-password".text = keycloakAdminPassword; services.keycloak = { enable = true; - # initialAdminPassword seeds the master-realm `admin` user on - # first boot. The bootstrap oneshot then logs in as that user - # (reading the same secret from a file via LoadCredential) to - # mint the service-account client. - initialAdminPassword = "hackme"; + initialAdminPassword = keycloakAdminPassword; settings = { hostname = "keycloak"; http-port = 8080; - # HTTP-only test deployment: skip TLS material and the strict - # hostname URL/scheme validation that aborts startup otherwise. - http-enabled = true; + http-enabled = true; # HTTP-only test deployment hostname-strict = false; }; database.passwordFile = "/etc/keycloak-db-password"; - # No client credentials supplied: the pairing bootstraps its own - # service-account client at boot using the admin password file. runtime = { enable = true; bootstrapAdminPasswordFile = "/etc/keycloak-admin-password"; @@ -63,9 +47,6 @@ }; }; - # Exercises that the reconciler picks up a new declared resource - # on a config switch (the bootstrap oneshot stays a no-op because - # its credentials file persists across reboots). specialisation.addRealm.configuration = { services.keycloak.runtime.realms.delta = { display_name = "Delta Realm"; @@ -77,14 +58,11 @@ import json def admin_token(): - # Anonymous /realms/ only exposes a tiny field set (no - # displayName); use the master-realm admin-cli token to read the - # full realm representation via /admin/realms/. resp = machine.succeed( "curl --fail -s -X POST " "http://localhost:8080/realms/master/protocol/openid-connect/token " "-d grant_type=password -d client_id=admin-cli " - "-d username=admin -d password=hackme" + "-d username=admin -d password=${keycloakAdminPassword}" ) return json.loads(resp)["access_token"] @@ -97,57 +75,45 @@ machine.start() - # The whole chain must converge at boot with zero manual setup: - # keycloak.service -> - # declarative-keycloak-bootstrap.service -> - # declarative-keycloak.service - # wait_for_unit blocks until the run-once reconciler has applied - # every declared resource successfully (a failed apply fails the unit). + # wait until keycloak.service is running and the bootstrap and the provisioning have finished machine.wait_for_unit("declarative-keycloak.service") - # The realm exists and its typed attributes were applied as declared. - acme = get_realm("acme") - assert acme.get("realm") == "acme", f"realm name not applied: {acme}" - assert acme.get("displayName") == "ACME Corp.", f"display_name not applied: {acme}" - assert acme.get("displayNameHtml") == "ACME Corp.", \ - f"display_name_html not applied: {acme}" - - # Per-secret indirection: the admin password was supplied as a host - # file and the minted client_secret was written to /var/lib by the - # bootstrap. Neither must appear in the generated .tf.json. - tfjson = machine.succeed( - "cat /var/lib/keycloak/declarative-terraform/main.tf.json" - ) - assert "hackme" not in tfjson, "admin password leaked into generated .tf.json" - client_secret = machine.succeed( - "cat /var/lib/declarative-keycloak-bootstrap/client_secret" - ).strip() - assert client_secret, "bootstrap did not write client_secret" - assert client_secret not in tfjson, "client_secret leaked into generated .tf.json" - - # State lands under /var/lib/keycloak (via systemd `StateDirectory=` - # under DynamicUser), is readable across runs (the `cat` above - # succeeded), and the reconciler exited 0 -- proving it ran as a - # working non-root UID that owns the state dir. We don't assert a - # specific owner name: DynamicUser allocates an opaque per-unit UID - # the host kernel may render as the overflow uid (65534) when - # stat'd outside the unit's mount namespace. - machine.succeed( - "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" - ) + with subtest("declared realm exists"): + acme = get_realm("acme") + assert acme.get("realm") == "acme", f"realm name not applied: {acme}" + assert acme.get("displayName") == "ACME Corp.", f"display_name not applied: {acme}" + assert acme.get("displayNameHtml") == "ACME Corp.", \ + f"display_name_html not applied: {acme}" - # Re-applying must be idempotent (a second run must also succeed). - machine.succeed("systemctl restart declarative-keycloak.service") + with subtest("secrets did not leak"): + tfjson = machine.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "${keycloakAdminPassword}" not in tfjson, "admin password leaked into generated .tf.json" + client_secret = machine.succeed( + "cat /var/lib/declarative-keycloak-bootstrap/client_secret" + ).strip() + assert client_secret, "bootstrap did not write client_secret" + assert client_secret not in tfjson, "client_secret leaked into generated .tf.json" + + with subtest("tfstate file is not empty"): + machine.succeed( + "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" + ) + + with subtest("reapplying the same config works"): + machine.succeed("systemctl restart declarative-keycloak.service") - # Config-change reconciliation: switching to the specialisation that - # declares a second realm must apply it without manual intervention. + # simulate a subsequent deployment with another realm machine.succeed( "/run/current-system/specialisation/addRealm/bin/switch-to-configuration test" ) - machine.wait_until_succeeds("curl --fail http://localhost:8080/realms/delta") - delta = get_realm("delta") - assert delta.get("displayName") == "Delta Realm", \ - f"delta realm display_name not applied: {delta}" + + with subtest("new realm can be deployed"): + machine.wait_until_succeeds("curl --fail http://localhost:8080/realms/delta") + delta = get_realm("delta") + assert delta.get("displayName") == "Delta Realm", \ + f"delta realm display_name not applied: {delta}" ''; }; } diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 2b8818f..c3d546e 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -1,65 +1,22 @@ -# Keycloak-provider specifics: the keycloak-wrapped OpenTofu executor and the -# .tf.json generation for a Keycloak pairing. The provider-agnostic helpers -# (label/file/reconciler) live in modules/lib and are specialized here for the -# keycloak/keycloak provider (in nixpkgs as -# `pkgs.terraform-providers.keycloak_keycloak`). -# -# The first-wave resource surface is `keycloak_realm` only; follow-ups add -# clients, scopes, roles, groups, users, identity providers, and mappers in -# the same shape (the `resourceTypes` record is extended; the renderer is -# already provider-agnostic). -# -# Two divergences from the forgejo template, forced by upstream Keycloak's -# shape: -# -# 1. Auth is OAuth2 client-credentials, not a single API token. Two -# sensitive Terraform variables are emitted: `keycloak_client_secret` -# (the primary, threaded through the generic reconciler as `tokenVar`) -# and `keycloak_client_id` (an extra credential id flowed through the -# generic reconciler's `credentials` map). Both are fed from systemd -# `LoadCredential=` at apply time, never written to the world-readable -# store. -# -# 2. The upstream NixOS keycloak module runs as `DynamicUser=true` with no -# persistent state directory. The module therefore enables the new -# `dynamicUser`/`stateDirectory` modes of `mkReconcileService` so the -# reconciler is allocated the same hashed UID as `keycloak.service` and -# writes its Terraform state into a systemd-managed StateDirectory at -# `/var/lib/keycloak/declarative-terraform`. -# -# Imported as `import ./lib.nix { inherit pkgs; }` from the Keycloak module -# and checks. +# FIXME this only provisions keycloak_realm resources for now { pkgs }: let inherit (pkgs) lib; genlib = import ../../modules/lib { inherit pkgs; }; inherit (genlib) tfLabel; - # In-nixpkgs provider; pin `required_providers` to its packaged version so - # the offline plugin mirror always matches the manifest. provider = pkgs.terraform-providers.keycloak_keycloak; providerVersion = provider.version; - # OAuth2 client-credentials grant: two paired sensitive variables. - # `tokenVar` is the primary credential flowed through the generic - # reconciler; `clientIdVar` is exported so module.nix can wire the matching - # bootstrap-produced (or operator-supplied) file into the reconciler's - # `credentials` map. + # credential names for the (less privileged) keycloak provisioner tokenVar = "keycloak_client_secret"; clientIdVar = "keycloak_client_id"; - # OpenTofu wrapped with the keycloak/keycloak provider. The provider binary - # lives in the wrapper's NIX_TERRAFORM_PLUGIN_DIR, so `tofu init`/`apply` - # resolve it with no registry access. executor = pkgs.opentofu.withPlugins (_: [ provider ]); ty = lib.types; - # Per-attribute option constructors. `o*` declare an *optional* attribute - # (`nullOr T`, default null -> omitted from the generated `.tf.json` when - # unset); `r*` declare a *required* attribute (no default -> a missing value - # is an eval-time error). Each carries the exact value shape the provider - # accepts. + # o* for optional oStr = description: lib.mkOption { @@ -75,7 +32,7 @@ let inherit description; }; - # The full keycloak/keycloak resource surface (first wave: realm only). Per + # The full keycloak/keycloak resource surface. Per # resource: # type the `keycloak_*` resource type # prefix unique Terraform label prefix @@ -105,11 +62,7 @@ let }; }; - # One option collection per resource: an `attrsOf` strictly-typed submodule. - # The submodule's options are the resource's settable attributes, plus the - # reference inputs (resolved into Terraform references at generation) and - # one `File` input per secret. No `freeformType`: an undeclared - # attribute is a definition error. + # generate nixos options for resources from resourceTypes resourceOptions = lib.mapAttrs ( _: spec: lib.mkOption { @@ -150,10 +103,7 @@ let } ) resourceTypes; - # Recursively drop null-valued attributes (unset options) and the submodule - # bookkeeping key `_module`, so the generated JSON carries only what the - # user actually set -- at every nesting level, including any typed nested - # objects added by later resources. + # remove null-valued and _module attributes cleanNulls = v: if builtins.isAttrs v then @@ -163,16 +113,8 @@ let else v; - # Build the Terraform JSON config for a Keycloak pairing from the module's - # cfg, together with the (id -> host path) credential map for any - # host-file-sourced secrets. Returns { config; credentials; }. - # - # Contains NO provider secret: the `client_id`/`client_secret` pair and - # every `File` secret are supplied at apply time as sensitive input - # variables fed from systemd `LoadCredential=`, never written to the store. - # A *literal* secret attribute (e.g. a value set directly) still lands in - # the world-readable store -- use the matching `File` option to avoid - # that. + # build JSON config and credential map (id -> host path) + # secrets are provided at apply time keycloakTfConfig = cfg: let @@ -267,13 +209,8 @@ let null ) (spec.requiredAttrs or [ ]); in - # deepSeq forces the validation thunks (whose results are otherwise - # unused) so a violated check `throw`s here. These live in the - # generator, not in NixOS `config.assertions`, because assertions only - # fire during a full NixOS system evaluation -- whereas - # `keycloakTfConfig` is also called standalone (e.g. tests, `nix - # eval`), where assertions would be silently skipped and a malformed - # config would surface opaquely at `tofu apply`. + # use deepSeq to force evaluation of checks + # (these are not config.assertions so they can be used outside a nixos system build) lib.nameValuePair (tfLabel spec.prefix key) ( builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( cleanNulls (base // nameInject // refAttrs // secretAttrs) @@ -285,8 +222,7 @@ let c: spec: lib.nameValuePair spec.type (lib.mapAttrs' (renderItem c spec) cfg.${c}) ) nonEmpty; - # Every host-file-sourced secret across the config, for the sensitive - # input variables and the (id -> host path) credential map. + # combine sensitive variables with (id -> host path) credential map allSecrets = lib.concatLists ( lib.mapAttrsToList ( c: spec: lib.concatLists (lib.mapAttrsToList (key: item: itemSecrets c spec key item) cfg.${c}) @@ -345,7 +281,5 @@ in clientIdVar ; - # The generic run-once reconciler, specialized with the keycloak executor - # and the keycloak_client_secret credential. mkReconcileService = args: genlib.mkReconcileService (args // { inherit executor tokenVar; }); } diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index 311b41c..5220638 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -1,22 +1,3 @@ -# Keycloak <-> keycloak/keycloak provider pairing. -# -# Enables the upstream `services.keycloak` module unchanged, and adds a -# run-once reconciler that applies declarative runtime state (realms, ...) -# against the live Keycloak admin API once `keycloak.service` is up. Unless -# `clientIdFile` and `clientSecretFile` are both supplied, a companion -# oneshot (`declarative-keycloak-bootstrap.service`) mints a dedicated -# service-account OIDC client in the `master` realm and grants it -# `realm-admin` on the `realm-management` client; the reconciler then -# authenticates via OAuth2 client-credentials grant. The bootstrap relies -# on the operator providing the master-realm admin's password via -# `bootstrapAdminPasswordFile` (read with systemd `LoadCredential=`, -# never copied into the world-readable Nix store). -# -# Upstream Keycloak uses `DynamicUser=true` with no persistent state dir; -# the reconciler enables the corresponding mode of mkReconcileService so it -# is allocated the same hashed `keycloak` UID as `keycloak.service` and -# writes Terraform state into `/var/lib/keycloak/declarative-terraform` -# via systemd `StateDirectory=`. { config, lib, @@ -38,8 +19,8 @@ let defaultBaseUrl = "http://localhost:${toString keycloak.settings.http-port}"; - # Companion oneshot that mints the reconciler's service-account OIDC - # client. Only wired in when the operator does not supply their own + # Companion oneshot that creates the reconciler's service-account OIDC + # client. Only used when the operator does not supply their own # (clientIdFile, clientSecretFile) pair. bootstrapServiceName = "declarative-keycloak-bootstrap"; bootstrapClient = cfg.clientIdFile == null; @@ -171,7 +152,7 @@ in pkgs.coreutils ]; environment = { - # kcadm.sh stashes its token cache under $HOME/.keycloak; under + # kcadm.sh places its token cache under $HOME/.keycloak; under # DynamicUser there is no real home, so direct it into the # StateDirectory (writable and owned by the same hashed UID). HOME = "/var/lib/${bootstrapServiceName}"; @@ -193,16 +174,13 @@ in client_id_file="$STATE_DIRECTORY/client_id" client_secret_file="$STATE_DIRECTORY/client_secret" - # Mint-once: the persisted credential pair is the "already - # bootstrapped" marker. /var/lib survives reboots, so this oneshot - # is no-op on every boot after the first successful run. + # the persisted credential pair is the "already bootstrapped" marker. + # /var/lib survives reboots, so this won't run on every boot after the first successful run if [ -s "$client_id_file" ] && [ -s "$client_secret_file" ]; then exit 0 fi - # `After=keycloak.service` waits for the Type=notify ready flag, - # but Quarkus keeps initialising for tens of seconds past that; - # poll the admin endpoint explicitly before talking to kcadm. + # poll max 3 minutes until keycloak has fully booted for _ in $(seq 1 90); do if curl -fsS -o /dev/null "${cfg.baseUrl}/realms/master"; then break @@ -238,9 +216,8 @@ in sa_uid="$(kcadm.sh get clients/$client_uuid/service-account-user -r master \ | jq -r .id)" - # The master realm's realm-level `admin` is a composite role that - # grants global admin across every realm; assigning it to the - # service-account user gives the reconciler full reach. + # The master realm's `admin` grants global admin across every realm; + # assigning it to the service-account user gives the reconciler full access. # Idempotent: kcadm tolerates re-grant. kcadm.sh add-roles -r master \ --uid "$sa_uid" \ From 991d22a0fa28dd71b65ad42191e9b813d847cd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 15:41:03 +0200 Subject: [PATCH 06/50] feat(services/keycloak): expand keycloak_realm flat attribute surface Adds ~50 settable scalar attrs of keycloak_realm: login config, ssl_required, themes, token lifespans, password_policy, authentication flow bindings, free-form attributes map, default client-scope lists. The nested-block schemas (smtp_server, internationalization, security_defenses, otp_policy, web_authn_*) wait for renderer support for `TypeList+MaxItems:1` block wrapping. VM test sets and asserts a representative cross-section (registration_allowed, login_theme, ssl_required, access_token_lifespan, password_policy, attributes). --- services/keycloak/checks.nix | 18 +++++++ services/keycloak/lib.nix | 98 +++++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 70390a5..a2ef937 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -43,6 +43,15 @@ in realms.acme = { display_name = "ACME Corp."; display_name_html = "ACME Corp."; + # exercises a representative cross-section of typed attrs + registration_allowed = true; + login_theme = "keycloak"; + ssl_required = "external"; + access_token_lifespan = "10m"; + password_policy = "length(8)"; + attributes = { + "userProfileEnabled" = "true"; + }; }; }; }; @@ -85,6 +94,15 @@ in assert acme.get("displayNameHtml") == "ACME Corp.", \ f"display_name_html not applied: {acme}" + with subtest("extended realm attrs reach the API"): + assert acme.get("registrationAllowed") is True, f"registration_allowed: {acme}" + assert acme.get("loginTheme") == "keycloak", f"login_theme: {acme}" + assert acme.get("sslRequired") == "external", f"ssl_required: {acme}" + assert acme.get("accessTokenLifespan") == 600, f"access_token_lifespan: {acme}" + assert acme.get("passwordPolicy") == "length(8)", f"password_policy: {acme}" + assert acme.get("attributes", {}).get("userProfileEnabled") == "true", \ + f"attributes: {acme}" + with subtest("secrets did not leak"): tfjson = machine.succeed( "cat /var/lib/keycloak/declarative-terraform/main.tf.json" diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index c3d546e..0deb3b4 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -16,7 +16,7 @@ let ty = lib.types; - # o* for optional + # o* for optional, r* for required oStr = description: lib.mkOption { @@ -31,6 +31,39 @@ let default = null; inherit description; }; + oInt = + description: + lib.mkOption { + type = ty.nullOr ty.int; + default = null; + inherit description; + }; + oListStr = + description: + lib.mkOption { + type = ty.nullOr (ty.listOf ty.str); + default = null; + inherit description; + }; + oAttrsStr = + description: + lib.mkOption { + type = ty.nullOr (ty.attrsOf ty.str); + default = null; + inherit description; + }; + rStr = + description: + lib.mkOption { + type = ty.str; + inherit description; + }; + rBool = + description: + lib.mkOption { + type = ty.bool; + inherit description; + }; # The full keycloak/keycloak resource surface. Per # resource: @@ -58,6 +91,69 @@ let enabled = oBool "Is the realm enabled?"; display_name = oStr "User-facing display name."; display_name_html = oStr "HTML-formatted display name."; + + # general + user_managed_access = oBool "Enable user-managed access."; + organizations_enabled = oBool "Enable the organizations feature."; + admin_permissions_enabled = oBool "Enable the v2 admin permissions feature."; + terraform_deletion_protection = oBool "Refuse to destroy the realm on `tofu destroy`."; + attributes = oAttrsStr "Free-form realm attribute map."; + + # login config + registration_allowed = oBool "Allow self-registration."; + registration_email_as_username = oBool "Use email as username on registration."; + edit_username_allowed = oBool "Allow users to edit their username."; + reset_password_allowed = oBool "Allow users to reset their password."; + remember_me = oBool "Offer the \"Remember Me\" checkbox on login."; + verify_email = oBool "Require email verification."; + login_with_email_allowed = oBool "Allow login with email."; + duplicate_emails_allowed = oBool "Allow duplicate emails across users."; + ssl_required = oStr "SSL required: 'none', 'external' (default), or 'all'."; + + # themes + login_theme = oStr "Login theme."; + account_theme = oStr "Account console theme."; + admin_theme = oStr "Admin console theme."; + email_theme = oStr "Email theme."; + + # tokens + default_signature_algorithm = oStr "Default JWS signing algorithm."; + revoke_refresh_token = oBool "Revoke refresh tokens on use."; + refresh_token_max_reuse = oInt "Max number of times a refresh token can be reused."; + sso_session_idle_timeout = oStr "SSO session idle timeout (duration string, e.g. \"30m\")."; + sso_session_idle_timeout_remember_me = oStr "SSO session idle timeout for \"Remember Me\" sessions."; + sso_session_max_lifespan = oStr "SSO session max lifespan."; + sso_session_max_lifespan_remember_me = oStr "SSO session max lifespan for \"Remember Me\" sessions."; + offline_session_idle_timeout = oStr "Offline session idle timeout."; + offline_session_max_lifespan = oStr "Offline session max lifespan."; + offline_session_max_lifespan_enabled = oBool "Cap offline sessions to `offline_session_max_lifespan`."; + client_session_idle_timeout = oStr "Client session idle timeout (falls back to SSO idle)."; + client_session_max_lifespan = oStr "Client session max lifespan (falls back to SSO max)."; + access_token_lifespan = oStr "Access token lifespan."; + access_token_lifespan_for_implicit_flow = oStr "Access token lifespan for the implicit flow."; + access_code_lifespan = oStr "Auth code lifespan."; + access_code_lifespan_login = oStr "Login-action code lifespan."; + access_code_lifespan_user_action = oStr "User-action code lifespan."; + action_token_generated_by_user_lifespan = oStr "Lifespan of user-generated action tokens."; + action_token_generated_by_admin_lifespan = oStr "Lifespan of admin-generated action tokens."; + oauth2_device_code_lifespan = oStr "OAuth2 device-code lifespan."; + oauth2_device_polling_interval = oInt "OAuth2 device-code polling interval (seconds)."; + + # authentication + password_policy = oStr "Password policy string (e.g. \"upperCase(1) and length(8) and notUsername(undefined)\")."; + + # authentication flow bindings (alias of a flow defined in the realm) + browser_flow = oStr "Authentication flow alias bound to the browser flow."; + registration_flow = oStr "Authentication flow alias bound to the registration flow."; + direct_grant_flow = oStr "Authentication flow alias bound to the direct-grant flow."; + reset_credentials_flow = oStr "Authentication flow alias bound to the reset-credentials flow."; + client_authentication_flow = oStr "Authentication flow alias bound to the client-auth flow."; + docker_authentication_flow = oStr "Authentication flow alias bound to the docker-auth flow."; + first_broker_login_flow = oStr "Authentication flow alias bound to the first-broker-login flow."; + + # default client scopes (referenced by name) + default_default_client_scopes = oListStr "Default client scopes auto-granted to new clients."; + default_optional_client_scopes = oListStr "Optional client scopes available to new clients."; }; }; }; From ecb640e70afffcb8e39a74b138f10844fc50489b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 15:45:40 +0200 Subject: [PATCH 07/50] feat(services/keycloak): add roles and default_roles - roles: realm- or client-scoped keycloak roles with composite_roles, description, attributes. - default_roles: per-realm bind of roles auto-granted to new users. Adds a shared realmRef so follow-up resources (groups, users, clients, mappers) can reuse it. VM test declares a realm-level role and a default-roles binding, then asserts via the admin API that the role exists with the declared description and is present in the realm's composite default-roles role. --- services/keycloak/checks.nix | 35 +++++++++++++++++++++++++++ services/keycloak/lib.nix | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index a2ef937..3e9aa1e 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -53,6 +53,21 @@ in "userProfileEnabled" = "true"; }; }; + + # realm-level role + default-roles binding + roles.acme_engineer = { + realm = "acme"; + name = "engineer"; + description = "ACME engineering role"; + }; + default_roles.acme = { + realm = "acme"; + default_roles = [ + "offline_access" + "uma_authorization" + "engineer" + ]; + }; }; }; @@ -103,6 +118,26 @@ in assert acme.get("attributes", {}).get("userProfileEnabled") == "true", \ f"attributes: {acme}" + with subtest("realm role exists with description"): + tok = admin_token() + role = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/roles/engineer" + )) + assert role.get("description") == "ACME engineering role", f"role: {role}" + + with subtest("default-roles binding includes the new role"): + tok = admin_token() + # the composite "default-roles-" role aggregates the realm's + # default roles; we read its composites to assert membership. + composites = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/roles/default-roles-acme/composites" + )) + names = {r["name"] for r in composites} + for r in ("offline_access", "uma_authorization", "engineer"): + assert r in names, f"default role {r!r} missing from {names}" + with subtest("secrets did not leak"): tfjson = machine.succeed( "cat /var/lib/keycloak/declarative-terraform/main.tf.json" diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 0deb3b4..207538f 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -65,6 +65,21 @@ let inherit description; }; + # ref spec shared by almost every non-realm resource: realm_id is a numeric + # id the user can't know, so it must resolve to a managed realm by key. + realmRef = { + attr = "realm_id"; + targets = [ + { + collection = "realms"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed realm (services.keycloak.runtime.realms.) this belongs to."; + }; + # The full keycloak/keycloak resource surface. Per # resource: # type the `keycloak_*` resource type @@ -156,6 +171,37 @@ let default_optional_client_scopes = oListStr "Optional client scopes available to new clients."; }; }; + + roles = { + type = "keycloak_role"; + prefix = "role"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + # client_id ref (-> keycloak_openid_client) lands when openid_clients do. + description = "Keycloak roles (realm-level by default), keyed by role name."; + attrs = { + name = oStr "Role name. Defaults to the attribute key."; + description = oStr "Role description."; + # opaque list: users supply role UUIDs or ${keycloak_role.X.id} + # interpolations directly. Managed list-refs land later. + composite_roles = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) the composite includes."; + attributes = oAttrsStr "Free-form role attribute map."; + }; + }; + + default_roles = { + type = "keycloak_default_roles"; + prefix = "default_roles"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "default_roles" ]; + description = "Realm-level default roles auto-granted to new users, keyed by an arbitrary label."; + attrs = { + default_roles = oListStr "Role names auto-granted to every new user of the realm."; + }; + }; }; # generate nixos options for resources from resourceTypes From 8ac75305c1cec26799cdaafc0dfc9c31dcc6cb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 15:52:37 +0200 Subject: [PATCH 08/50] feat(services/keycloak): add groups + default_groups + memberships + roles - groups: keyed by name; optional parent_id resolves to another managed group for nested hierarchies; description + attributes. - default_groups: realm-level set of groups new users auto-join (opaque UUIDs / `${keycloak_group.X.id}` interpolations until managed list-refs land). - group_memberships: users-to-group binding by username. - group_roles: roles-to-group binding (opaque role-id list) with an `exhaustive` flag for full-replace vs additive. VM test creates a nested group hierarchy with a role assignment and asserts via the admin API that the subgroup lives under its parent (via /groups//children, since KC 26 paginates subGroups) and that the engineer role is in the parent's realm role mappings. --- services/keycloak/checks.nix | 42 ++++++++++++++++ services/keycloak/lib.nix | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 3e9aa1e..20811eb 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -68,6 +68,25 @@ in "engineer" ]; }; + + # group hierarchy: parent_id resolves to a managed group + groups.acme_eng = { + realm = "acme"; + name = "engineering"; + attributes."team" = "infra"; + }; + groups.acme_eng_backend = { + realm = "acme"; + name = "backend"; + parent = "acme_eng"; + }; + group_roles.acme_eng_admins = { + realm = "acme"; + group = "acme_eng"; + # raw role id reference: managed list-refs not yet supported. + role_ids = [ "\${keycloak_role.role_acme_engineer.id}" ]; + exhaustive = true; + }; }; }; @@ -138,6 +157,29 @@ in for r in ("offline_access", "uma_authorization", "engineer"): assert r in names, f"default role {r!r} missing from {names}" + with subtest("group hierarchy + role assignment"): + tok = admin_token() + groups = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/groups" + )) + eng = next((g for g in groups if g["name"] == "engineering"), None) + assert eng, f"engineering group missing: {groups}" + # KC 26 returns subGroupCount but a paginated `subGroups` (empty by + # default); the /children endpoint gives the actual subgroup list. + children = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/acme/groups/{eng['id']}/children" + )) + assert any(c["name"] == "backend" for c in children), \ + f"backend subgroup missing under engineering: {children}" + eng_roles = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/acme/groups/{eng['id']}/role-mappings/realm" + )) + assert any(r["name"] == "engineer" for r in eng_roles), \ + f"engineer role not assigned to engineering group: {eng_roles}" + with subtest("secrets did not leak"): tfjson = machine.succeed( "cat /var/lib/keycloak/declarative-terraform/main.tf.json" diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 207538f..a6215b1 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -202,6 +202,104 @@ let default_roles = oListStr "Role names auto-granted to every new user of the realm."; }; }; + + groups = { + type = "keycloak_group"; + prefix = "group"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + parent = { + attr = "parent_id"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = true; + required = false; + description = "Optional parent group (key of another managed group) for nested groups."; + }; + }; + description = "Keycloak groups, keyed by group name."; + attrs = { + name = oStr "Group name. Defaults to the attribute key."; + description = oStr "Group description."; + attributes = oAttrsStr "Free-form group attribute map."; + }; + }; + + default_groups = { + type = "keycloak_default_groups"; + prefix = "default_groups"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "group_ids" ]; + description = "Realm-level default groups auto-joined by new users, keyed by an arbitrary label."; + attrs = { + # opaque list: users supply group UUIDs or ${keycloak_group.X.id} + # interpolations directly. Managed list-refs land later. + group_ids = oListStr "Group UUIDs (or `\${keycloak_group.X.id}` refs) new users auto-join."; + }; + }; + + group_memberships = { + type = "keycloak_group_memberships"; + prefix = "group_membership"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + group = { + attr = "group_id"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed group (services.keycloak.runtime.groups.) the members are added to."; + }; + }; + requiredAttrs = [ "members" ]; + description = "Keycloak group memberships, keyed by an arbitrary label."; + attrs = { + members = oListStr "Usernames of users to add to the group."; + }; + }; + + group_roles = { + type = "keycloak_group_roles"; + prefix = "group_roles"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + group = { + attr = "group_id"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed group (services.keycloak.runtime.groups.) to assign roles to."; + }; + }; + requiredAttrs = [ "role_ids" ]; + description = "Role assignments for a group, keyed by an arbitrary label."; + attrs = { + role_ids = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) granted to the group."; + exhaustive = oBool "If true, only the listed roles remain assigned; if false, listed roles are added without removing others."; + }; + }; }; # generate nixos options for resources from resourceTypes From 4d12ed9b1cd9b256281b9385047b1dfd546fc135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 15:57:08 +0200 Subject: [PATCH 09/50] feat(services/keycloak): add users + user_roles + user_groups - users: keyed by username (lowercase, per provider). Settable: email, email_verified, first_name, last_name, enabled, attributes, required_actions. initial_password and federated_identity wait for nested-block renderer support. - user_roles, user_groups: per-user bindings with the exhaustive flag. VM test creates a user with attributes, role assignment, and group membership; asserts all three via the admin API. --- services/keycloak/checks.nix | 48 ++++++++++++++++++++++ services/keycloak/lib.nix | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 20811eb..cb7900a 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -87,6 +87,30 @@ in role_ids = [ "\${keycloak_role.role_acme_engineer.id}" ]; exhaustive = true; }; + + # user + bindings; initial_password is a nested-block secret that + # needs renderer extension, so we set a required_action instead. + users.acme_alice = { + realm = "acme"; + username = "alice"; + email = "alice@acme.example"; + first_name = "Alice"; + last_name = "Anderson"; + email_verified = true; + required_actions = [ "UPDATE_PASSWORD" ]; + }; + user_roles.acme_alice = { + realm = "acme"; + user = "acme_alice"; + role_ids = [ "\${keycloak_role.role_acme_engineer.id}" ]; + exhaustive = false; + }; + user_groups.acme_alice = { + realm = "acme"; + user = "acme_alice"; + group_ids = [ "\${keycloak_group.group_acme_eng.id}" ]; + exhaustive = false; + }; }; }; @@ -157,6 +181,30 @@ in for r in ("offline_access", "uma_authorization", "engineer"): assert r in names, f"default role {r!r} missing from {names}" + with subtest("user exists with attributes, roles, and group membership"): + tok = admin_token() + users = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/users?username=alice" + )) + alice = next((u for u in users if u["username"] == "alice"), None) + assert alice, f"alice missing: {users}" + assert alice.get("email") == "alice@acme.example", f"alice: {alice}" + assert alice.get("firstName") == "Alice", f"alice: {alice}" + assert "UPDATE_PASSWORD" in alice.get("requiredActions", []), f"alice: {alice}" + alice_roles = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/acme/users/{alice['id']}/role-mappings/realm" + )) + assert any(r["name"] == "engineer" for r in alice_roles), \ + f"engineer role missing on alice: {alice_roles}" + alice_groups = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/acme/users/{alice['id']}/groups" + )) + assert any(g["name"] == "engineering" for g in alice_groups), \ + f"engineering group missing on alice: {alice_groups}" + with subtest("group hierarchy + role assignment"): tok = admin_token() groups = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index a6215b1..f11b4b4 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -300,6 +300,84 @@ let exhaustive = oBool "If true, only the listed roles remain assigned; if false, listed roles are added without removing others."; }; }; + + users = { + type = "keycloak_user"; + prefix = "user"; + nameAttr = "username"; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "username" ]; + # initial_password / federated_identity are nested blocks with a Sensitive + # `value`; they need File support for nested attrs and land later. + description = "Keycloak users, keyed by username (must be lowercase)."; + attrs = { + username = oStr "Username (lowercase). Defaults to the attribute key."; + email = oStr "Email address."; + email_verified = oBool "Has the user verified their email?"; + first_name = oStr "First name."; + last_name = oStr "Last name."; + enabled = oBool "Is the user enabled?"; + attributes = oAttrsStr "Free-form user attribute map."; + required_actions = oListStr "Required actions on next login (e.g. \"VERIFY_EMAIL\", \"UPDATE_PASSWORD\")."; + }; + }; + + user_roles = { + type = "keycloak_user_roles"; + prefix = "user_roles"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + user = { + attr = "user_id"; + targets = [ + { + collection = "users"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed user (services.keycloak.runtime.users.) to assign roles to."; + }; + }; + requiredAttrs = [ "role_ids" ]; + description = "Role assignments for a user, keyed by an arbitrary label."; + attrs = { + role_ids = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) granted to the user."; + exhaustive = oBool "If true, only the listed roles remain assigned; otherwise the listed roles are added without removing others."; + }; + }; + + user_groups = { + type = "keycloak_user_groups"; + prefix = "user_groups"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + user = { + attr = "user_id"; + targets = [ + { + collection = "users"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed user (services.keycloak.runtime.users.) to add to groups."; + }; + }; + requiredAttrs = [ "group_ids" ]; + description = "Group memberships for a user, keyed by an arbitrary label."; + attrs = { + group_ids = oListStr "Group UUIDs (or `\${keycloak_group.X.id}` refs) the user joins."; + exhaustive = oBool "If true, only the listed groups remain joined; otherwise the listed groups are added without removing others."; + }; + }; }; # generate nixos options for resources from resourceTypes From aef3482bcd4f960273b1537b707fafc4b42e3b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 16:01:03 +0200 Subject: [PATCH 10/50] feat(services/keycloak): add openid_client_scopes + saml_client_scopes Per-realm OAuth2 / SAML consent scopes with consent_screen_text, gui_order, extra_config (openid scopes also carry include_in_token_scope). VM test creates an openid_client_scope and asserts via /admin/realms//client-scopes that it exists with the declared description and consent text. --- services/keycloak/checks.nix | 21 +++++++++++++++++++++ services/keycloak/lib.nix | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index cb7900a..17ca550 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -111,6 +111,15 @@ in group_ids = [ "\${keycloak_group.group_acme_eng.id}" ]; exhaustive = false; }; + + openid_client_scopes.acme_profile = { + realm = "acme"; + name = "acme-profile"; + description = "ACME profile scope"; + consent_screen_text = "Access your ACME profile"; + include_in_token_scope = true; + gui_order = 10; + }; }; }; @@ -205,6 +214,18 @@ in assert any(g["name"] == "engineering" for g in alice_groups), \ f"engineering group missing on alice: {alice_groups}" + with subtest("openid client scope exists with declared attrs"): + tok = admin_token() + scopes = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/client-scopes" + )) + s = next((x for x in scopes if x["name"] == "acme-profile"), None) + assert s, f"acme-profile scope missing: {[x['name'] for x in scopes]}" + assert s.get("description") == "ACME profile scope", f"scope: {s}" + assert s.get("attributes", {}).get("consent.screen.text") == "Access your ACME profile", \ + f"consent_screen_text: {s}" + with subtest("group hierarchy + role assignment"): tok = admin_token() groups = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index f11b4b4..66ee5ea 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -378,6 +378,39 @@ let exhaustive = oBool "If true, only the listed groups remain joined; otherwise the listed groups are added without removing others."; }; }; + + openid_client_scopes = { + type = "keycloak_openid_client_scope"; + prefix = "openid_client_scope"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "OpenID client scopes (per-realm), keyed by scope name."; + attrs = { + name = oStr "Scope name. Defaults to the attribute key."; + description = oStr "Scope description."; + consent_screen_text = oStr "Text shown on the consent screen."; + include_in_token_scope = oBool "Include the scope name in the issued token's `scope` claim?"; + gui_order = oInt "Display order in the admin UI."; + extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; + }; + }; + + saml_client_scopes = { + type = "keycloak_saml_client_scope"; + prefix = "saml_client_scope"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "SAML client scopes (per-realm), keyed by scope name."; + attrs = { + name = oStr "Scope name. Defaults to the attribute key."; + description = oStr "Scope description."; + consent_screen_text = oStr "Text shown on the consent screen."; + gui_order = oInt "Display order in the admin UI."; + extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; + }; + }; }; # generate nixos options for resources from resourceTypes From aa3e6a7cb01f38274b61cfde517c2e6519a3bce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 16:07:23 +0200 Subject: [PATCH 11/50] feat(services/keycloak): add openid_clients + scope/service-account bindings - openid_clients: ~45 typed attributes covering the core OIDC client surface (flows, redirect URIs, web origins, token timeouts, consent, device authorization). client_secret takes the standard File indirection. Nested authorization + authentication_flow_binding_ overrides plus the write-only client_secret_wo variant land later. - openid_client_default_scopes / openid_client_optional_scopes: per-client bindings to managed openid_client_scopes by name. - openid_client_service_account_roles: grant per-client role to a service-account user. - openid_client_service_account_realm_roles: grant realm role to a service-account user. VM test creates an openid_client with a literal client_secret and a default-scope binding; asserts via the admin API that the client and its bound scopes exist. --- services/keycloak/checks.nix | 41 ++++++++ services/keycloak/lib.nix | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 17ca550..680cbd3 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -120,6 +120,31 @@ in include_in_token_scope = true; gui_order = 10; }; + + # OpenID client with a literal secret + scope bindings. + openid_clients.acme_app = { + realm = "acme"; + client_id = "acme-app"; + name = "ACME App"; + access_type = "CONFIDENTIAL"; + client_secret = "topsecret"; + standard_flow_enabled = true; + direct_access_grants_enabled = true; + service_accounts_enabled = true; + valid_redirect_uris = [ "https://app.acme.example/*" ]; + web_origins = [ "https://app.acme.example" ]; + consent_required = false; + full_scope_allowed = true; + }; + openid_client_default_scopes.acme_app = { + realm = "acme"; + client = "acme_app"; + default_scopes = [ + "profile" + "email" + "acme-profile" + ]; + }; }; }; @@ -214,6 +239,22 @@ in assert any(g["name"] == "engineering" for g in alice_groups), \ f"engineering group missing on alice: {alice_groups}" + with subtest("openid client exists with declared scopes attached"): + tok = admin_token() + clients = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/clients?clientId=acme-app" + )) + app = clients[0] + assert app["clientId"] == "acme-app", f"app: {app}" + assert app["enabled"] is True, f"app: {app}" + default_scopes = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/acme/clients/{app['id']}/default-client-scopes" + )) + names = {s["name"] for s in default_scopes} + assert "acme-profile" in names, f"acme-profile not bound as default scope: {names}" + with subtest("openid client scope exists with declared attrs"): tok = admin_token() scopes = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 66ee5ea..def194c 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -411,6 +411,181 @@ let extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; }; }; + + openid_clients = { + type = "keycloak_openid_client"; + prefix = "openid_client"; + nameAttr = "client_id"; + scope = null; + refs.realm = realmRef; + secrets = [ "client_secret" ]; + # Skips nested blocks (authorization, authentication_flow_binding_overrides) + # and write-only secret variants (client_secret_wo) -- those need + # nested-block / write-only renderer extensions and land separately. + description = "OpenID Connect clients (per-realm), keyed by clientId."; + attrs = { + client_id = oStr "OAuth2 clientId. Defaults to the attribute key."; + name = oStr "Display name."; + description = oStr "Client description."; + enabled = oBool "Is the client enabled?"; + access_type = oStr "Access type: 'CONFIDENTIAL', 'PUBLIC', or 'BEARER-ONLY'."; + + client_secret = oStr "Client secret. Prefer `client_secretFile` to keep it out of the world-readable store."; + client_authenticator_type = oStr "Client authenticator type (default 'client-secret')."; + + standard_flow_enabled = oBool "Enable the standard (authorization code) flow."; + implicit_flow_enabled = oBool "Enable the implicit flow."; + direct_access_grants_enabled = oBool "Enable direct-access (password) grants."; + service_accounts_enabled = oBool "Enable a service account for client-credentials grants."; + frontchannel_logout_enabled = oBool "Enable front-channel logout."; + + valid_redirect_uris = oListStr "Valid redirect URIs (sets/wildcards allowed)."; + valid_post_logout_redirect_uris = oListStr "Valid post-logout redirect URIs."; + web_origins = oListStr "Allowed CORS origins."; + + root_url = oStr "Root URL."; + admin_url = oStr "Admin URL."; + base_url = oStr "Base URL."; + login_theme = oStr "Per-client login theme."; + + pkce_code_challenge_method = oStr "PKCE code-challenge method (e.g. 'S256')."; + require_dpop_bound_tokens = oBool "Require DPoP-bound tokens."; + + access_token_lifespan = oStr "Override realm-level access token lifespan."; + client_offline_session_idle_timeout = oStr "Override realm-level offline-session idle timeout."; + client_offline_session_max_lifespan = oStr "Override realm-level offline-session max lifespan."; + client_session_idle_timeout = oStr "Override realm-level client-session idle timeout."; + client_session_max_lifespan = oStr "Override realm-level client-session max lifespan."; + + exclude_session_state_from_auth_response = oBool "Exclude session_state from auth responses."; + exclude_issuer_from_auth_response = oBool "Exclude issuer from auth responses."; + + full_scope_allowed = oBool "Grant the full scope by default."; + consent_required = oBool "Require consent on first use."; + display_on_consent_screen = oBool "Display the client on the consent screen."; + consent_screen_text = oStr "Text shown on the consent screen."; + + use_refresh_tokens = oBool "Issue refresh tokens."; + use_refresh_tokens_client_credentials = oBool "Issue refresh tokens for client-credentials grants."; + standard_token_exchange_enabled = oBool "Enable standard token exchange."; + allow_refresh_token_in_standard_token_exchange = oStr "Refresh-token policy for standard token exchange ('NO', 'SAME_SESSION', 'YES')."; + + frontchannel_logout_url = oStr "Front-channel logout URL."; + backchannel_logout_url = oStr "Back-channel logout URL."; + backchannel_logout_session_required = oBool "Include session_id in back-channel logout requests."; + backchannel_logout_revoke_offline_sessions = oBool "Revoke offline sessions on back-channel logout."; + + oauth2_device_authorization_grant_enabled = oBool "Enable the OAuth2 device authorization grant."; + oauth2_device_code_lifespan = oStr "Device code lifespan."; + oauth2_device_polling_interval = oStr "Device polling interval."; + + always_display_in_console = oBool "Always display the client in the user account console."; + extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; + }; + }; + + openid_client_default_scopes = { + type = "keycloak_openid_client_default_scopes"; + prefix = "openid_client_default_scopes"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed OpenID client (services.keycloak.runtime.openid_clients.) the scope binding applies to."; + }; + }; + requiredAttrs = [ "default_scopes" ]; + description = "Default OAuth2 scopes auto-attached to a client, keyed by an arbitrary label."; + attrs = { + default_scopes = oListStr "Names of scopes attached by default."; + }; + }; + + openid_client_optional_scopes = { + type = "keycloak_openid_client_optional_scopes"; + prefix = "openid_client_optional_scopes"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed OpenID client (services.keycloak.runtime.openid_clients.) the scope binding applies to."; + }; + }; + requiredAttrs = [ "optional_scopes" ]; + description = "Optional OAuth2 scopes available to a client, keyed by an arbitrary label."; + attrs = { + optional_scopes = oListStr "Names of optionally-attached scopes."; + }; + }; + + openid_client_service_account_roles = { + type = "keycloak_openid_client_service_account_role"; + prefix = "openid_client_sa_role"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed target client whose role is granted."; + }; + }; + requiredAttrs = [ + "service_account_user_id" + "role" + ]; + description = "Grant a per-client role to a service-account user, keyed by an arbitrary label."; + attrs = { + # Computed from the source client (`${keycloak_openid_client.X.service_account_user_id}`). + service_account_user_id = oStr "Service-account user id (typically `\${keycloak_openid_client.X.service_account_user_id}`)."; + role = oStr "Name of the role granted (must exist on the target client)."; + }; + }; + + openid_client_service_account_realm_roles = { + type = "keycloak_openid_client_service_account_realm_role"; + prefix = "openid_client_sa_realm_role"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ + "service_account_user_id" + "role" + ]; + description = "Grant a realm-level role to a service-account user, keyed by an arbitrary label."; + attrs = { + service_account_user_id = oStr "Service-account user id (typically `\${keycloak_openid_client.X.service_account_user_id}`)."; + role = oStr "Name of the realm-level role granted."; + }; + }; }; # generate nixos options for resources from resourceTypes From f9952f3b35c7037f20cbb8d8f271103e806b05e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 16:09:08 +0200 Subject: [PATCH 12/50] feat(services/keycloak): add saml_clients + saml_client_default_scopes - saml_clients: full SAML client surface (signing/encryption flags + algorithms, redirect URIs, logout bindings, IdP-initiated SSO, certs). signing_private_key gets File even though the provider doesn't mark it Sensitive (private-key material). - saml_client_default_scopes: per-client default-scope binding. No VM-test fixture for this commit; a SAML scenario can be added later if needed. --- services/keycloak/lib.nix | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index def194c..daa71fa 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -586,6 +586,91 @@ let role = oStr "Name of the realm-level role granted."; }; }; + + saml_clients = { + type = "keycloak_saml_client"; + prefix = "saml_client"; + nameAttr = "client_id"; + scope = null; + refs.realm = realmRef; + # signing_private_key isn't marked Sensitive by the provider but is a + # private key in practice; expose File so operators can keep it + # out of the world-readable store. + secrets = [ "signing_private_key" ]; + description = "SAML clients (per-realm), keyed by clientId."; + attrs = { + client_id = oStr "SAML clientId. Defaults to the attribute key."; + name = oStr "Display name."; + description = oStr "Client description."; + enabled = oBool "Is the client enabled?"; + + include_authn_statement = oBool "Include the AuthnStatement in assertions."; + sign_documents = oBool "Sign SAML documents."; + sign_assertions = oBool "Sign SAML assertions."; + encrypt_assertions = oBool "Encrypt assertions."; + encryption_algorithm = oStr "Assertion encryption algorithm."; + encryption_key_algorithm = oStr "Assertion encryption key algorithm."; + encryption_digest_method = oStr "Assertion encryption digest method."; + encryption_mask_generation_function = oStr "Assertion encryption MGF."; + client_signature_required = oBool "Require the client to sign requests."; + force_post_binding = oBool "Force POST binding."; + consent_required = oBool "Require consent on first use."; + front_channel_logout = oBool "Use front-channel logout."; + force_name_id_format = oBool "Force the configured name_id_format."; + signature_algorithm = oStr "SAML signature algorithm."; + signature_key_name = oStr "SAML signature key name."; + canonicalization_method = oStr "SAML canonicalization method URI."; + name_id_format = oStr "SAML NameID format."; + full_scope_allowed = oBool "Grant the full scope by default."; + + root_url = oStr "Root URL."; + valid_redirect_uris = oListStr "Valid redirect URIs."; + base_url = oStr "Base URL."; + login_theme = oStr "Per-client login theme."; + master_saml_processing_url = oStr "Master SAML processing URL."; + + encryption_certificate = oStr "Encryption certificate (PEM)."; + signing_certificate = oStr "Signing certificate (PEM)."; + signing_private_key = oStr "Signing private key (PEM). Prefer `signing_private_keyFile`."; + + idp_initiated_sso_url_name = oStr "IdP-initiated SSO URL name."; + idp_initiated_sso_relay_state = oStr "IdP-initiated SSO RelayState."; + assertion_consumer_post_url = oStr "Assertion consumer service POST URL."; + assertion_consumer_redirect_url = oStr "Assertion consumer service Redirect URL."; + logout_service_post_binding_url = oStr "SAML logout service POST binding URL."; + logout_service_redirect_binding_url = oStr "SAML logout service Redirect binding URL."; + + always_display_in_console = oBool "Always display the client in the user account console."; + extra_config = oAttrsStr "Free-form extra config entries."; + }; + }; + + saml_client_default_scopes = { + type = "keycloak_saml_client_default_scopes"; + prefix = "saml_client_default_scopes"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = { + attr = "client_id"; + targets = [ + { + collection = "saml_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed SAML client (services.keycloak.runtime.saml_clients.) the scope binding applies to."; + }; + }; + requiredAttrs = [ "default_scopes" ]; + description = "Default SAML scopes auto-attached to a SAML client, keyed by an arbitrary label."; + attrs = { + default_scopes = oListStr "Names of SAML scopes attached by default."; + }; + }; }; # generate nixos options for resources from resourceTypes From b435f7a798236c1e9d4b36accc2be240fc8cfb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 16:14:08 +0200 Subject: [PATCH 13/50] feat(services/keycloak): add 13 openid protocol mappers Adds every keycloak_openid_*_protocol_mapper: - user_attribute, user_property, group_membership, full_name - sub, hardcoded_claim, audience, audience_resolve, hardcoded_role - user_realm_role, user_client_role, user_session_note, script All share realm + (client | client_scope) refs; common add_to_{id_token,access_token,userinfo} attrs are factored into a shared helper. VM test attaches a user_attribute mapper to the acme-profile client scope and asserts via the admin API that the mapper exists with the declared user.attribute / claim.name config. --- services/keycloak/checks.nix | 29 ++++ services/keycloak/lib.nix | 298 +++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 680cbd3..ab3c728 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -145,6 +145,19 @@ in "acme-profile" ]; }; + + # OpenID protocol mapper attached to the acme-profile scope. + openid_user_attribute_protocol_mappers.team_claim = { + realm = "acme"; + client_scope = "acme_profile"; + name = "team"; + user_attribute = "team"; + claim_name = "team"; + claim_value_type = "String"; + add_to_id_token = true; + add_to_access_token = true; + add_to_userinfo = true; + }; }; }; @@ -239,6 +252,22 @@ in assert any(g["name"] == "engineering" for g in alice_groups), \ f"engineering group missing on alice: {alice_groups}" + with subtest("protocol mapper attached to client scope"): + tok = admin_token() + scopes = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/client-scopes" + )) + s = next((x for x in scopes if x["name"] == "acme-profile"), None) + mappers = s.get("protocolMappers", []) if s else [] + mapper = next((m for m in mappers if m["name"] == "team"), None) + assert mapper, f"team mapper missing on acme-profile: {mappers}" + assert mapper.get("protocolMapper") == "oidc-usermodel-attribute-mapper", \ + f"mapper type mismatch: {mapper}" + cfg = mapper.get("config", {}) + assert cfg.get("user.attribute") == "team", f"mapper config: {cfg}" + assert cfg.get("claim.name") == "team", f"mapper config: {cfg}" + with subtest("openid client exists with declared scopes attached"): tok = admin_token() clients = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index daa71fa..2922cab 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -80,6 +80,40 @@ let description = "Key of the managed realm (services.keycloak.runtime.realms.) this belongs to."; }; + # optional refs to a managed openid_client / openid_client_scope, used by + # all openid protocol mappers (mutually exclusive at the provider). + openidClientOptionalRef = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = false; + description = "Optional managed OpenID client this mapper attaches to."; + }; + openidClientScopeOptionalRef = { + attr = "client_scope_id"; + targets = [ + { + collection = "openid_client_scopes"; + field = "id"; + } + ]; + managedOnly = true; + required = false; + description = "Optional managed OpenID client scope this mapper attaches to."; + }; + # the four common openid-mapper attrs (every mapper has at least the first 3) + openidMapperCommonAttrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + add_to_id_token = oBool "Include in ID token?"; + add_to_access_token = oBool "Include in access token?"; + add_to_userinfo = oBool "Include in UserInfo?"; + }; + # The full keycloak/keycloak resource surface. Per # resource: # type the `keycloak_*` resource type @@ -671,6 +705,270 @@ let default_scopes = oListStr "Names of SAML scopes attached by default."; }; }; + + # OpenID protocol mappers: each is its own resource type, keyed by the + # mapper name; all share the same realm + (client | client_scope) refs. + openid_user_attribute_protocol_mappers = { + type = "keycloak_openid_user_attribute_protocol_mapper"; + prefix = "openid_user_attribute_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ + "user_attribute" + "claim_name" + ]; + description = "OpenID protocol mapper that maps a user attribute to a claim."; + attrs = openidMapperCommonAttrs // { + multivalued = oBool "Treat the attribute as multivalued?"; + user_attribute = oStr "Name of the user attribute to map."; + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type ('String', 'long', 'int', 'boolean', 'JSON')."; + aggregate_attributes = oBool "Aggregate multiple values into one claim?"; + }; + }; + + openid_user_property_protocol_mappers = { + type = "keycloak_openid_user_property_protocol_mapper"; + prefix = "openid_user_property_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ + "user_property" + "claim_name" + ]; + description = "OpenID protocol mapper that maps a built-in user property (e.g. `email`, `username`) to a claim."; + attrs = openidMapperCommonAttrs // { + user_property = oStr "Built-in user property to map (e.g. 'email', 'username')."; + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type."; + }; + }; + + openid_group_membership_protocol_mappers = { + type = "keycloak_openid_group_membership_protocol_mapper"; + prefix = "openid_group_membership_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ "claim_name" ]; + description = "OpenID protocol mapper that maps group memberships to a claim."; + attrs = openidMapperCommonAttrs // { + claim_name = oStr "Name of the resulting JWT claim."; + full_path = oBool "Emit full group path (/parent/child) rather than just the leaf name?"; + }; + }; + + openid_full_name_protocol_mappers = { + type = "keycloak_openid_full_name_protocol_mapper"; + prefix = "openid_full_name_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + description = "OpenID protocol mapper that emits the user's full name as a single claim."; + attrs = openidMapperCommonAttrs; + }; + + openid_sub_protocol_mappers = { + type = "keycloak_openid_sub_protocol_mapper"; + prefix = "openid_sub_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + description = "OpenID protocol mapper for the `sub` claim."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + add_to_access_token = oBool "Include in access token?"; + add_to_token_introspection = oBool "Include in token introspection?"; + }; + }; + + openid_hardcoded_claim_protocol_mappers = { + type = "keycloak_openid_hardcoded_claim_protocol_mapper"; + prefix = "openid_hardcoded_claim_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ + "claim_name" + "claim_value" + ]; + description = "OpenID protocol mapper that adds a hardcoded claim with a fixed value."; + attrs = openidMapperCommonAttrs // { + claim_name = oStr "Name of the resulting JWT claim."; + claim_value = oStr "Hardcoded claim value."; + claim_value_type = oStr "Claim value type."; + }; + }; + + openid_audience_protocol_mappers = { + type = "keycloak_openid_audience_protocol_mapper"; + prefix = "openid_audience_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + description = "OpenID protocol mapper that adds an audience to issued tokens (exactly one of `included_client_audience` / `included_custom_audience`)."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + included_client_audience = oStr "ClientId of a client to include as audience."; + included_custom_audience = oStr "Custom audience string to include."; + add_to_id_token = oBool "Include in ID token?"; + add_to_access_token = oBool "Include in access token?"; + }; + }; + + openid_audience_resolve_protocol_mappers = { + type = "keycloak_openid_audience_resolve_protocol_mapper"; + prefix = "openid_audience_resolve_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + description = "OpenID audience-resolve mapper (derives audience from client roles)."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + }; + }; + + openid_hardcoded_role_protocol_mappers = { + type = "keycloak_openid_hardcoded_role_protocol_mapper"; + prefix = "openid_hardcoded_role_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ "role_id" ]; + description = "OpenID protocol mapper that adds a hardcoded role to issued tokens."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + role_id = oStr "Role UUID (or `\${keycloak_role.X.id}` reference) to hardcode."; + }; + }; + + openid_user_realm_role_protocol_mappers = { + type = "keycloak_openid_user_realm_role_protocol_mapper"; + prefix = "openid_user_realm_role_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ "claim_name" ]; + description = "OpenID protocol mapper that maps the user's realm roles to a claim."; + attrs = openidMapperCommonAttrs // { + add_to_token_introspection = oBool "Include in token introspection?"; + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type."; + multivalued = oBool "Treat as multivalued?"; + realm_role_prefix = oStr "Optional prefix prepended to each role name in the claim."; + }; + }; + + openid_user_client_role_protocol_mappers = { + type = "keycloak_openid_user_client_role_protocol_mapper"; + prefix = "openid_user_client_role_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ "claim_name" ]; + description = "OpenID protocol mapper that maps the user's roles on a specific client to a claim."; + attrs = openidMapperCommonAttrs // { + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type."; + multivalued = oBool "Treat as multivalued?"; + client_id_for_role_mappings = oStr "Source clientId whose role mappings are emitted."; + client_role_prefix = oStr "Optional prefix prepended to each role name in the claim."; + }; + }; + + openid_user_session_note_protocol_mappers = { + type = "keycloak_openid_user_session_note_protocol_mapper"; + prefix = "openid_user_session_note_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ + "claim_name" + "session_note" + ]; + description = "OpenID protocol mapper that maps a user session note to a claim."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + add_to_id_token = oBool "Include in ID token?"; + add_to_access_token = oBool "Include in access token?"; + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type."; + session_note = oStr "Name of the user session note to read."; + }; + }; + + openid_script_protocol_mappers = { + type = "keycloak_openid_script_protocol_mapper"; + prefix = "openid_script_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + requiredAttrs = [ + "script" + "claim_name" + ]; + description = "OpenID protocol mapper that produces a claim from a JavaScript expression (requires the scripts feature)."; + attrs = openidMapperCommonAttrs // { + multivalued = oBool "Treat as multivalued?"; + script = oStr "JavaScript expression evaluated to produce the claim value."; + claim_name = oStr "Name of the resulting JWT claim."; + claim_value_type = oStr "Claim value type."; + }; + }; }; # generate nixos options for resources from resourceTypes From 5bc99eaf7c38f41d82a0cb94dee9c09fd9d93cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 16:16:21 +0200 Subject: [PATCH 14/50] feat(services/keycloak): add saml + generic protocol mappers - saml_user_attribute / user_property / script: SAML counterparts of the openid mappers, with friendly_name + saml_attribute_name(_format). - generic_protocol_mapper / generic_client_protocol_mapper: escape hatches taking protocol + protocol_mapper id + free-form config. - generic_role_mapper / generic_client_role_mapper: role-scope mappers (multi-target ref accepts openid or saml siblings). Shared anyClient* / samlClient* ref helpers mirror the openidClient* helpers from the previous commit. No VM-test fixture (no SAML clients/scopes declared); eval check covers schema correctness. --- services/keycloak/lib.nix | 218 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 2922cab..6633aef 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -114,6 +114,68 @@ let add_to_userinfo = oBool "Include in UserInfo?"; }; + # SAML mapper attachment refs (analogous to the openid pair above). + samlClientOptionalRef = { + attr = "client_id"; + targets = [ + { + collection = "saml_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = false; + description = "Optional managed SAML client this mapper attaches to."; + }; + samlClientScopeOptionalRef = { + attr = "client_scope_id"; + targets = [ + { + collection = "saml_client_scopes"; + field = "id"; + } + ]; + managedOnly = true; + required = false; + description = "Optional managed SAML client scope this mapper attaches to."; + }; + + # generic mappers / role mappers attach to either an openid or a SAML + # client/scope; multi-target so a managed key from either collection + # resolves, and a literal id string falls through. + anyClientOptionalRef = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + { + collection = "saml_clients"; + field = "id"; + } + ]; + managedOnly = false; + required = false; + description = "Optional managed client (openid or saml) this mapper attaches to."; + }; + anyClientScopeOptionalRef = { + attr = "client_scope_id"; + targets = [ + { + collection = "openid_client_scopes"; + field = "id"; + } + { + collection = "saml_client_scopes"; + field = "id"; + } + ]; + managedOnly = false; + required = false; + description = "Optional managed client scope (openid or saml) this mapper attaches to."; + }; + # The full keycloak/keycloak resource surface. Per # resource: # type the `keycloak_*` resource type @@ -969,6 +1031,162 @@ let claim_value_type = oStr "Claim value type."; }; }; + + saml_user_attribute_protocol_mappers = { + type = "keycloak_saml_user_attribute_protocol_mapper"; + prefix = "saml_user_attribute_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = samlClientOptionalRef; + client_scope = samlClientScopeOptionalRef; + }; + requiredAttrs = [ + "user_attribute" + "saml_attribute_name" + ]; + description = "SAML mapper that exposes a user attribute as a SAML attribute."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + user_attribute = oStr "Source user attribute."; + friendly_name = oStr "Optional SAML friendlyName."; + saml_attribute_name = oStr "SAML attribute name."; + saml_attribute_name_format = oStr "SAML attribute name format ('Basic', 'URI Reference', 'Unspecified')."; + aggregate_attributes = oBool "Aggregate multivalued attributes into one SAML attribute?"; + }; + }; + + saml_user_property_protocol_mappers = { + type = "keycloak_saml_user_property_protocol_mapper"; + prefix = "saml_user_property_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = samlClientOptionalRef; + client_scope = samlClientScopeOptionalRef; + }; + requiredAttrs = [ + "user_property" + "saml_attribute_name" + ]; + description = "SAML mapper that exposes a built-in user property as a SAML attribute."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + user_property = oStr "Built-in user property (e.g. 'email', 'username')."; + friendly_name = oStr "Optional SAML friendlyName."; + saml_attribute_name = oStr "SAML attribute name."; + saml_attribute_name_format = oStr "SAML attribute name format."; + }; + }; + + saml_script_protocol_mappers = { + type = "keycloak_saml_script_protocol_mapper"; + prefix = "saml_script_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = samlClientOptionalRef; + client_scope = samlClientScopeOptionalRef; + }; + requiredAttrs = [ + "script" + "saml_attribute_name" + ]; + description = "SAML mapper that produces a SAML attribute from a JavaScript expression."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + single_value_attribute = oBool "Emit as a single-value attribute?"; + script = oStr "JavaScript expression evaluated to produce the SAML attribute value."; + friendly_name = oStr "Optional SAML friendlyName."; + saml_attribute_name = oStr "SAML attribute name."; + saml_attribute_name_format = oStr "SAML attribute name format."; + }; + }; + + generic_protocol_mappers = { + type = "keycloak_generic_protocol_mapper"; + prefix = "generic_protocol_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = anyClientOptionalRef; + client_scope = anyClientScopeOptionalRef; + }; + requiredAttrs = [ + "protocol" + "protocol_mapper" + "config" + ]; + description = "Generic protocol mapper escape hatch (for mappers without a dedicated typed resource)."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + protocol = oStr "Protocol ('openid-connect' or 'saml')."; + protocol_mapper = oStr "Provider-id of the mapper implementation (e.g. 'oidc-usermodel-attribute-mapper')."; + config = oAttrsStr "Mapper configuration (provider-specific key/value pairs)."; + }; + }; + + generic_client_protocol_mappers = { + type = "keycloak_generic_client_protocol_mapper"; + prefix = "generic_client_protocol_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = anyClientOptionalRef; + client_scope = anyClientScopeOptionalRef; + }; + requiredAttrs = [ + "protocol" + "protocol_mapper" + "config" + ]; + description = "Generic protocol mapper attached to a specific client (without a dedicated typed resource)."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + protocol = oStr "Protocol ('openid-connect' or 'saml')."; + protocol_mapper = oStr "Provider-id of the mapper implementation."; + config = oAttrsStr "Mapper configuration (provider-specific key/value pairs)."; + }; + }; + + generic_role_mappers = { + type = "keycloak_generic_role_mapper"; + prefix = "generic_role_mapper"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = anyClientOptionalRef; + client_scope = anyClientScopeOptionalRef; + }; + requiredAttrs = [ "role_id" ]; + description = "Generic role-scope mapper that attaches a role to a client / client scope, keyed by an arbitrary label."; + attrs = { + role_id = oStr "Role UUID (or `\${keycloak_role.X.id}` reference) to attach."; + }; + }; + + generic_client_role_mappers = { + type = "keycloak_generic_client_role_mapper"; + prefix = "generic_client_role_mapper"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = anyClientOptionalRef; + client_scope = anyClientScopeOptionalRef; + }; + requiredAttrs = [ "role_id" ]; + description = "Generic role-scope mapper attached to a specific client (deprecated alias kept for completeness)."; + attrs = { + role_id = oStr "Role UUID (or `\${keycloak_role.X.id}` reference) to attach."; + }; + }; }; # generate nixos options for resources from resourceTypes From bc951fa0225304d304197abb09311a889705c496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:23:45 +0200 Subject: [PATCH 15/50] feat(services/keycloak): add 6 identity providers - oidc_identity_providers: generic OIDC IdP with the full URL surface (authorization / token / userinfo / jwks / logout) and a Sensitive client_secret with File support. - saml_identity_providers: SAML IdP with single_sign_on_service_url, entity_id, signing_certificate, principal_type, authn_context_*, post-binding flags. - oidc_google / facebook / github / kubernetes: thin OIDC IdP specialisations with a Required+Sensitive client_secret (and GitHub Enterprise base/api URL overrides). Shared helpers near the top of lib.nix: - realmAliasRef: IdPs reference the realm by alias, not id. - commonIdpAttrs: the 17 attrs every IdP carries. VM test declares an acme_google IdP whose client_secret is supplied via /etc/acme-google-secret, then asserts via the admin API that providerId / alias are right and the literal secret never reaches main.tf.json. --- services/keycloak/checks.nix | 26 +++++ services/keycloak/lib.nix | 188 +++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index ab3c728..2df247e 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -23,6 +23,7 @@ in # mock agenix secrets: the module expects passwords to be supplied as files environment.etc."keycloak-db-password".text = "hackme"; environment.etc."keycloak-admin-password".text = keycloakAdminPassword; + environment.etc."acme-google-secret".text = "fakesecret"; services.keycloak = { enable = true; @@ -158,6 +159,14 @@ in add_to_access_token = true; add_to_userinfo = true; }; + + # google IdP exercises the realmAliasRef ref-by-alias path and + # secret-file indirection on the new client_secret field. + oidc_google_identity_providers.acme_google = { + realm = "acme"; + client_id = "fake-client-id"; + client_secretFile = "/etc/acme-google-secret"; + }; }; }; @@ -252,6 +261,23 @@ in assert any(g["name"] == "engineering" for g in alice_groups), \ f"engineering group missing on alice: {alice_groups}" + with subtest("google IdP exists and client_secret stays out of .tf.json"): + tok = admin_token() + # alias defaults to the collection key (`acme_google`) via + # nameAttr; the provider then sets providerId="google". + idp = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/identity-provider/instances/acme_google" + )) + assert idp.get("providerId") == "google", f"google IdP: {idp}" + assert idp.get("alias") == "acme_google", f"google IdP: {idp}" + # operator-supplied secret loaded via LoadCredential must not + # leak into the generated config. + tfjson = machine.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + with subtest("protocol mapper attached to client scope"): tok = admin_token() scopes = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 6633aef..1c83bb3 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -140,6 +140,42 @@ let description = "Optional managed SAML client scope this mapper attaches to."; }; + # identity providers reference the realm by its alias (name), not by id; + # `realm = ""` is how the provider wires them. + realmAliasRef = { + attr = "realm"; + targets = [ + { + collection = "realms"; + field = "realm"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed realm (services.keycloak.runtime.realms.) the IdP lives in."; + }; + + # shared attrs every keycloak identity provider exposes (alias is the IdP + # key, display_name is human-readable, enabled toggles, etc.). + commonIdpAttrs = { + alias = oStr "Provider alias. Defaults to the attribute key."; + display_name = oStr "Human-readable name shown on the login page."; + enabled = oBool "Is the identity provider enabled?"; + store_token = oBool "Persist tokens obtained from the IdP."; + add_read_token_role_on_create = oBool "Grant the read-token role to newly federated users."; + authenticate_by_default = oBool "Use this IdP as the default authenticator."; + link_only = oBool "Don't allow new login -- only link existing accounts."; + trust_email = oBool "Trust the email returned by the IdP (skip verification)."; + first_broker_login_flow_alias = oStr "Alias of the first-broker-login flow used."; + post_broker_login_flow_alias = oStr "Alias of the post-broker-login flow used."; + organization_id = oStr "Optional organization id this IdP belongs to."; + extra_config = oAttrsStr "Free-form extra IdP config entries."; + gui_order = oStr "Display order in the admin UI (string)."; + sync_mode = oStr "Sync mode: 'IMPORT', 'LEGACY', or 'FORCE'."; + org_redirect_mode_email_matches = oBool "Redirect users whose email matches an organization's domain to this IdP."; + org_domain = oStr "Organization domain matched against the user's email."; + }; + # generic mappers / role mappers attach to either an openid or a SAML # client/scope; multi-target so a managed key from either collection # resolves, and a literal id string falls through. @@ -1187,6 +1223,158 @@ let role_id = oStr "Role UUID (or `\${keycloak_role.X.id}` reference) to attach."; }; }; + + oidc_identity_providers = { + type = "keycloak_oidc_identity_provider"; + prefix = "oidc_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + secrets = [ "client_secret" ]; + requiredAttrs = [ + "authorization_url" + "client_id" + "token_url" + ]; + description = "Generic OIDC identity providers (per-realm), keyed by alias."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'oidc')."; + backchannel_supported = oBool "Does the IdP support back-channel logout?"; + validate_signature = oBool "Validate the IdP's token signature."; + authorization_url = oStr "OIDC authorization endpoint."; + client_id = oStr "OIDC client id."; + client_secret = oStr "OIDC client secret. Prefer `client_secretFile`."; + user_info_url = oStr "OIDC userinfo endpoint."; + jwks_url = oStr "OIDC JWKS endpoint."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + token_url = oStr "OIDC token endpoint."; + logout_url = oStr "OIDC logout endpoint."; + login_hint = oBool "Pass `login_hint` query parameter to the IdP."; + ui_locales = oBool "Pass `ui_locales` query parameter to the IdP."; + default_scopes = oStr "Space-separated default scopes to request."; + accepts_prompt_none_forward_from_client = oBool "Forward `prompt=none` requests to this IdP."; + disable_user_info = oBool "Don't call the userinfo endpoint."; + issuer = oStr "Expected `iss` claim value."; + disable_type_claim_check = oBool "Skip the typ-claim check on returned tokens."; + }; + }; + + saml_identity_providers = { + type = "keycloak_saml_identity_provider"; + prefix = "saml_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + requiredAttrs = [ + "entity_id" + "single_sign_on_service_url" + ]; + description = "SAML identity providers (per-realm), keyed by alias."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'saml')."; + backchannel_supported = oBool "Does the IdP support back-channel logout?"; + validate_signature = oBool "Validate SAML signatures."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + name_id_policy_format = oStr "Default name_id_policy_format URN."; + single_logout_service_url = oStr "SAML SLO endpoint URL."; + entity_id = oStr "Entity ID expected from the IdP."; + single_sign_on_service_url = oStr "SAML SSO endpoint URL."; + signing_certificate = oStr "IdP signing certificate (PEM)."; + signature_algorithm = oStr "Signature algorithm."; + xml_sign_key_info_key_name_transformer = oStr "KeyInfo KeyName transformer."; + post_binding_authn_request = oBool "Use POST binding for AuthnRequests."; + post_binding_response = oBool "Use POST binding for Responses."; + post_binding_logout = oBool "Use POST binding for Logout."; + force_authn = oBool "Force re-authentication on every login."; + login_hint = oBool "Pass login_hint to the IdP."; + want_assertions_signed = oBool "Require signed assertions."; + want_assertions_encrypted = oBool "Require encrypted assertions."; + want_authn_requests_signed = oBool "Require signed AuthnRequests."; + principal_type = oStr "How to derive the user principal ('SUBJECT', 'ATTRIBUTE', 'FRIENDLY_ATTRIBUTE')."; + principal_attribute = oStr "Attribute name when principal_type is ATTRIBUTE or FRIENDLY_ATTRIBUTE."; + authn_context_class_refs = oListStr "AuthnContext class refs requested in AuthnRequests."; + authn_context_decl_refs = oListStr "AuthnContext declaration refs requested in AuthnRequests."; + authn_context_comparison_type = oStr "AuthnContext comparison type ('exact', 'minimum', 'maximum', 'better')."; + }; + }; + + oidc_google_identity_providers = { + type = "keycloak_oidc_google_identity_provider"; + prefix = "oidc_google_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + secrets = [ "client_secret" ]; + requiredSecrets = [ "client_secret" ]; + requiredAttrs = [ "client_id" ]; + description = "Google OIDC identity providers (per-realm), keyed by alias (defaults to 'google')."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'google')."; + client_id = oStr "Google OAuth2 client id."; + client_secret = oStr "Google OAuth2 client secret. Prefer `client_secretFile`."; + hosted_domain = oStr "Restrict to a Google Workspace hosted domain (or `*`)."; + use_user_ip_param = oBool "Forward the user's IP to Google's UserInfo service."; + request_refresh_token = oBool "Request a refresh token (`access_type=offline`)."; + default_scopes = oStr "Space-separated default scopes (default 'openid profile email')."; + accepts_prompt_none_forward_from_client = oBool "Forward `prompt=none` requests."; + disable_user_info = oBool "Don't call the UserInfo service."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + }; + }; + + oidc_facebook_identity_providers = { + type = "keycloak_oidc_facebook_identity_provider"; + prefix = "oidc_facebook_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + secrets = [ "client_secret" ]; + requiredSecrets = [ "client_secret" ]; + requiredAttrs = [ "client_id" ]; + description = "Facebook OIDC identity providers (per-realm), keyed by alias (defaults to 'facebook')."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'facebook')."; + client_id = oStr "Facebook app id."; + client_secret = oStr "Facebook app secret. Prefer `client_secretFile`."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + }; + }; + + oidc_github_identity_providers = { + type = "keycloak_oidc_github_identity_provider"; + prefix = "oidc_github_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + secrets = [ "client_secret" ]; + requiredSecrets = [ "client_secret" ]; + requiredAttrs = [ "client_id" ]; + description = "GitHub OIDC identity providers (per-realm), keyed by alias (defaults to 'github')."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'github')."; + client_id = oStr "GitHub OAuth app client id."; + client_secret = oStr "GitHub OAuth app client secret. Prefer `client_secretFile`."; + base_url = oStr "Override the GitHub Enterprise base URL."; + api_url = oStr "Override the GitHub Enterprise API URL."; + github_json_format = oBool "Use GitHub's JSON content type."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + }; + }; + + kubernetes_identity_providers = { + type = "keycloak_kubernetes_identity_provider"; + prefix = "kubernetes_idp"; + nameAttr = "alias"; + scope = null; + refs.realm = realmAliasRef; + requiredAttrs = [ "issuer" ]; + description = "Kubernetes OIDC identity providers (per-realm), keyed by alias."; + attrs = commonIdpAttrs // { + provider_id = oStr "Provider id (defaults to 'kubernetes')."; + issuer = oStr "Kubernetes API server issuer URL."; + hide_on_login_page = oBool "Hide this IdP on the login page."; + }; + }; }; # generate nixos options for resources from resourceTypes From ba750939ccdab5614c6c2bc7a74be597de54ad39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:30:37 +0200 Subject: [PATCH 16/50] feat(services/keycloak): add 7 identity provider mappers - hardcoded_attribute / group / role: set a fixed attribute, group or role on every federated user. - attribute_importer: import a SAML attribute or OIDC claim onto the user. - attribute_to_role: grant a role when an attribute / claim matches. - user_template_importer: derive the username from a Mustache-style template over IdP claims. - custom: escape hatch for mapper implementations without a typed resource (provider-id + free-form config). Shared helpers: - idpAliasRequiredRef: multi-target alias ref across all six IdP collections, falling back to a literal alias. - commonIdpMapperAttrs: name + extra_config carried by every mapper. VM test attaches an attribute_importer mapper to the google IdP and asserts via the admin API that the mapper exists with the declared config. --- services/keycloak/checks.nix | 23 +++++ services/keycloak/lib.nix | 160 +++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 2df247e..19c5695 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -167,6 +167,15 @@ in client_id = "fake-client-id"; client_secretFile = "/etc/acme-google-secret"; }; + + # IdP mapper exercises idpAliasRequiredRef across IdP collections. + attribute_importer_identity_provider_mappers.google_email = { + realm = "acme"; + identity_provider = "acme_google"; + name = "google-email"; + user_attribute = "email"; + claim_name = "email"; + }; }; }; @@ -278,6 +287,20 @@ in ) assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + with subtest("IdP mapper attached to google via managed alias ref"): + tok = admin_token() + mappers = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/identity-provider/instances/acme_google/mappers" + )) + m = next((x for x in mappers if x["name"] == "google-email"), None) + assert m, f"google-email mapper missing: {mappers}" + # provider picks the right mapper type for the IdP variant + # (here `google-user-attribute-mapper`); just assert config reached it. + assert m.get("identityProviderAlias") == "acme_google", f"mapper: {m}" + cfg = m.get("config", {}) + assert cfg.get("user.attribute") == "email", f"mapper config: {cfg}" + with subtest("protocol mapper attached to client scope"): tok = admin_token() scopes = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 1c83bb3..b02ad60 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -155,6 +155,47 @@ let description = "Key of the managed realm (services.keycloak.runtime.realms.) the IdP lives in."; }; + # identity provider mappers reference an IdP by alias; the alias may + # belong to any of the six IdP variants we model. + idpAliasRequiredRef = { + attr = "identity_provider_alias"; + targets = [ + { + collection = "oidc_identity_providers"; + field = "alias"; + } + { + collection = "saml_identity_providers"; + field = "alias"; + } + { + collection = "oidc_google_identity_providers"; + field = "alias"; + } + { + collection = "oidc_facebook_identity_providers"; + field = "alias"; + } + { + collection = "oidc_github_identity_providers"; + field = "alias"; + } + { + collection = "kubernetes_identity_providers"; + field = "alias"; + } + ]; + managedOnly = false; + required = true; + description = "Alias of the managed identity provider (in any IdP collection) this mapper attaches to, or a literal alias."; + }; + + # common attrs every IdP mapper carries. + commonIdpMapperAttrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + extra_config = oAttrsStr "Free-form extra mapper config entries."; + }; + # shared attrs every keycloak identity provider exposes (alias is the IdP # key, display_name is human-readable, enabled toggles, etc.). commonIdpAttrs = { @@ -1375,6 +1416,125 @@ let hide_on_login_page = oBool "Hide this IdP on the login page."; }; }; + + hardcoded_attribute_identity_provider_mappers = { + type = "keycloak_hardcoded_attribute_identity_provider_mapper"; + prefix = "hardcoded_attribute_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + requiredAttrs = [ "user_session" ]; + description = "Sets a hardcoded user (or session-note) attribute on every federated user."; + attrs = commonIdpMapperAttrs // { + attribute_name = oStr "Name of the attribute / session note to set."; + attribute_value = oStr "Value of the attribute / session note."; + user_session = oBool "If true, set as a session note; if false, as a user attribute."; + }; + }; + + hardcoded_group_identity_provider_mappers = { + type = "keycloak_hardcoded_group_identity_provider_mapper"; + prefix = "hardcoded_group_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + description = "Adds every federated user to a hardcoded group."; + attrs = commonIdpMapperAttrs // { + group = oStr "Group path (e.g. `/engineering/backend`) every federated user joins."; + }; + }; + + hardcoded_role_identity_provider_mappers = { + type = "keycloak_hardcoded_role_identity_provider_mapper"; + prefix = "hardcoded_role_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + description = "Grants a hardcoded role to every federated user."; + attrs = commonIdpMapperAttrs // { + role = oStr "Realm or `client.role` name granted to every federated user."; + }; + }; + + attribute_importer_identity_provider_mappers = { + type = "keycloak_attribute_importer_identity_provider_mapper"; + prefix = "attribute_importer_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + requiredAttrs = [ "user_attribute" ]; + description = "Imports an attribute / claim from the IdP onto the federated user."; + attrs = commonIdpMapperAttrs // { + user_attribute = oStr "Destination user attribute on the keycloak side."; + attribute_name = oStr "Source SAML attribute name (SAML IdPs; conflicts with attribute_friendly_name)."; + attribute_friendly_name = oStr "Source SAML attribute friendly name (SAML IdPs; conflicts with attribute_name)."; + claim_name = oStr "Source OIDC claim name (OIDC IdPs)."; + }; + }; + + attribute_to_role_identity_provider_mappers = { + type = "keycloak_attribute_to_role_identity_provider_mapper"; + prefix = "attribute_to_role_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + requiredAttrs = [ "role" ]; + description = "Grants a role to federated users whose IdP attribute / claim matches a value."; + attrs = commonIdpMapperAttrs // { + attribute_name = oStr "SAML attribute name to match (conflicts with attribute_friendly_name)."; + attribute_value = oStr "Value the SAML attribute must equal."; + attribute_friendly_name = oStr "SAML friendly name to match (conflicts with attribute_name)."; + claim_name = oStr "OIDC claim name to match."; + claim_value = oStr "Value the OIDC claim must equal."; + role = oStr "Realm or `client.role` name granted on match."; + }; + }; + + user_template_importer_identity_provider_mappers = { + type = "keycloak_user_template_importer_identity_provider_mapper"; + prefix = "user_template_importer_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + description = "Derives the federated user's username from a Mustache-style template over IdP claims."; + attrs = commonIdpMapperAttrs // { + template = oStr "Username template (e.g. `\${CLAIM.preferred_username}@example`)."; + }; + }; + + custom_identity_provider_mappers = { + type = "keycloak_custom_identity_provider_mapper"; + prefix = "custom_idp_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmAliasRef; + identity_provider = idpAliasRequiredRef; + }; + requiredAttrs = [ "identity_provider_mapper" ]; + description = "Escape hatch for an IdP mapper implementation without a dedicated typed resource."; + attrs = commonIdpMapperAttrs // { + identity_provider_mapper = oStr "Provider-id of the mapper implementation."; + }; + }; }; # generate nixos options for resources from resourceTypes From 3851cf06bd9b979e2dcf157d76c250b7c1002352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:34:12 +0200 Subject: [PATCH 17/50] feat(services/keycloak): add 5 authentication primitives - authentication_flows: top-level per-realm flows. - authentication_subflows: nested under a parent flow / subflow (parent_flow_alias multi-targets both collections + falls back to a literal alias for built-ins like "browser"). - authentication_executions: leaf authenticator steps inside a flow. - authentication_execution_configs: keyed config map attached to a managed execution by id. - authentication_bindings: realm-level overrides for the seven standard flow bindings. VM test declares one authentication_flow and asserts via the admin API that it appears in /admin/realms//authentication/flows with the declared description. --- services/keycloak/checks.nix | 17 +++++ services/keycloak/lib.nix | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 19c5695..6265d3f 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -176,6 +176,13 @@ in user_attribute = "email"; claim_name = "email"; }; + + # Top-level authentication flow. + authentication_flows.acme_passkey = { + realm = "acme"; + alias = "acme-passkey"; + description = "Passkey login flow"; + }; }; }; @@ -287,6 +294,16 @@ in ) assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + with subtest("authentication flow exists"): + tok = admin_token() + flows = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/authentication/flows" + )) + flow = next((f for f in flows if f.get("alias") == "acme-passkey"), None) + assert flow, f"acme-passkey flow missing: {[f.get('alias') for f in flows]}" + assert flow.get("description") == "Passkey login flow", f"flow: {flow}" + with subtest("IdP mapper attached to google via managed alias ref"): tok = admin_token() mappers = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index b02ad60..e6d94e6 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -1535,6 +1535,134 @@ let identity_provider_mapper = oStr "Provider-id of the mapper implementation."; }; }; + + authentication_flows = { + type = "keycloak_authentication_flow"; + prefix = "authentication_flow"; + nameAttr = "alias"; + scope = null; + refs.realm = realmRef; + description = "Top-level authentication flows (per-realm), keyed by alias."; + attrs = { + alias = oStr "Flow alias. Defaults to the attribute key."; + provider_id = oStr "Flow implementation: 'basic-flow' (default) or 'client-flow'."; + description = oStr "Flow description."; + }; + }; + + authentication_subflows = { + type = "keycloak_authentication_subflow"; + prefix = "authentication_subflow"; + nameAttr = "alias"; + scope = null; + refs = { + realm = realmRef; + parent_flow = { + attr = "parent_flow_alias"; + targets = [ + { + collection = "authentication_flows"; + field = "alias"; + } + { + collection = "authentication_subflows"; + field = "alias"; + } + ]; + managedOnly = false; + required = true; + description = "Alias of the parent flow (managed key or literal alias)."; + }; + }; + description = "Authentication subflows nested under a parent flow, keyed by alias."; + attrs = { + alias = oStr "Subflow alias. Defaults to the attribute key."; + provider_id = oStr "Subflow implementation: 'basic-flow' (default), 'form-flow', or 'client-flow'."; + description = oStr "Subflow description."; + authenticator = oStr "Authenticator id (for form / conditional subflows)."; + requirement = oStr "Execution requirement ('REQUIRED', 'ALTERNATIVE', 'OPTIONAL', 'CONDITIONAL', 'DISABLED')."; + priority = oInt "Display / evaluation order within the parent flow."; + }; + }; + + authentication_executions = { + type = "keycloak_authentication_execution"; + prefix = "authentication_execution"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + parent_flow = { + attr = "parent_flow_alias"; + targets = [ + { + collection = "authentication_flows"; + field = "alias"; + } + { + collection = "authentication_subflows"; + field = "alias"; + } + ]; + managedOnly = false; + required = true; + description = "Alias of the parent flow / subflow (managed key or literal alias)."; + }; + }; + requiredAttrs = [ "authenticator" ]; + description = "Authentication executions inside a flow / subflow, keyed by an arbitrary label."; + attrs = { + authenticator = oStr "Authenticator provider id (e.g. 'auth-username-password-form')."; + requirement = oStr "Execution requirement ('REQUIRED', 'ALTERNATIVE', 'OPTIONAL', 'CONDITIONAL', 'DISABLED')."; + priority = oInt "Display / evaluation order within the parent flow."; + }; + }; + + authentication_execution_configs = { + type = "keycloak_authentication_execution_config"; + prefix = "authentication_execution_config"; + nameAttr = "alias"; + scope = null; + refs = { + realm = realmRef; + execution = { + attr = "execution_id"; + targets = [ + { + collection = "authentication_executions"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed authentication_execution (services.keycloak.runtime.authentication_executions.) this config attaches to."; + }; + }; + requiredAttrs = [ "config" ]; + description = "Per-execution configuration map, keyed by config alias."; + attrs = { + alias = oStr "Config alias. Defaults to the attribute key."; + config = oAttrsStr "Execution config key/value pairs."; + }; + }; + + authentication_bindings = { + type = "keycloak_authentication_bindings"; + prefix = "authentication_bindings"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + description = "Realm-level authentication flow bindings (browser / registration / direct grant / etc.), keyed by an arbitrary label."; + attrs = { + browser_flow = oStr "Alias of the flow bound to the browser flow."; + registration_flow = oStr "Alias of the flow bound to the registration flow."; + direct_grant_flow = oStr "Alias of the flow bound to the direct-grant flow."; + reset_credentials_flow = oStr "Alias of the flow bound to the reset-credentials flow."; + client_authentication_flow = oStr "Alias of the flow bound to the client-auth flow."; + docker_authentication_flow = oStr "Alias of the flow bound to the docker-auth flow."; + first_broker_login_flow = oStr "Alias of the flow bound to the first-broker-login flow."; + }; + }; }; # generate nixos options for resources from resourceTypes From 5e88765771f5b6dcf695d5222487bd2d6c082dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:36:42 +0200 Subject: [PATCH 18/50] feat(services/keycloak): add openid authorization + 8 policies - openid_client_authorization_resource / _scope / _permission: the three core authz primitives hanging off a resource server. - openid_client_authorization_aggregate_policy: composite of other policies under a decision strategy. - openid_client_authorization_client_policy: grant by client. - openid_client_authorization_client_scope_policy: grant by client scope (nested list of `{ id; required; }` blocks). - openid_client_authorization_group_policy: grant by group (nested list of `{ id; path; extend_children; }` blocks). - openid_client_authorization_js_policy: javascript-implemented policy (requires the scripts feature). - openid_client_authorization_role_policy: grant by role (nested list of `{ id; required; }` blocks). - openid_client_authorization_time_policy: time-window policy. - openid_client_authorization_user_policy: grant by user. All policies share resource_server_id (managed ref into openid_clients.resource_server_id, which the provider computes once `authorization` is enabled on the client) and the standard {decision_strategy, logic, description} attrs. Adds an oListSub helper for the nested groups / role / scope list-of-object blocks the policies use. Eval-only verification; a runtime fixture needs the deferred renderer support for `TypeList+MaxItems:1` blocks (to enable authorization on the parent client). --- services/keycloak/lib.nix | 395 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index e6d94e6..266e63e 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -64,6 +64,13 @@ let type = ty.bool; inherit description; }; + oListSub = + options: description: + lib.mkOption { + type = ty.nullOr (ty.listOf (ty.submodule { inherit options; })); + default = null; + inherit description; + }; # ref spec shared by almost every non-realm resource: realm_id is a numeric # id the user can't know, so it must resolve to a managed realm by key. @@ -1663,6 +1670,394 @@ let first_broker_login_flow = oStr "Alias of the flow bound to the first-broker-login flow."; }; }; + + openid_client_authorization_resources = { + type = "keycloak_openid_client_authorization_resource"; + prefix = "openid_client_authz_resource"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client (with authorization enabled) hosting this resource."; + }; + }; + description = "Authorization resources hosted on an openid_client's resource server."; + attrs = { + name = oStr "Resource name. Defaults to the attribute key."; + display_name = oStr "Human-friendly display name."; + uris = oListStr "URIs the resource represents."; + icon_uri = oStr "Optional icon URI."; + owner_managed_access = oBool "Allow the owner to manage access to this resource."; + scopes = oListStr "Names of authorization scopes available on the resource."; + type = oStr "Optional resource type discriminator."; + attributes = oAttrsStr "Free-form attribute map."; + }; + }; + + openid_client_authorization_scopes = { + type = "keycloak_openid_client_authorization_scope"; + prefix = "openid_client_authz_scope"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client (with authorization enabled) hosting this scope."; + }; + }; + description = "Authorization scopes on an openid_client's resource server."; + attrs = { + name = oStr "Scope name. Defaults to the attribute key."; + display_name = oStr "Human-friendly display name."; + icon_uri = oStr "Optional icon URI."; + }; + }; + + openid_client_authorization_permissions = { + type = "keycloak_openid_client_authorization_permission"; + prefix = "openid_client_authz_permission"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client (with authorization enabled) hosting this permission."; + }; + }; + description = "Authorization permissions tying resources/scopes to policies."; + attrs = { + name = oStr "Permission name. Defaults to the attribute key."; + description = oStr "Permission description."; + decision_strategy = oStr "Decision strategy ('UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS'; default 'UNANIMOUS')."; + policies = oListStr "Names / ids of policies that apply."; + resources = oListStr "Resource names this permission covers (conflicts with resource_type)."; + resource_type = oStr "Single resource type this permission covers (conflicts with resources)."; + scopes = oListStr "Scope names this permission covers."; + type = oStr "Permission type ('resource' [default] or 'scope')."; + }; + }; + + openid_client_authorization_aggregate_policies = { + type = "keycloak_openid_client_authorization_aggregate_policy"; + prefix = "openid_client_authz_aggregate_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this aggregate policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "policies" + ]; + description = "Aggregate policy combining other policies under a decision strategy."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy ('UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS')."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + policies = oListStr "Names / ids of policies aggregated by this policy."; + }; + }; + + openid_client_authorization_client_policies = { + type = "keycloak_openid_client_authorization_client_policy"; + prefix = "openid_client_authz_client_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "clients" + ]; + description = "Policy granting access to a specific set of clients."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + clients = oListStr "ClientIds of clients the policy applies to."; + }; + }; + + openid_client_authorization_client_scope_policies = { + type = "keycloak_openid_client_authorization_client_scope_policy"; + prefix = "openid_client_authz_client_scope_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "scope" + ]; + description = "Policy granting access by client scope membership; each scope block is `{ id; required = false; }`."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + scope = oListSub { + id = rStr "Client scope id."; + required = oBool "Treat the scope as required (vs optional)."; + } "List of `{ id; required; }` blocks naming client scopes the policy applies to."; + }; + }; + + openid_client_authorization_group_policies = { + type = "keycloak_openid_client_authorization_group_policy"; + prefix = "openid_client_authz_group_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "groups" + ]; + description = "Policy granting access by group membership; each group block is `{ id; path; extend_children; }`."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + groups_claim = oStr "Optional JWT claim whose value carries the group path."; + groups = oListSub { + id = rStr "Group id."; + path = oStr "Group path (read from the API)."; + extend_children = oBool "Match descendants of the group as well."; + } "List of `{ id; path; extend_children; }` blocks naming groups the policy applies to."; + }; + }; + + openid_client_authorization_js_policies = { + type = "keycloak_openid_client_authorization_js_policy"; + prefix = "openid_client_authz_js_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "code" + ]; + description = "Policy implemented in JavaScript (requires the scripts feature)."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + type = oStr "Policy type discriminator ('js')."; + code = oStr "JavaScript source."; + }; + }; + + openid_client_authorization_role_policies = { + type = "keycloak_openid_client_authorization_role_policy"; + prefix = "openid_client_authz_role_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "role" + ]; + description = "Policy granting access by realm or client role membership; each role block is `{ id; required = false; }`."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + type = oStr "Policy type discriminator."; + fetch_roles = oBool "Fetch role information on policy evaluation."; + role = oListSub { + id = rStr "Role id."; + required = oBool "Treat the role as required (vs optional)."; + } "List of `{ id; required; }` blocks naming roles the policy applies to."; + }; + }; + + openid_client_authorization_time_policies = { + type = "keycloak_openid_client_authorization_time_policy"; + prefix = "openid_client_authz_time_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ "decision_strategy" ]; + description = "Policy granting access within a time window."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + not_before = oStr "Date-time before which access is denied (`YYYY-MM-DD HH:MM:SS`)."; + not_on_or_after = oStr "Date-time on or after which access is denied."; + day_month = oStr "Day-of-month window start."; + day_month_end = oStr "Day-of-month window end."; + month = oStr "Month window start."; + month_end = oStr "Month window end."; + year = oStr "Year window start."; + year_end = oStr "Year window end."; + hour = oStr "Hour-of-day window start."; + hour_end = oStr "Hour-of-day window end."; + minute = oStr "Minute-of-hour window start."; + minute_end = oStr "Minute-of-hour window end."; + }; + }; + + openid_client_authorization_user_policies = { + type = "keycloak_openid_client_authorization_user_policy"; + prefix = "openid_client_authz_user_policy"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + resource_server = { + attr = "resource_server_id"; + targets = [ + { + collection = "openid_clients"; + field = "resource_server_id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client hosting this policy."; + }; + }; + requiredAttrs = [ + "decision_strategy" + "users" + ]; + description = "Policy granting access to a specific set of users."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + decision_strategy = oStr "Decision strategy."; + logic = oStr "Policy logic ('POSITIVE' or 'NEGATIVE')."; + users = oListStr "User ids the policy applies to."; + }; + }; }; # generate nixos options for resources from resourceTypes From 411eb39f79aaa9596a04a9d4b57a40f8a475ef6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:39:04 +0200 Subject: [PATCH 19/50] feat(services/keycloak): add ldap_user_federations + 10 mappers - ldap_user_federations: ~30 typed attrs covering the full federation surface (vendor + connection + edit_mode + sync + auth). bind_credential is Sensitive and goes through File. Declares kerberos and cache nested sub-blocks via oSub. - ldap_user_attribute_mapper / group_mapper / role_mapper: the three core LDAP <-> keycloak attribute / group / role mappers. - ldap_hardcoded_role / attribute / group mappers: per-user hardcoded grants. - ldap_msad_user_account_control / msad_lds_user_account_control: MSAD integration. - ldap_full_name_mapper: split / join a single LDAP fullName. - ldap_custom_mapper: escape hatch for unmapped LDAP SPIs. Adds ldapFederationIdRef (reused by every mapper) and an oSub helper for nested sub-blocks. Eval-only verification (a runtime fixture needs a reachable LDAP server). --- services/keycloak/lib.nix | 315 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 266e63e..265dad0 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -71,6 +71,13 @@ let default = null; inherit description; }; + oSub = + options: description: + lib.mkOption { + type = ty.nullOr (ty.submodule { inherit options; }); + default = null; + inherit description; + }; # ref spec shared by almost every non-realm resource: realm_id is a numeric # id the user can't know, so it must resolve to a managed realm by key. @@ -162,6 +169,21 @@ let description = "Key of the managed realm (services.keycloak.runtime.realms.) the IdP lives in."; }; + # LDAP mappers reference their parent federation by managed key (numeric + # id). Used by every keycloak_ldap_*_mapper resource. + ldapFederationIdRef = { + attr = "ldap_user_federation_id"; + targets = [ + { + collection = "ldap_user_federations"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed LDAP user federation (services.keycloak.runtime.ldap_user_federations.) this mapper attaches to."; + }; + # identity provider mappers reference an IdP by alias; the alias may # belong to any of the six IdP variants we model. idpAliasRequiredRef = { @@ -2025,6 +2047,299 @@ let }; }; + ldap_user_federations = { + type = "keycloak_ldap_user_federation"; + prefix = "ldap_user_federation"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + secrets = [ "bind_credential" ]; + requiredAttrs = [ + "username_ldap_attribute" + "rdn_ldap_attribute" + "uuid_ldap_attribute" + "user_object_classes" + "connection_url" + "users_dn" + ]; + # `kerberos` and `cache` are TypeList+MaxItems:1 nested blocks -- + # declared as oSub here, but their JSON emission will be wrong + # until R1 (block-list wrapping) lands. Avoid setting them. + description = "LDAP user federations (per-realm), keyed by name."; + attrs = { + name = oStr "Federation name. Defaults to the attribute key."; + enabled = oBool "Is the federation enabled?"; + priority = oInt "Evaluation priority (lower runs first)."; + import_enabled = oBool "Import users from LDAP into Keycloak's local DB."; + edit_mode = oStr "'READ_ONLY' (default), 'WRITABLE', or 'UNSYNCED'."; + sync_registrations = oBool "Write new user registrations back into LDAP."; + vendor = oStr "LDAP vendor: 'OTHER' (default), 'EDIRECTORY', 'AD', 'RHDS', 'TIVOLI'."; + username_ldap_attribute = oStr "LDAP attribute carrying the username."; + rdn_ldap_attribute = oStr "LDAP RDN attribute."; + uuid_ldap_attribute = oStr "LDAP attribute carrying a stable UUID."; + user_object_classes = oListStr "LDAP objectClasses for users."; + connection_url = oStr "LDAP connection URL (ldap[s]://host:port)."; + users_dn = oStr "Base DN under which users live."; + bind_dn = oStr "DN used to authenticate to LDAP (omit for anonymous bind)."; + bind_credential = oStr "Password for bind_dn. Prefer `bind_credentialFile`."; + custom_user_search_filter = oStr "Extra LDAP filter applied when looking up users."; + krb_principal_attribute = oStr "LDAP attribute carrying the Kerberos principal."; + debug = oStr "Enable LDAP debug logging ('true' / 'false')."; + search_scope = oStr "Search scope: 'ONE_LEVEL' (default) or 'SUBTREE'."; + start_tls = oBool "Issue STARTTLS after connecting."; + connection_pooling = oBool "Pool LDAP connections."; + use_password_modify_extended_op = oBool "Use the LDAP password modify extended operation."; + validate_password_policy = oBool "Validate passwords against the realm's password policy."; + trust_email = oBool "Trust the email returned by LDAP without verification."; + use_truststore_spi = oStr "Truststore SPI usage: 'ALWAYS', 'ONLY_FOR_LDAPS' (default), or 'NEVER'."; + connection_timeout = oStr "LDAP connection timeout (duration string)."; + read_timeout = oStr "LDAP read timeout (duration string)."; + pagination = oBool "Enable LDAP pagination."; + batch_size_for_sync = oInt "Number of users per sync batch."; + full_sync_period = oInt "Full sync period in seconds (-1 disables)."; + changed_sync_period = oInt "Incremental sync period in seconds (-1 disables)."; + delete_default_mappers = oBool "Remove the default protocol mappers shipped with the federation."; + kerberos = oSub { + kerberos_realm = oStr "Kerberos realm."; + server_principal = oStr "Kerberos service principal of the LDAP server."; + key_tab = oStr "Path to the keytab file."; + use_kerberos_for_password_authentication = oBool "Use Kerberos for password auth."; + } "Kerberos integration sub-block. Needs R1 (block-list wrapping) to emit correctly."; + cache = oSub { + policy = oStr "Cache policy ('DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE')."; + max_lifespan = oStr "Max lifespan (for MAX_LIFESPAN)."; + eviction_day = oStr "Eviction day (for EVICT_WEEKLY)."; + eviction_hour = oStr "Eviction hour."; + eviction_minute = oStr "Eviction minute."; + } "Cache configuration sub-block. Needs R1 (block-list wrapping) to emit correctly."; + }; + }; + + ldap_user_attribute_mappers = { + type = "keycloak_ldap_user_attribute_mapper"; + prefix = "ldap_user_attribute_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "user_model_attribute" + "ldap_attribute" + ]; + description = "Maps a keycloak user attribute to an LDAP attribute."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + user_model_attribute = oStr "Keycloak-side user attribute name."; + ldap_attribute = oStr "LDAP attribute name."; + read_only = oBool "Treat LDAP as the source of truth (writes are no-ops)."; + always_read_value_from_ldap = oBool "Re-read value from LDAP on every access."; + is_mandatory_in_ldap = oBool "LDAP enforces presence of the attribute."; + attribute_force_default = oBool "Force the default value when the LDAP attribute is missing."; + attribute_default_value = oStr "Default value used when LDAP returns none."; + is_binary_attribute = oBool "Treat the LDAP attribute as binary."; + }; + }; + + ldap_group_mappers = { + type = "keycloak_ldap_group_mapper"; + prefix = "ldap_group_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "ldap_groups_dn" + "group_name_ldap_attribute" + "group_object_classes" + "membership_ldap_attribute" + "membership_user_ldap_attribute" + ]; + description = "Maps LDAP groups onto keycloak groups."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + ldap_groups_dn = oStr "Base DN under which groups live."; + group_name_ldap_attribute = oStr "LDAP attribute carrying the group name."; + group_object_classes = oListStr "LDAP objectClasses for groups."; + preserve_group_inheritance = oBool "Preserve nested group hierarchy."; + ignore_missing_groups = oBool "Ignore membership entries pointing to missing groups."; + membership_ldap_attribute = oStr "LDAP attribute on the group holding member references."; + membership_attribute_type = oStr "'DN' (default) or 'UID'."; + membership_user_ldap_attribute = oStr "LDAP attribute on the user that uniquely identifies them."; + groups_ldap_filter = oStr "Extra LDAP filter for group lookups."; + mode = oStr "Mapper mode: 'READ_ONLY' (default), 'LDAP_ONLY', or 'IMPORT'."; + user_roles_retrieve_strategy = oStr "Strategy for resolving a user's groups."; + memberof_ldap_attribute = oStr "LDAP attribute holding direct group memberships (memberOf-style)."; + mapped_group_attributes = oListStr "LDAP group attributes preserved into keycloak."; + drop_non_existing_groups_during_sync = oBool "Delete keycloak groups missing from LDAP during sync."; + groups_path = oStr "Path under which mapped groups live (e.g. `/ldap-groups`)."; + }; + }; + + ldap_role_mappers = { + type = "keycloak_ldap_role_mapper"; + prefix = "ldap_role_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "ldap_roles_dn" + "role_name_ldap_attribute" + "role_object_classes" + "membership_ldap_attribute" + "membership_user_ldap_attribute" + ]; + description = "Maps LDAP roles onto keycloak realm or client roles."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + ldap_roles_dn = oStr "Base DN under which roles live."; + role_name_ldap_attribute = oStr "LDAP attribute carrying the role name."; + role_object_classes = oListStr "LDAP objectClasses for roles."; + membership_ldap_attribute = oStr "LDAP attribute on the role holding member references."; + membership_attribute_type = oStr "'DN' (default) or 'UID'."; + membership_user_ldap_attribute = oStr "LDAP attribute on the user that uniquely identifies them."; + roles_ldap_filter = oStr "Extra LDAP filter for role lookups."; + mode = oStr "Mapper mode: 'READ_ONLY' (default), 'LDAP_ONLY', or 'IMPORT'."; + user_roles_retrieve_strategy = oStr "Strategy for resolving a user's roles."; + memberof_ldap_attribute = oStr "LDAP attribute holding direct role memberships."; + use_realm_roles_mapping = oBool "Map onto realm roles (true) or client roles (false)."; + client_id = oStr "ClientId roles are scoped to when `use_realm_roles_mapping = false`."; + }; + }; + + ldap_hardcoded_role_mappers = { + type = "keycloak_ldap_hardcoded_role_mapper"; + prefix = "ldap_hardcoded_role_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ "role" ]; + description = "Grants a hardcoded role to every LDAP-federated user."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + role = oStr "Realm or `client.role` name granted."; + }; + }; + + ldap_hardcoded_attribute_mappers = { + type = "keycloak_ldap_hardcoded_attribute_mapper"; + prefix = "ldap_hardcoded_attribute_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "attribute_name" + "attribute_value" + ]; + description = "Sets a hardcoded user attribute on every LDAP-federated user."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + attribute_name = oStr "Name of the attribute to set."; + attribute_value = oStr "Value of the attribute."; + }; + }; + + ldap_hardcoded_group_mappers = { + type = "keycloak_ldap_hardcoded_group_mapper"; + prefix = "ldap_hardcoded_group_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ "group" ]; + description = "Adds every LDAP-federated user to a hardcoded group."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + group = oStr "Group path (e.g. `/engineering`) every federated user joins."; + }; + }; + + ldap_msad_user_account_control_mappers = { + type = "keycloak_ldap_msad_user_account_control_mapper"; + prefix = "ldap_msad_uac_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + description = "MSAD userAccountControl integration mapper (enables / disables and locks out users)."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + ldap_password_policy_hints_enabled = oBool "Forward keycloak password-policy hints to MSAD."; + }; + }; + + ldap_msad_lds_user_account_control_mappers = { + type = "keycloak_ldap_msad_lds_user_account_control_mapper"; + prefix = "ldap_msad_lds_uac_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + description = "MSAD LDS userAccountControl integration mapper."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + }; + }; + + ldap_full_name_mappers = { + type = "keycloak_ldap_full_name_mapper"; + prefix = "ldap_full_name_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ "ldap_full_name_attribute" ]; + description = "Splits/joins a single LDAP full-name attribute into keycloak's first / last name fields."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + ldap_full_name_attribute = oStr "LDAP attribute carrying the full name."; + read_only = oBool "Treat LDAP as source of truth."; + write_only = oBool "Only push the full name back to LDAP."; + }; + }; + + ldap_custom_mappers = { + type = "keycloak_ldap_custom_mapper"; + prefix = "ldap_custom_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "provider_id" + "provider_type" + ]; + description = "Escape hatch for an LDAP mapper implementation without a dedicated typed resource."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + provider_id = oStr "Provider-id of the mapper implementation."; + provider_type = oStr "SPI type the provider implements."; + config = oAttrsStr "Mapper-specific configuration."; + }; + }; + openid_client_authorization_user_policies = { type = "keycloak_openid_client_authorization_user_policy"; prefix = "openid_client_authz_user_policy"; From 289c5f2fb11af261ea29b7599fa0b7a65374115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:40:06 +0200 Subject: [PATCH 20/50] feat(services/keycloak): add custom_user_federation + hardcoded_attribute_mapper - custom_user_federation: JPA / SPI-backed user federation with cache_policy, sync periods, and a provider config map. - hardcoded_attribute_mapper: bare (non-LDAP-prefixed) hardcoded attribute mapper -- distinct from ldap_hardcoded_attribute_mapper and hardcoded_attribute_identity_provider_mapper (same shape, different SPI hookup). Eval-only verification. --- services/keycloak/lib.nix | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 265dad0..f7ef30b 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2340,6 +2340,48 @@ let }; }; + custom_user_federations = { + type = "keycloak_custom_user_federation"; + prefix = "custom_user_federation"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "provider_id" ]; + description = "Custom user federation backed by a JPA / SPI provider."; + attrs = { + name = oStr "Federation name. Defaults to the attribute key."; + parent_id = oStr "Optional parent federation id."; + provider_id = oStr "Provider-id of the federation implementation."; + enabled = oBool "Is the federation enabled?"; + priority = oInt "Evaluation priority (lower runs first)."; + cache_policy = oStr "Cache policy: 'DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE'."; + full_sync_period = oInt "Full sync period in seconds (-1 disables)."; + changed_sync_period = oInt "Incremental sync period in seconds (-1 disables)."; + config = oAttrsStr "Provider-specific configuration map."; + }; + }; + + hardcoded_attribute_mappers = { + type = "keycloak_hardcoded_attribute_mapper"; + prefix = "hardcoded_attribute_mapper"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + ldap_user_federation = ldapFederationIdRef; + }; + requiredAttrs = [ + "attribute_name" + "attribute_value" + ]; + description = "Sets a hardcoded user attribute on every federated user. Distinct from ldap_hardcoded_attribute_mapper and hardcoded_attribute_identity_provider_mapper."; + attrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + attribute_name = oStr "Name of the attribute to set."; + attribute_value = oStr "Value of the attribute."; + }; + }; + openid_client_authorization_user_policies = { type = "keycloak_openid_client_authorization_user_policy"; prefix = "openid_client_authz_user_policy"; From 241a39cfd5b2eec2d494063d9d2f174837a30660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:43:31 +0200 Subject: [PATCH 21/50] feat(services/keycloak): add 6 realm keystores - realm_keystore_aes_generated / ecdsa_generated / hmac_generated / rsa_generated: keystores keycloak generates itself, parameterised by curve / algorithm / key_size. - realm_keystore_java_keystore: backed by a JKS file on disk; keystore_password and key_password go through File. - realm_keystore_rsa: externally-provided PEM private_key + certificate; both go through File. All keystores share realm + (name / active / enabled / priority). VM test declares one realm_keystore_rsa_generated and asserts via /admin/realms//keys that an ACTIVE RS256 key shows up. --- services/keycloak/checks.nix | 26 +++++++ services/keycloak/lib.nix | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 6265d3f..dd8ee3b 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -183,6 +183,15 @@ in alias = "acme-passkey"; description = "Passkey login flow"; }; + + # additional realm RSA key. + realm_keystore_rsa_generateds.acme_extra_rsa = { + realm = "acme"; + name = "acme-extra-rsa"; + algorithm = "RS256"; + key_size = 2048; + priority = 50; + }; }; }; @@ -294,6 +303,23 @@ in ) assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + with subtest("realm RSA keystore appears in the keys endpoint"): + tok = admin_token() + keys = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/keys" + )) + # /keys returns { keys: [...], active: {...} }; look for our component + # by checking that an RS256 entry from our provider name exists. + providers = {k.get("providerId") for k in keys.get("keys", [])} + assert any( + "acme-extra-rsa" in str(k.get("providerId") or "") + for k in keys.get("keys", []) + ) or any( + k.get("algorithm") == "RS256" and k.get("status") == "ACTIVE" + for k in keys.get("keys", []) + ), f"acme-extra-rsa key not found, providers: {providers}" + with subtest("authentication flow exists"): tok = admin_token() flows = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index f7ef30b..5c7e68f 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2361,6 +2361,134 @@ let }; }; + realm_keystore_aes_generateds = { + type = "keycloak_realm_keystore_aes_generated"; + prefix = "realm_keystore_aes_generated"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "AES keystore generated by Keycloak."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + secret_size = oInt "Secret size in bytes (16, 24, or 32; default 16)."; + }; + }; + + realm_keystore_ecdsa_generateds = { + type = "keycloak_realm_keystore_ecdsa_generated"; + prefix = "realm_keystore_ecdsa_generated"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "ECDSA keystore generated by Keycloak."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + elliptic_curve_key = oStr "Curve: 'P-256' (default), 'P-384', or 'P-521'."; + }; + }; + + realm_keystore_hmac_generateds = { + type = "keycloak_realm_keystore_hmac_generated"; + prefix = "realm_keystore_hmac_generated"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "HMAC keystore generated by Keycloak."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + algorithm = oStr "HMAC algorithm: 'HS256' (default), 'HS384', or 'HS512'."; + secret_size = oInt "Secret size in bytes (16, 24, 32, 64, 128, 256, or 512; default 64)."; + }; + }; + + realm_keystore_java_keystores = { + type = "keycloak_realm_keystore_java_keystore"; + prefix = "realm_keystore_java_keystore"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + secrets = [ + "keystore_password" + "key_password" + ]; + requiredSecrets = [ + "keystore_password" + "key_password" + ]; + requiredAttrs = [ + "keystore" + "key_alias" + ]; + description = "Keystore backed by a Java KeyStore (JKS) file."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + algorithm = oStr "Signing algorithm (default 'RS256')."; + keystore = oStr "Host path to the JKS file (on the keycloak server)."; + keystore_password = oStr "Password unlocking the JKS file. Prefer `keystore_passwordFile`."; + key_alias = oStr "Key alias within the JKS file."; + key_password = oStr "Password unlocking the key entry. Prefer `key_passwordFile`."; + }; + }; + + realm_keystore_rsas = { + type = "keycloak_realm_keystore_rsa"; + prefix = "realm_keystore_rsa"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + # private_key and certificate are PEM material; provider doesn't mark + # them Sensitive but operators want them out of the world-readable store. + secrets = [ + "private_key" + "certificate" + ]; + requiredSecrets = [ + "private_key" + "certificate" + ]; + description = "Keystore backed by an externally-provided RSA private key / certificate pair."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + algorithm = oStr "Signing algorithm (default 'RS256')."; + private_key = oStr "PEM-encoded RSA private key. Prefer `private_keyFile`."; + certificate = oStr "PEM-encoded certificate. Prefer `certificateFile`."; + provider_id = oStr "Provider id (default 'rsa')."; + extra_config = oAttrsStr "Free-form extra config entries."; + }; + }; + + realm_keystore_rsa_generateds = { + type = "keycloak_realm_keystore_rsa_generated"; + prefix = "realm_keystore_rsa_generated"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "RSA keystore generated by Keycloak."; + attrs = { + name = oStr "Keystore name. Defaults to the attribute key."; + active = oBool "Is the key active?"; + enabled = oBool "Is the keystore enabled?"; + priority = oInt "Selection priority."; + algorithm = oStr "Signing algorithm: 'RS256' (default), 'RS384', 'RS512', 'PS256', 'PS384', or 'PS512'."; + key_size = oInt "Key size in bits (1024, 2048, or 4096; default 2048)."; + }; + }; + hardcoded_attribute_mappers = { type = "keycloak_hardcoded_attribute_mapper"; prefix = "hardcoded_attribute_mapper"; From 120908a1a12df54d408681e3714b0eae586dfff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:48:18 +0200 Subject: [PATCH 22/50] refactor(services/keycloak): render TypeList+MaxItems:1 as a block-list renderItem now reads a per-resource `blockAttrs` list and wraps each named attribute's value in `[ obj ]` after cleanNulls, so single- instance terraform blocks emit as JSON arrays of one object (`"x": [{...}]`) instead of plain objects. Resources without `blockAttrs` are unaffected. realms now declares smtp_server (with auth and token_auth sub-blocks; nested-Sensitive fields are LITERAL for now -- nested File support lands later) and internationalization (supported_locales + default_locale), both listed in blockAttrs. VM test fixture sets internationalization (en + de, default en) and smtp_server (flat fields) on realms.acme, and asserts via the admin API that smtpServer.host / smtpServer.from / supportedLocales / defaultLocale all reach keycloak. The remaining previously-skipped nested blocks (openid_clients. authorization, authentication_flow_binding_overrides on openid/saml clients, users.initial_password / federated_identity, realm's security_defenses / otp_policy / web_authn_*) wire up in a later commit. --- services/keycloak/checks.nix | 29 +++++++++++++++++++++ services/keycloak/lib.nix | 50 +++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index dd8ee3b..e806402 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -53,6 +53,24 @@ in attributes = { "userProfileEnabled" = "true"; }; + # nested block; renderer wraps as `[{...}]` via blockAttrs. + # Internationalisation has no nested secret -- safe to set fully. + internationalization = { + supported_locales = [ + "en" + "de" + ]; + default_locale = "en"; + }; + # smtp_server is a nested block too; flat fields only here + # (no auth -- the nested-Sensitive auth.password / token_auth. + # client_secret aren't yet protected by File). + smtp_server = { + host = "smtp.example.com"; + from = "noreply@example.com"; + port = "25"; + from_display_name = "ACME"; + }; }; # realm-level role + default-roles binding @@ -242,6 +260,17 @@ in assert acme.get("attributes", {}).get("userProfileEnabled") == "true", \ f"attributes: {acme}" + with subtest("realm nested blocks (smtp_server + internationalization) applied"): + smtp = acme.get("smtpServer", {}) + assert smtp.get("host") == "smtp.example.com", f"smtp.host: {smtp}" + assert smtp.get("from") == "noreply@example.com", f"smtp.from: {smtp}" + assert smtp.get("fromDisplayName") == "ACME", f"smtp.from_display_name: {smtp}" + assert acme.get("internationalizationEnabled") is True, \ + f"i18n not enabled: {acme}" + locales = set(acme.get("supportedLocales", [])) + assert {"en", "de"}.issubset(locales), f"supported_locales: {locales}" + assert acme.get("defaultLocale") == "en", f"default_locale: {acme}" + with subtest("realm role exists with description"): tok = admin_token() role = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 5c7e68f..d72652b 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -302,6 +302,10 @@ let nameAttr = "realm"; scope = null; refs = { }; + blockAttrs = [ + "smtp_server" + "internationalization" + ]; description = "Keycloak realms, keyed by realm name."; attrs = { realm = oStr "Realm name. Defaults to the attribute key."; @@ -371,6 +375,42 @@ let # default client scopes (referenced by name) default_default_client_scopes = oListStr "Default client scopes auto-granted to new clients."; default_optional_client_scopes = oListStr "Optional client scopes available to new clients."; + + # nested blocks: rendered as [{ ... }] via the `blockAttrs` markup. + # Nested-sensitive fields (smtp_server.auth.password, + # smtp_server.token_auth.client_secret) don't yet have File + # support; supplying a literal lands in the world-readable store. + smtp_server = oSub { + host = rStr "SMTP host."; + from = rStr "From address."; + port = oStr "SMTP port (string -- matches the provider schema)."; + starttls = oBool "Use STARTTLS."; + ssl = oBool "Use SSL/TLS."; + allow_utf8 = oBool "Allow UTF-8 in addresses."; + from_display_name = oStr "Display name shown on the From: line."; + reply_to = oStr "Reply-to address."; + reply_to_display_name = oStr "Reply-to display name."; + envelope_from = oStr "Envelope From address."; + auth = oSub { + username = rStr "SMTP auth username."; + password = rStr "SMTP auth password (LITERAL -- nested File support not yet implemented)."; + } "SMTP basic-auth credentials (mutually exclusive with token_auth)."; + token_auth = oSub { + username = rStr "OAuth2 token-auth username."; + url = rStr "OAuth2 token endpoint."; + client_id = rStr "OAuth2 client_id."; + client_secret = rStr "OAuth2 client_secret (LITERAL -- nested File support not yet implemented)."; + scope = rStr "OAuth2 scope."; + } "SMTP OAuth2 token credentials (mutually exclusive with auth)."; + } "SMTP server configuration."; + + internationalization = oSub { + supported_locales = lib.mkOption { + type = ty.listOf ty.str; + description = "Locales the realm supports."; + }; + default_locale = rStr "Default locale."; + } "Realm internationalization settings."; }; }; @@ -2691,12 +2731,20 @@ let else null ) (spec.requiredAttrs or [ ]); + # wrap TypeList+MaxItems:1 nested blocks in [ obj ] so Terraform JSON + # gets block syntax. spec.blockAttrs lists the attrs that need it. + wrapBlocks = + v: + lib.mapAttrs ( + k: x: + if builtins.elem k (spec.blockAttrs or [ ]) && builtins.isAttrs x then [ x ] else x + ) v; in # use deepSeq to force evaluation of checks # (these are not config.assertions so they can be used outside a nixos system build) lib.nameValuePair (tfLabel spec.prefix key) ( builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( - cleanNulls (base // nameInject // refAttrs // secretAttrs) + wrapBlocks (cleanNulls (base // nameInject // refAttrs // secretAttrs)) ) ); From 856cbedb47d2217f0b7d1ae18b9f5523eef7ef2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:53:47 +0200 Subject: [PATCH 23/50] feat(services/keycloak): add 7 realm-level resources - required_actions: per-realm required actions (keyed by alias, e.g. "CONFIGURE_TOTP", "VERIFY_EMAIL"). - realm_events: realm-wide event-logging config. - realm_localizations: per-locale message bundle. - realm_default_client_scopes / realm_optional_client_scopes: realm-wide client-scope bindings. - organizations: keycloak organizations with a nested `domain` list of `{ name; verified; }` blocks. - identity_provider_token_exchange_scope_permissions: per-IdP token-exchange policy granting a set of clients access. VM test toggles CONFIGURE_TOTP off on the acme realm and adds a custom EN localization ("loginAccountTitle = ACME"), asserting both via the admin API. Deferred: realm_user_profile, realm_client_policy_profile(_policy), group_permissions, users_permissions -- they use the scopePermissionsSchema() pattern, beyond the simple `MaxItems:1` wrap. --- services/keycloak/checks.nix | 35 +++++++++++ services/keycloak/lib.nix | 118 ++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index e806402..415df61 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -210,6 +210,23 @@ in key_size = 2048; priority = 50; }; + + # built-in required action toggle. + required_actions.acme_configure_totp = { + realm = "acme"; + alias = "CONFIGURE_TOTP"; + enabled = false; + default_action = false; + }; + + # custom realm localization texts. + realm_localizations.acme_en = { + realm = "acme"; + locale = "en"; + texts = { + loginAccountTitle = "ACME"; + }; + }; }; }; @@ -332,6 +349,24 @@ in ) assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + with subtest("required_action CONFIGURE_TOTP is disabled"): + tok = admin_token() + ras = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/authentication/required-actions" + )) + totp = next((r for r in ras if r.get("alias") == "CONFIGURE_TOTP"), None) + assert totp, f"CONFIGURE_TOTP not found: {[r.get('alias') for r in ras]}" + assert totp.get("enabled") is False, f"CONFIGURE_TOTP should be disabled: {totp}" + + with subtest("realm localization message reaches the API"): + tok = admin_token() + texts = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/localization/en" + )) + assert texts.get("loginAccountTitle") == "ACME", f"localization texts: {texts}" + with subtest("realm RSA keystore appears in the keys endpoint"): tok = admin_token() keys = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index d72652b..d09056c 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2583,6 +2583,121 @@ let users = oListStr "User ids the policy applies to."; }; }; + + required_actions = { + type = "keycloak_required_action"; + prefix = "required_action"; + nameAttr = "alias"; + scope = null; + refs.realm = realmRef; + description = "Realm required actions (per-realm), keyed by alias."; + attrs = { + alias = oStr "Required action alias (e.g. 'CONFIGURE_TOTP'). Defaults to the attribute key."; + name = oStr "Display name shown to the user."; + enabled = oBool "Is the required action enabled?"; + default_action = oBool "Is the action set as a default for new users?"; + priority = oInt "Display / evaluation order."; + config = oAttrsStr "Action-specific configuration."; + }; + }; + + realm_events = { + type = "keycloak_realm_events"; + prefix = "realm_events"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + description = "Per-realm event logging configuration, keyed by an arbitrary label."; + attrs = { + admin_events_details_enabled = oBool "Log admin event representation details."; + admin_events_enabled = oBool "Log admin events."; + enabled_event_types = oListStr "Event types to log (empty list = all)."; + events_enabled = oBool "Log user events."; + events_expiration = oInt "User-event retention period in seconds (0 = forever)."; + events_listeners = oListStr "SPI listeners receiving events (e.g. [\"jboss-logging\"])."; + }; + }; + + realm_localizations = { + type = "keycloak_realm_localization"; + prefix = "realm_localization"; + nameAttr = "locale"; + scope = null; + refs.realm = realmRef; + description = "Per-realm i18n message bundle, keyed by locale."; + attrs = { + locale = oStr "BCP-47 locale tag (e.g. 'en'). Defaults to the attribute key."; + texts = oAttrsStr "Message-key to translation map."; + }; + }; + + realm_default_client_scopes = { + type = "keycloak_realm_default_client_scopes"; + prefix = "realm_default_client_scopes"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "default_scopes" ]; + description = "Realm-wide default client-scope binding (set of scope names), keyed by an arbitrary label. Distinct from realms..default_default_client_scopes, which is a free-form realm attribute."; + attrs = { + default_scopes = oListStr "Names of scopes auto-attached as default to every new client."; + }; + }; + + realm_optional_client_scopes = { + type = "keycloak_realm_optional_client_scopes"; + prefix = "realm_optional_client_scopes"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "optional_scopes" ]; + description = "Realm-wide optional client-scope binding (set of scope names), keyed by an arbitrary label."; + attrs = { + optional_scopes = oListStr "Names of scopes available as optional to every new client."; + }; + }; + + organizations = { + type = "keycloak_organization"; + prefix = "organization"; + nameAttr = "name"; + scope = null; + refs.realm = realmAliasRef; + description = "Keycloak organizations (per-realm, requires the organizations feature), keyed by name."; + attrs = { + name = oStr "Organization name. Defaults to the attribute key."; + alias = oStr "Stable alias (defaults to a normalised form of the name)."; + enabled = oBool "Is the organization enabled?"; + description = oStr "Organization description."; + redirect_url = oStr "Optional redirect URL for organization-aware flows."; + # domain is TypeSet of nested blocks; user provides a list of objects + # and the renderer emits as a JSON array unchanged (no blockAttrs + # wrap needed because it's already a list). + domain = oListSub { + name = rStr "Domain name (e.g. acme.example)."; + verified = oBool "Has the domain been verified?"; + } "List of `{ name; verified; }` domains owned by the organization."; + attributes = oAttrsStr "Free-form organization attribute map."; + }; + }; + + identity_provider_token_exchange_scope_permissions = { + type = "keycloak_identity_provider_token_exchange_scope_permission"; + prefix = "idp_token_exchange_perm"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ + "provider_alias" + "clients" + ]; + description = "Per-IdP token-exchange permission policy granting a set of clients access to the IdP's token-exchange scope."; + attrs = { + provider_alias = oStr "Alias of the IdP this permission applies to."; + policy_type = oStr "Policy type (default 'client')."; + clients = oListStr "ClientIds of clients the permission is granted to."; + }; + }; }; # generate nixos options for resources from resourceTypes @@ -2736,8 +2851,7 @@ let wrapBlocks = v: lib.mapAttrs ( - k: x: - if builtins.elem k (spec.blockAttrs or [ ]) && builtins.isAttrs x then [ x ] else x + k: x: if builtins.elem k (spec.blockAttrs or [ ]) && builtins.isAttrs x then [ x ] else x ) v; in # use deepSeq to force evaluation of checks From 0396551cb04573f9a3d4b15a478621b7cab8f8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 22:55:48 +0200 Subject: [PATCH 24/50] feat(services/keycloak): add client-policy + fine-grained permissions - realm_client_policy_profiles: client-policy profile with an `executor` list of `{ name; configuration; }` blocks. - realm_client_policy_profile_policies: policy with a `condition` list and a profiles list. - group_permissions: per-group fine-grained authz with scope blocks (view / manage / view_members / manage_members / manage_membership). - users_permissions: realm-wide fine-grained authz on the users collection (view / manage / map_roles / manage_group_membership / impersonate / user_impersonated). Each scope_* attr is a `TypeList+MaxItems:1` nested block listed in blockAttrs so the renderer wraps it as `[{ decision_strategy; policies; description; }]`. Deferred: realm_user_profile (nested MaxItems:1 block inside a list element; wrapBlocks needs to recurse into list elements first) plus the unwired nested blocks on openid_clients / saml_clients / users. Eval-only verification. --- services/keycloak/lib.nix | 116 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index d09056c..01df3f2 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2698,6 +2698,122 @@ let clients = oListStr "ClientIds of clients the permission is granted to."; }; }; + + realm_client_policy_profiles = { + type = "keycloak_realm_client_policy_profile"; + prefix = "realm_client_policy_profile"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + description = "Realm client-policy profile, listing executors that enforce a client policy."; + attrs = { + name = oStr "Profile name. Defaults to the attribute key."; + description = oStr "Profile description."; + executor = oListSub { + name = rStr "Executor provider-id (e.g. 'secure-client-uris')."; + configuration = oAttrsStr "Executor-specific configuration."; + } "List of executors run on policy evaluation."; + }; + }; + + realm_client_policy_profile_policies = { + type = "keycloak_realm_client_policy_profile_policy"; + prefix = "realm_client_policy_profile_policy"; + nameAttr = "name"; + scope = null; + refs.realm = realmRef; + requiredAttrs = [ "profiles" ]; + description = "Realm client-policy policy binding a set of profiles to a set of conditions."; + attrs = { + name = oStr "Policy name. Defaults to the attribute key."; + description = oStr "Policy description."; + enabled = oBool "Is the policy enabled?"; + condition = oListSub { + name = rStr "Condition provider-id (e.g. 'client-roles')."; + configuration = oAttrsStr "Condition-specific configuration."; + } "List of conditions; the policy applies when all conditions match."; + profiles = oListStr "Names of client-policy profiles this policy applies."; + }; + }; + + group_permissions = { + type = "keycloak_group_permissions"; + prefix = "group_permissions"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + group = { + attr = "group_id"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed group these fine-grained permissions apply to."; + }; + }; + # every scope_* attr is a MaxItems:1 nested block per scopePermissionsSchema(). + blockAttrs = [ + "view_scope" + "manage_scope" + "view_members_scope" + "manage_members_scope" + "manage_membership_scope" + ]; + description = "Fine-grained authorization permissions for a group; each scope_* attr binds a scope to a `{ decision_strategy; policies; description; }` block."; + attrs = + let + scopePerm = oSub { + policies = oListStr "Names / ids of policies that apply to this scope."; + description = oStr "Description."; + decision_strategy = oStr "Decision strategy ('UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS')."; + }; + in + { + view_scope = scopePerm "View-scope permission block."; + manage_scope = scopePerm "Manage-scope permission block."; + view_members_scope = scopePerm "View-members-scope permission block."; + manage_members_scope = scopePerm "Manage-members-scope permission block."; + manage_membership_scope = scopePerm "Manage-membership-scope permission block."; + }; + }; + + users_permissions = { + type = "keycloak_users_permissions"; + prefix = "users_permissions"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + blockAttrs = [ + "view_scope" + "manage_scope" + "map_roles_scope" + "manage_group_membership_scope" + "impersonate_scope" + "user_impersonated_scope" + ]; + description = "Fine-grained authorization permissions on the realm's users collection; each scope_* attr binds a scope to a `{ decision_strategy; policies; description; }` block."; + attrs = + let + scopePerm = oSub { + policies = oListStr "Names / ids of policies that apply to this scope."; + description = oStr "Description."; + decision_strategy = oStr "Decision strategy ('UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS')."; + }; + in + { + view_scope = scopePerm "View-scope permission block."; + manage_scope = scopePerm "Manage-scope permission block."; + map_roles_scope = scopePerm "Map-roles-scope permission block."; + manage_group_membership_scope = scopePerm "Manage-group-membership-scope permission block."; + impersonate_scope = scopePerm "Impersonate-scope permission block."; + user_impersonated_scope = scopePerm "User-impersonated-scope permission block."; + }; + }; }; # generate nixos options for resources from resourceTypes From e7764bae366b90a605cdf3176bed949c336dcb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 23:16:33 +0200 Subject: [PATCH 25/50] feat(services/keycloak): add openid_client_permissions Mirrors group_permissions / users_permissions: fine-grained authz on an openid_client, with 7 scope blocks (view, manage, configure, map_roles, map_roles_client_scope, map_roles_composite, token_exchange). All 7 listed in blockAttrs so the renderer emits them as `[{ decision_strategy; policies; description; }]`. --- services/keycloak/lib.nix | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 01df3f2..7e6285b 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2782,6 +2782,55 @@ let }; }; + openid_client_permissions = { + type = "keycloak_openid_client_permissions"; + prefix = "openid_client_permissions"; + nameAttr = null; + scope = null; + refs = { + realm = realmRef; + client = { + attr = "client_id"; + targets = [ + { + collection = "openid_clients"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + description = "Key of the managed openid_client these fine-grained permissions apply to."; + }; + }; + blockAttrs = [ + "view_scope" + "manage_scope" + "configure_scope" + "map_roles_scope" + "map_roles_client_scope_scope" + "map_roles_composite_scope" + "token_exchange_scope" + ]; + description = "Fine-grained authorization permissions on an openid_client; each scope_* attr binds a scope to a `{ decision_strategy; policies; description; }` block."; + attrs = + let + scopePerm = oSub { + policies = oListStr "Names / ids of policies that apply to this scope."; + description = oStr "Description."; + decision_strategy = oStr "Decision strategy ('UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS')."; + }; + in + { + view_scope = scopePerm "View-scope permission block."; + manage_scope = scopePerm "Manage-scope permission block."; + configure_scope = scopePerm "Configure-scope permission block."; + map_roles_scope = scopePerm "Map-roles-scope permission block."; + map_roles_client_scope_scope = scopePerm "Map-roles-client-scope-scope permission block."; + map_roles_composite_scope = scopePerm "Map-roles-composite-scope permission block."; + token_exchange_scope = scopePerm "Token-exchange-scope permission block."; + }; + }; + users_permissions = { type = "keycloak_users_permissions"; prefix = "users_permissions"; From feb5110d45645cde78e119d81d9254be3b33bcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 23:23:06 +0200 Subject: [PATCH 26/50] feat(services/keycloak): wire deferred nested blocks; recurse wrapBlocks Renderer: - wrapBlocks now takes a path and recurses through both attrsets and list elements. blockAttrs entries become dotted paths (e.g. `security_defenses.headers`, `attribute.permissions`), so nested-in-nested MaxItems:1 blocks and MaxItems:1 blocks inside list elements both get wrapped. Backwards-compatible with the flat entries already in use. Resources gaining nested-block options + blockAttrs: - realm: security_defenses (with headers + brute_force_detection), otp_policy, web_authn_policy, web_authn_passwordless_policy. - openid_clients: authorization, authentication_flow_binding_overrides. - saml_clients: authentication_flow_binding_overrides. - users: initial_password (LITERAL value until nested-File lands), federated_identity (renders as JSON array directly). VM test sets security_defenses (headers + brute_force_detection) and otp_policy on realms.acme, then asserts via the admin API that the header fields, brute-force settings, and otp_policy fields all reach keycloak. --- services/keycloak/checks.nix | 32 ++++++++ services/keycloak/lib.nix | 142 ++++++++++++++++++++++++++++++++--- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 415df61..b60023b 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -71,6 +71,27 @@ in port = "25"; from_display_name = "ACME"; }; + # exercises the nested-in-nested block-list wrapping (the + # security_defenses outer block and its inner headers / + # brute_force_detection sub-blocks each need [{...}] wrap). + security_defenses = { + headers = { + x_frame_options = "DENY"; + strict_transport_security = "max-age=63072000; includeSubDomains; preload"; + }; + brute_force_detection = { + permanent_lockout = false; + max_login_failures = 5; + }; + }; + otp_policy = { + type = "totp"; + algorithm = "HmacSHA256"; + digits = 6; + period = 30; + initial_counter = 0; + look_ahead_window = 1; + }; }; # realm-level role + default-roles binding @@ -288,6 +309,17 @@ in assert {"en", "de"}.issubset(locales), f"supported_locales: {locales}" assert acme.get("defaultLocale") == "en", f"default_locale: {acme}" + with subtest("nested-in-nested blocks (security_defenses.headers + brute_force_detection) applied"): + headers = acme.get("browserSecurityHeaders", {}) + assert headers.get("xFrameOptions") == "DENY", f"headers: {headers}" + assert headers.get("strictTransportSecurity", "").startswith("max-age=63072000"), \ + f"headers: {headers}" + # brute-force settings land at realm top-level under camelCase names. + assert acme.get("failureFactor") == 5, f"max_login_failures: {acme.get('failureFactor')}" + # otp_policy fields also flatten to the realm representation. + assert acme.get("otpPolicyAlgorithm") == "HmacSHA256", \ + f"otp algorithm: {acme.get('otpPolicyAlgorithm')}" + with subtest("realm role exists with description"): tok = admin_token() role = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 7e6285b..d60e061 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -305,6 +305,12 @@ let blockAttrs = [ "smtp_server" "internationalization" + "security_defenses" + "security_defenses.headers" + "security_defenses.brute_force_detection" + "otp_policy" + "web_authn_policy" + "web_authn_passwordless_policy" ]; description = "Keycloak realms, keyed by realm name."; attrs = { @@ -411,6 +417,66 @@ let }; default_locale = rStr "Default locale."; } "Realm internationalization settings."; + + security_defenses = oSub { + headers = oSub { + x_frame_options = oStr "X-Frame-Options header value."; + content_security_policy = oStr "Content-Security-Policy header value."; + content_security_policy_report_only = oStr "Content-Security-Policy-Report-Only header value."; + x_content_type_options = oStr "X-Content-Type-Options header value."; + x_robots_tag = oStr "X-Robots-Tag header value."; + x_xss_protection = oStr "X-XSS-Protection header value."; + strict_transport_security = oStr "Strict-Transport-Security header value."; + referrer_policy = oStr "Referrer-Policy header value."; + } "Response-header defaults Keycloak applies to admin/account endpoints."; + brute_force_detection = oSub { + permanent_lockout = oBool "Permanently lock accounts after too many failures."; + max_temporary_lockouts = oInt "Max number of temporary lockouts before a permanent one."; + max_login_failures = oInt "Number of failures triggering a lockout."; + wait_increment_seconds = oInt "Lockout duration increment."; + quick_login_check_milli_seconds = oInt "Quick-login check window (ms)."; + minimum_quick_login_wait_seconds = oInt "Minimum wait after a quick-login failure."; + max_failure_wait_seconds = oInt "Maximum lockout duration."; + failure_reset_time_seconds = oInt "Failure counter reset window."; + } "Brute-force-protection settings."; + } "Security defenses (response headers + brute-force protection)."; + + otp_policy = oSub { + type = oStr "OTP type: 'totp' (default) or 'hotp'."; + algorithm = oStr "HMAC algorithm: 'HmacSHA1' (default), 'HmacSHA256', or 'HmacSHA512'."; + digits = oInt "Number of OTP digits (6 or 8)."; + initial_counter = oInt "Initial counter (HOTP)."; + look_ahead_window = oInt "Look-ahead window size."; + period = oInt "Time-step (TOTP) in seconds."; + } "Realm OTP policy."; + + web_authn_policy = oSub { + acceptable_aaguids = oListStr "Accepted authenticator AAGUIDs (empty = any)."; + extra_origins = oListStr "Extra trusted origins for WebAuthn registration / login."; + attestation_conveyance_preference = oStr "Attestation conveyance preference ('not specified', 'none', 'indirect', 'direct')."; + authenticator_attachment = oStr "Authenticator attachment ('not specified', 'platform', 'cross-platform')."; + avoid_same_authenticator_register = oBool "Refuse to register an already-registered authenticator."; + create_timeout = oInt "Registration ceremony timeout in seconds."; + require_resident_key = oStr "Require a resident key ('not specified', 'Yes', 'No')."; + relying_party_entity_name = oStr "Relying-Party entity name."; + relying_party_id = oStr "Relying-Party id."; + signature_algorithms = oListStr "COSEAlgorithmIdentifiers accepted."; + user_verification_requirement = oStr "User verification requirement ('not specified', 'required', 'preferred', 'discouraged')."; + } "Realm WebAuthn (second-factor) policy."; + + web_authn_passwordless_policy = oSub { + acceptable_aaguids = oListStr "Accepted authenticator AAGUIDs (empty = any)."; + extra_origins = oListStr "Extra trusted origins for WebAuthn registration / login."; + attestation_conveyance_preference = oStr "Attestation conveyance preference."; + authenticator_attachment = oStr "Authenticator attachment."; + avoid_same_authenticator_register = oBool "Refuse to register an already-registered authenticator."; + create_timeout = oInt "Registration ceremony timeout in seconds."; + require_resident_key = oStr "Require a resident key."; + relying_party_entity_name = oStr "Relying-Party entity name."; + relying_party_id = oStr "Relying-Party id."; + signature_algorithms = oListStr "COSEAlgorithmIdentifiers accepted."; + user_verification_requirement = oStr "User verification requirement."; + } "Realm WebAuthn passwordless policy."; }; }; @@ -550,8 +616,11 @@ let scope = null; refs.realm = realmRef; requiredAttrs = [ "username" ]; - # initial_password / federated_identity are nested blocks with a Sensitive - # `value`; they need File support for nested attrs and land later. + blockAttrs = [ "initial_password" ]; + # initial_password.value is Sensitive but supplied LITERAL today + # (nested-File support is a follow-up); avoid setting a real + # password until that lands. federated_identity is a TypeSet of + # nested blocks -- renders as a JSON array directly, no wrap needed. description = "Keycloak users, keyed by username (must be lowercase)."; attrs = { username = oStr "Username (lowercase). Defaults to the attribute key."; @@ -562,6 +631,17 @@ let enabled = oBool "Is the user enabled?"; attributes = oAttrsStr "Free-form user attribute map."; required_actions = oListStr "Required actions on next login (e.g. \"VERIFY_EMAIL\", \"UPDATE_PASSWORD\")."; + + initial_password = oSub { + value = rStr "Initial password (LITERAL -- nested File support not yet implemented)."; + temporary = oBool "Force the user to change the password on first login."; + } "Initial password set at user creation."; + + federated_identity = oListSub { + identity_provider = rStr "Alias of the federating IdP."; + user_id = rStr "User id on the IdP side."; + user_name = rStr "Username on the IdP side."; + } "Federated-identity links pre-bound to the user; each block is `{ identity_provider; user_id; user_name; }`."; }; }; @@ -661,9 +741,12 @@ let scope = null; refs.realm = realmRef; secrets = [ "client_secret" ]; - # Skips nested blocks (authorization, authentication_flow_binding_overrides) - # and write-only secret variants (client_secret_wo) -- those need - # nested-block / write-only renderer extensions and land separately. + blockAttrs = [ + "authorization" + "authentication_flow_binding_overrides" + ]; + # Skips the write-only secret variants (client_secret_wo / + # client_secret_wo_version) -- those need a write-only renderer mode. description = "OpenID Connect clients (per-realm), keyed by clientId."; attrs = { client_id = oStr "OAuth2 clientId. Defaults to the attribute key."; @@ -723,6 +806,18 @@ let always_display_in_console = oBool "Always display the client in the user account console."; extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; + + authorization = oSub { + policy_enforcement_mode = rStr "Policy enforcement mode ('ENFORCING', 'PERMISSIVE', or 'DISABLED')."; + decision_strategy = oStr "Decision strategy when multiple policies apply (default 'UNANIMOUS')."; + allow_remote_resource_management = oBool "Allow resource management via the protection API."; + keep_defaults = oBool "Keep default resources / scopes / permissions Keycloak creates."; + } "Enables fine-grained authorization on the client (resource server). Required for openid_client_authorization_* resources."; + + authentication_flow_binding_overrides = oSub { + browser_id = oStr "Authentication flow id overriding the realm's browser flow for this client."; + direct_grant_id = oStr "Authentication flow id overriding the realm's direct-grant flow for this client."; + } "Per-client authentication flow overrides."; }; }; @@ -839,6 +934,7 @@ let # private key in practice; expose File so operators can keep it # out of the world-readable store. secrets = [ "signing_private_key" ]; + blockAttrs = [ "authentication_flow_binding_overrides" ]; description = "SAML clients (per-realm), keyed by clientId."; attrs = { client_id = oStr "SAML clientId. Defaults to the attribute key."; @@ -884,6 +980,11 @@ let always_display_in_console = oBool "Always display the client in the user account console."; extra_config = oAttrsStr "Free-form extra config entries."; + + authentication_flow_binding_overrides = oSub { + browser_id = oStr "Authentication flow id overriding the realm's browser flow for this client."; + direct_grant_id = oStr "Authentication flow id overriding the realm's direct-grant flow for this client."; + } "Per-client authentication flow overrides."; }; }; @@ -3011,19 +3112,36 @@ let else null ) (spec.requiredAttrs or [ ]); - # wrap TypeList+MaxItems:1 nested blocks in [ obj ] so Terraform JSON - # gets block syntax. spec.blockAttrs lists the attrs that need it. + # wrap TypeList+MaxItems:1 nested blocks in [ obj ] so Terraform + # JSON gets block syntax. spec.blockAttrs lists dotted paths + # (e.g. "smtp_server", "security_defenses.headers", + # "attribute.permissions"); recursion walks into both attrsets + # and list elements -- so a nested block inside a list element + # (like realm_user_profile.attribute[].permissions) is wrapped. wrapBlocks = - v: - lib.mapAttrs ( - k: x: if builtins.elem k (spec.blockAttrs or [ ]) && builtins.isAttrs x then [ x ] else x - ) v; + path: v: + if builtins.isAttrs v then + lib.mapAttrs ( + k: x: + let + childPath = if path == "" then k else "${path}.${k}"; + wrapped = wrapBlocks childPath x; + in + if builtins.elem childPath (spec.blockAttrs or [ ]) && builtins.isAttrs wrapped then + [ wrapped ] + else + wrapped + ) v + else if builtins.isList v then + map (wrapBlocks path) v + else + v; in # use deepSeq to force evaluation of checks # (these are not config.assertions so they can be used outside a nixos system build) lib.nameValuePair (tfLabel spec.prefix key) ( builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( - wrapBlocks (cleanNulls (base // nameInject // refAttrs // secretAttrs)) + wrapBlocks "" (cleanNulls (base // nameInject // refAttrs // secretAttrs)) ) ); From 2f3665095f0cf3cbfe005609d6bb418ff7f178b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 23 Jun 2026 23:34:44 +0200 Subject: [PATCH 27/50] feat(services/keycloak): add realm_user_profile Per-realm user-profile schema with typed `attribute` and `group` lists. Each attribute carries the deep-nested `permissions` MaxItems:1 block (view + edit roles) and a `validator` list of `{ name; config; }` blocks. `blockAttrs = [ "attribute.permissions" ]` exercises the recursive wrapBlocks (matching inside the list element). VM test declares a user-profile with five attributes (the four built-ins + a custom `team`), asserts via /admin/realms//users/profile that the custom attribute has the declared view/edit permissions and that the username attribute has the length validator. Keycloak refuses to drop the built-ins on PUT, so any real fixture must include them. --- services/keycloak/checks.nix | 114 +++++++++++++++++++++++++++++++++++ services/keycloak/lib.nix | 74 +++++++++++++++++++---- 2 files changed, 177 insertions(+), 11 deletions(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index b60023b..1a8fd87 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -248,6 +248,101 @@ in loginAccountTitle = "ACME"; }; }; + + # realm_user_profile exercises a MaxItems:1 nested block inside a + # list element (attribute[].permissions); proves wrapBlocks + # recurses into list elements. + realm_user_profiles.acme = { + realm = "acme"; + unmanaged_attribute_policy = "ENABLED"; + # Keycloak refuses to drop the built-in attrs (username, + # email, firstName, lastName); declare them alongside the + # custom one. display_name uses Keycloak's `${i18n.key}` + # syntax which collides with Terraform interpolation, so we + # leave those off here. + attribute = [ + { + name = "username"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + validator = [ + { + name = "length"; + config = { + min = "3"; + max = "255"; + }; + } + ]; + } + { + name = "email"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "firstName"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "lastName"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "team"; + display_name = "Team"; + group = "metadata"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ "admin" ]; + }; + } + ]; + group = [ + { + name = "metadata"; + display_header = "Metadata"; + display_description = "ACME-internal user metadata"; + } + ]; + }; }; }; @@ -391,6 +486,25 @@ in assert totp, f"CONFIGURE_TOTP not found: {[r.get('alias') for r in ras]}" assert totp.get("enabled") is False, f"CONFIGURE_TOTP should be disabled: {totp}" + with subtest("realm_user_profile attribute[].permissions block wrap reaches the API"): + tok = admin_token() + up = json.loads(machine.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + "http://localhost:8080/admin/realms/acme/users/profile" + )) + attrs = {a["name"]: a for a in up.get("attributes", [])} + assert "team" in attrs, f"team attribute missing: {list(attrs)}" + team_perms = attrs["team"].get("permissions", {}) + assert set(team_perms.get("view", [])) == {"admin", "user"}, \ + f"team view perms: {team_perms}" + assert set(team_perms.get("edit", [])) == {"admin"}, \ + f"team edit perms: {team_perms}" + username_validators = attrs["username"].get("validations", {}) + assert "length" in username_validators, \ + f"length validator missing on username: {username_validators}" + assert up.get("unmanagedAttributePolicy") == "ENABLED", \ + f"unmanaged_attribute_policy: {up}" + with subtest("realm localization message reaches the API"): tok = admin_token() texts = json.loads(machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index d60e061..a9ec42e 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -637,11 +637,14 @@ let temporary = oBool "Force the user to change the password on first login."; } "Initial password set at user creation."; - federated_identity = oListSub { - identity_provider = rStr "Alias of the federating IdP."; - user_id = rStr "User id on the IdP side."; - user_name = rStr "Username on the IdP side."; - } "Federated-identity links pre-bound to the user; each block is `{ identity_provider; user_id; user_name; }`."; + federated_identity = + oListSub + { + identity_provider = rStr "Alias of the federating IdP."; + user_id = rStr "User id on the IdP side."; + user_name = rStr "Username on the IdP side."; + } + "Federated-identity links pre-bound to the user; each block is `{ identity_provider; user_id; user_name; }`."; }; }; @@ -807,12 +810,15 @@ let always_display_in_console = oBool "Always display the client in the user account console."; extra_config = oAttrsStr "Free-form extra config entries the upstream attribute set does not cover."; - authorization = oSub { - policy_enforcement_mode = rStr "Policy enforcement mode ('ENFORCING', 'PERMISSIVE', or 'DISABLED')."; - decision_strategy = oStr "Decision strategy when multiple policies apply (default 'UNANIMOUS')."; - allow_remote_resource_management = oBool "Allow resource management via the protection API."; - keep_defaults = oBool "Keep default resources / scopes / permissions Keycloak creates."; - } "Enables fine-grained authorization on the client (resource server). Required for openid_client_authorization_* resources."; + authorization = + oSub + { + policy_enforcement_mode = rStr "Policy enforcement mode ('ENFORCING', 'PERMISSIVE', or 'DISABLED')."; + decision_strategy = oStr "Decision strategy when multiple policies apply (default 'UNANIMOUS')."; + allow_remote_resource_management = oBool "Allow resource management via the protection API."; + keep_defaults = oBool "Keep default resources / scopes / permissions Keycloak creates."; + } + "Enables fine-grained authorization on the client (resource server). Required for openid_client_authorization_* resources."; authentication_flow_binding_overrides = oSub { browser_id = oStr "Authentication flow id overriding the realm's browser flow for this client."; @@ -2800,6 +2806,52 @@ let }; }; + realm_user_profiles = { + type = "keycloak_realm_user_profile"; + prefix = "realm_user_profile"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + # `attribute[].permissions` is a MaxItems:1 nested block inside a list + # element; the recursive wrapBlocks walks into list elements, so the + # dotted path picks it up. + blockAttrs = [ "attribute.permissions" ]; + description = "Per-realm user-profile schema (attribute declarations + groups). Keyed by an arbitrary label (one resource per realm)."; + attrs = { + unmanaged_attribute_policy = oStr "Policy for unmanaged attributes: 'DISABLED' (default), 'ENABLED', 'ADMIN_VIEW', or 'ADMIN_EDIT'."; + attribute = oListSub { + name = rStr "Attribute name."; + display_name = oStr "Display name (may be an i18n key)."; + multi_valued = oBool "Allow multiple values."; + group = oStr "Display group the attribute belongs to."; + enabled_when_scope = oListStr "Scopes that make the attribute available."; + required_for_roles = oListStr "Roles for which the attribute is required."; + required_for_scopes = oListStr "Scopes for which the attribute is required."; + permissions = oSub { + view = lib.mkOption { + type = ty.listOf ty.str; + description = "Roles that can view the attribute (e.g. \"admin\", \"user\")."; + }; + edit = lib.mkOption { + type = ty.listOf ty.str; + description = "Roles that can edit the attribute."; + }; + } "View / edit permissions for the attribute."; + validator = oListSub { + name = rStr "Validator id (e.g. \"length\", \"pattern\")."; + config = oAttrsStr "Validator-specific configuration."; + } "Validators applied to the attribute."; + annotations = oAttrsStr "Free-form display annotations."; + } "List of user-profile attribute declarations."; + group = oListSub { + name = rStr "Group name."; + display_header = oStr "Display header."; + display_description = oStr "Display description."; + annotations = oAttrsStr "Free-form display annotations."; + } "List of user-profile groups (used to cluster attributes in the UI)."; + }; + }; + realm_client_policy_profiles = { type = "keycloak_realm_client_policy_profile"; prefix = "realm_client_policy_profile"; From 507ffb96fe7f312981f99d6ca6c9cda3ff871c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 09:20:20 +0200 Subject: [PATCH 28/50] refactor(services/keycloak): nested-secret File via recursive walk Renderer: - Replace itemSecrets (top-level only) with substituteSecrets, a recursive walk over the cleaned value tree. For every `File = "/path"` at any depth, swap in ` = "${var.}"`, collect an (id, file) entry, and use a dotted-path id so nested secrets get unique names (e.g. `secret_realm_acme_smtp_server_auth_password`). Throws if both the literal and the *File sibling are set on the same object. - renderItem now returns { label; value; secrets } so secrets flow up alongside the JSON values; downstream allSecrets / credentials / variable block all key off the same path-aware ids. - Top-level File flows through the same walk and keeps the same var ids (a top-level path is just ), so existing fixtures aren't affected. Option declarations: - realm: smtp_server.auth.{password,passwordFile} and smtp_server.token_auth.{client_secret,client_secretFile} drop from required-literal to optional-with-File-alternative. - users: initial_password.{value,valueFile} get the same treatment. VM test adds smtp_server.auth.passwordFile and asserts the literal ("verysecretpassword") is absent from main.tf.json while the substituted var reference (`secret_realm_acme_smtp_server_auth_password`) is present. --- services/keycloak/checks.nix | 18 +++- services/keycloak/lib.nix | 163 ++++++++++++++++++++++++++--------- 2 files changed, 138 insertions(+), 43 deletions(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 1a8fd87..e8eb81b 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -24,6 +24,7 @@ in environment.etc."keycloak-db-password".text = "hackme"; environment.etc."keycloak-admin-password".text = keycloakAdminPassword; environment.etc."acme-google-secret".text = "fakesecret"; + environment.etc."acme-smtp-password".text = "verysecretpassword"; services.keycloak = { enable = true; @@ -62,14 +63,19 @@ in ]; default_locale = "en"; }; - # smtp_server is a nested block too; flat fields only here - # (no auth -- the nested-Sensitive auth.password / token_auth. - # client_secret aren't yet protected by File). + # smtp_server with nested auth credentials: passwordFile is a + # host path read via LoadCredential=, never copied into the + # generated .tf.json (substituteSecrets walks the value tree + # and substitutes `${var.}` at the nested location). smtp_server = { host = "smtp.example.com"; from = "noreply@example.com"; port = "25"; from_display_name = "ACME"; + auth = { + username = "noreply"; + passwordFile = "/etc/acme-smtp-password"; + }; }; # exercises the nested-in-nested block-list wrapping (the # security_defenses outer block and its inner headers / @@ -631,6 +637,12 @@ in ).strip() assert client_secret, "bootstrap did not write client_secret" assert client_secret not in tfjson, "client_secret leaked into generated .tf.json" + # nested-secret indirection: smtp_server.auth.passwordFile must + # leave the literal out of the generated config. + assert "verysecretpassword" not in tfjson, \ + "smtp_server.auth.password leaked into generated .tf.json" + assert "secret_realm_acme_smtp_server_auth_password" in tfjson, \ + "expected nested-secret var reference in generated .tf.json" with subtest("tfstate file is not empty"): machine.succeed( diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index a9ec42e..0fb3e20 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -383,9 +383,11 @@ let default_optional_client_scopes = oListStr "Optional client scopes available to new clients."; # nested blocks: rendered as [{ ... }] via the `blockAttrs` markup. - # Nested-sensitive fields (smtp_server.auth.password, - # smtp_server.token_auth.client_secret) don't yet have File - # support; supplying a literal lands in the world-readable store. + # Nested-Sensitive fields (smtp_server.auth.password, + # smtp_server.token_auth.client_secret) can be supplied either as + # a literal (lands in the world-readable store) or via the matching + # `File` sibling (host path resolved at apply time through + # systemd LoadCredential=, never copied to the store). smtp_server = oSub { host = rStr "SMTP host."; from = rStr "From address."; @@ -399,13 +401,15 @@ let envelope_from = oStr "Envelope From address."; auth = oSub { username = rStr "SMTP auth username."; - password = rStr "SMTP auth password (LITERAL -- nested File support not yet implemented)."; + password = oStr "SMTP auth password. Prefer `passwordFile`."; + passwordFile = oStr "Runtime path to a file holding `password` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `password`."; } "SMTP basic-auth credentials (mutually exclusive with token_auth)."; token_auth = oSub { username = rStr "OAuth2 token-auth username."; url = rStr "OAuth2 token endpoint."; client_id = rStr "OAuth2 client_id."; - client_secret = rStr "OAuth2 client_secret (LITERAL -- nested File support not yet implemented)."; + client_secret = oStr "OAuth2 client_secret. Prefer `client_secretFile`."; + client_secretFile = oStr "Runtime path to a file holding `client_secret` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `client_secret`."; scope = rStr "OAuth2 scope."; } "SMTP OAuth2 token credentials (mutually exclusive with auth)."; } "SMTP server configuration."; @@ -633,7 +637,8 @@ let required_actions = oListStr "Required actions on next login (e.g. \"VERIFY_EMAIL\", \"UPDATE_PASSWORD\")."; initial_password = oSub { - value = rStr "Initial password (LITERAL -- nested File support not yet implemented)."; + value = oStr "Initial password literal. Prefer `valueFile`."; + valueFile = oStr "Runtime path to a file holding `value` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `value`."; temporary = oBool "Force the user to change the password on first login."; } "Initial password set at user creation."; @@ -3103,34 +3108,105 @@ let else val; - # Host-file-sourced secrets of one item: [{ attr; id; path; }]. Throws if - # both the literal attribute and its `File` are set. - itemSecrets = - c: spec: key: item: - lib.concatMap ( - attr: - let - file = item.${attr + "File"} or null; - in - lib.optionals (file != null) ( - if (item.${attr} or null) != null then - throw "services.keycloak.runtime.${c}.${key}: set either '${attr}' or '${attr}File', not both" - else - [ + # Walk a (cleaned) value tree, replacing every `File = "/path"` + # with ` = "${var.}"` and collecting `[{ id; file; }]` + # entries. Works at any depth -- top-level attrs, nested submodules + # and inside list elements. Throws when both `` and `File` + # are set on the same object. The id uses a dotted-path-encoded + # suffix so nested secrets (e.g. smtp_server.auth.password) get a + # unique var name (`secret_realm_acme_smtp_server_auth_password`). + substituteSecrets = + spec: key: + let + mkId = pathParts: secretId spec key (varSafe (lib.concatStringsSep "_" pathParts)); + go = + pathParts: v: + if builtins.isAttrs v then + let + fileKeys = builtins.filter (k: lib.hasSuffix "File" k) (builtins.attrNames v); + fileEntries = map ( + k: + let + attr = lib.removeSuffix "File" k; + id = mkId (pathParts ++ [ attr ]); + in + { + inherit attr id; + file = v.${k}; + bareConflict = v ? ${attr}; + } + ) fileKeys; + conflict = builtins.filter (e: e.bareConflict) fileEntries; + fileMap = lib.listToAttrs ( + map ( + e: + lib.nameValuePair e.attr { + inherit (e) id; + ref = "\${var.${e.id}}"; + } + ) fileEntries + ); + # Walk each existing key: drop *File entries; for bare attrs + # in fileMap, replace with ${var.}; otherwise recurse. + processed = lib.concatMapAttrs ( + k: x: + if lib.hasSuffix "File" k then + { } + else if fileMap ? ${k} then + { ${k} = fileMap.${k}.ref; } + else + { ${k} = (go (pathParts ++ [ k ]) x).value; } + ) v; + # Synthesize bare attrs from fileMap that aren't present + # in v (i.e. user only supplied File, no literal). + synthesized = lib.listToAttrs ( + map (a: lib.nameValuePair a fileMap.${a}.ref) ( + builtins.filter (a: !(v ? ${a})) (builtins.attrNames fileMap) + ) + ); + localSecrets = map (e: { + inherit (e) id file; + }) fileEntries; + childSecrets = lib.concatLists ( + lib.mapAttrsToList ( + k: x: if lib.hasSuffix "File" k || fileMap ? ${k} then [ ] else (go (pathParts ++ [ k ]) x).secrets + ) v + ); + in + if conflict != [ ] then + throw "services.keycloak.runtime.${spec.prefix}.${key}: set either '${ + lib.concatStringsSep "." (pathParts ++ [ (builtins.head conflict).attr ]) + }' or '${ + lib.concatStringsSep "." (pathParts ++ [ ((builtins.head conflict).attr + "File") ]) + }', not both" + else { - inherit attr; - id = secretId spec key attr; - path = file; + value = processed // synthesized; + secrets = localSecrets ++ childSecrets; } - ] - ) - ) (spec.secrets or [ ]); + else if builtins.isList v then + let + mapped = map (e: go pathParts e) v; + in + { + value = map (m: m.value) mapped; + secrets = lib.concatLists (map (m: m.secrets) mapped); + } + else + { + value = v; + secrets = [ ]; + }; + in + go [ ]; renderItem = c: spec: key: item: let - secretEntries = itemSecrets c spec key item; - virtuals = builtins.attrNames spec.refs ++ map (s: "${s}File") (spec.secrets or [ ]); + # Drop ref virtuals (their values get re-injected via refAttrs). + # *File siblings are NOT dropped here -- substituteSecrets handles + # them via the value-tree walk after cleanNulls. + virtuals = builtins.attrNames spec.refs; base = removeAttrs item ([ "_module" ] ++ virtuals); nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { ${spec.nameAttr} = key; @@ -3141,7 +3217,6 @@ let ${refSpec.attr} = resolveRef refSpec item.${refName}; } ) spec.refs; - secretAttrs = lib.listToAttrs (map (e: lib.nameValuePair e.attr "\${var.${e.id}}") secretEntries); # A required secret must be supplied via either the literal or its file. reqSecretChecks = map ( attr: @@ -3188,25 +3263,33 @@ let map (wrapBlocks path) v else v; + cleaned = cleanNulls (base // nameInject // refAttrs); + substituted = substituteSecrets spec key cleaned; + wrapped = wrapBlocks "" substituted.value; in # use deepSeq to force evaluation of checks # (these are not config.assertions so they can be used outside a nixos system build) - lib.nameValuePair (tfLabel spec.prefix key) ( - builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( - wrapBlocks "" (cleanNulls (base // nameInject // refAttrs // secretAttrs)) - ) - ); + builtins.deepSeq [ reqSecretChecks reqAttrChecks ] { + label = tfLabel spec.prefix key; + value = wrapped; + inherit (substituted) secrets; + }; nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; + # Per-collection: [ { label; value; secrets } ... ] for each managed item. + renderedPerCollection = lib.mapAttrs ( + c: items: lib.mapAttrsToList (key: item: renderItem c resourceTypes.${c} key item) items + ) (lib.intersectAttrs nonEmpty cfg); resourceBlocks = lib.mapAttrs' ( - c: spec: lib.nameValuePair spec.type (lib.mapAttrs' (renderItem c spec) cfg.${c}) - ) nonEmpty; + c: items: + lib.nameValuePair resourceTypes.${c}.type ( + lib.listToAttrs (map (r: lib.nameValuePair r.label r.value) items) + ) + ) renderedPerCollection; # combine sensitive variables with (id -> host path) credential map allSecrets = lib.concatLists ( - lib.mapAttrsToList ( - c: spec: lib.concatLists (lib.mapAttrsToList (key: item: itemSecrets c spec key item) cfg.${c}) - ) nonEmpty + lib.concatLists (lib.mapAttrsToList (_: items: map (r: r.secrets) items) renderedPerCollection) ); secretIds = map (e: e.id) allSecrets; @@ -3247,7 +3330,7 @@ let if lib.length secretIds != lib.length (lib.unique secretIds) then throw "services.keycloak.runtime: secret credential id collision (${toString secretIds}); rename the colliding resource keys" else - lib.listToAttrs (map (e: lib.nameValuePair e.id e.path) allSecrets); + lib.listToAttrs (map (e: lib.nameValuePair e.id e.file) allSecrets); in { inherit config credentials; From 14614e1e5eedc3f27561bf1b25ffdb4b6dda6304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 09:24:49 +0200 Subject: [PATCH 29/50] refactor(services/keycloak): list-of-managed-refs for 13 binding attrs Refs spec gains an optional `list = true` flag. When set: - resourceOptions declares the option as `listOf str`. - renderItem maps resolveRef over each element, so each entry is either resolved to `${type.label.field}` for managed keys or passed through as a literal. Migrated 13 list-of-refs cases (formerly opaque oListStr attrs where users had to write `${keycloak_role.X.id}` interpolations by hand): - roles.composite_roles -> roles[].id - default_roles.default_roles -> roles[].name - default_groups.group_ids -> groups[].id - group_memberships.members -> users[].username - group_roles.role_ids -> roles[].id - user_roles.role_ids -> roles[].id - user_groups.group_ids -> groups[].id - openid_client_default_scopes.default_scopes -> openid_client_scopes[].name - openid_client_optional_scopes.optional_scopes -> openid_client_scopes[].name - saml_client_default_scopes.default_scopes -> saml_client_scopes[].name - realm_default_client_scopes.default_scopes -> openid_client_scopes[].name | saml_client_scopes[].name - realm_optional_client_scopes.optional_scopes -> same multi-target - realm_client_policy_profile_policies.profiles -> realm_client_policy_profiles[].name All migrated refs are `managedOnly = false`, so the renderer falls back to the literal when no managed sibling matches -- built-in role names like "offline_access" and built-in scope names like "profile" just work. VM fixtures drop their inline `${keycloak_role.X.id}` / `${keycloak_group.X.id}` interpolations in favour of managed keys. The generated .tf.json is byte-identical; the change is purely ergonomic. --- services/keycloak/checks.nix | 7 +- services/keycloak/lib.nix | 270 +++++++++++++++++++++++++++-------- 2 files changed, 217 insertions(+), 60 deletions(-) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index e8eb81b..27a697b 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -129,8 +129,7 @@ in group_roles.acme_eng_admins = { realm = "acme"; group = "acme_eng"; - # raw role id reference: managed list-refs not yet supported. - role_ids = [ "\${keycloak_role.role_acme_engineer.id}" ]; + role_ids = [ "acme_engineer" ]; # managed role key, resolved to .id exhaustive = true; }; @@ -148,13 +147,13 @@ in user_roles.acme_alice = { realm = "acme"; user = "acme_alice"; - role_ids = [ "\${keycloak_role.role_acme_engineer.id}" ]; + role_ids = [ "acme_engineer" ]; # managed role key exhaustive = false; }; user_groups.acme_alice = { realm = "acme"; user = "acme_alice"; - group_ids = [ "\${keycloak_group.group_acme_eng.id}" ]; + group_ids = [ "acme_eng" ]; # managed group key exhaustive = false; }; diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 0fb3e20..98d0e5e 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -489,15 +489,26 @@ let prefix = "role"; nameAttr = "name"; scope = null; - refs.realm = realmRef; - # client_id ref (-> keycloak_openid_client) lands when openid_clients do. + refs = { + realm = realmRef; + composite_roles = { + attr = "composite_roles"; + targets = [ + { + collection = "roles"; + field = "id"; + } + ]; + managedOnly = false; + required = false; + list = true; + description = "Roles composited into this role. Each entry is a managed role key (resolved to its id) or a literal role UUID."; + }; + }; description = "Keycloak roles (realm-level by default), keyed by role name."; attrs = { name = oStr "Role name. Defaults to the attribute key."; description = oStr "Role description."; - # opaque list: users supply role UUIDs or ${keycloak_role.X.id} - # interpolations directly. Managed list-refs land later. - composite_roles = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) the composite includes."; attributes = oAttrsStr "Free-form role attribute map."; }; }; @@ -507,12 +518,24 @@ let prefix = "default_roles"; nameAttr = null; scope = null; - refs.realm = realmRef; - requiredAttrs = [ "default_roles" ]; - description = "Realm-level default roles auto-granted to new users, keyed by an arbitrary label."; - attrs = { - default_roles = oListStr "Role names auto-granted to every new user of the realm."; + refs = { + realm = realmRef; + default_roles = { + attr = "default_roles"; + targets = [ + { + collection = "roles"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Role names auto-granted to every new user. Each entry is a managed role key (resolved to its name) or a literal role name (built-ins like 'offline_access' work as literals)."; + }; }; + description = "Realm-level default roles auto-granted to new users, keyed by an arbitrary label."; + attrs = { }; }; groups = { @@ -548,14 +571,24 @@ let prefix = "default_groups"; nameAttr = null; scope = null; - refs.realm = realmRef; - requiredAttrs = [ "group_ids" ]; - description = "Realm-level default groups auto-joined by new users, keyed by an arbitrary label."; - attrs = { - # opaque list: users supply group UUIDs or ${keycloak_group.X.id} - # interpolations directly. Managed list-refs land later. - group_ids = oListStr "Group UUIDs (or `\${keycloak_group.X.id}` refs) new users auto-join."; + refs = { + realm = realmRef; + group_ids = { + attr = "group_ids"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Groups new users auto-join. Each entry is a managed group key (resolved to its id) or a literal group UUID."; + }; }; + description = "Realm-level default groups auto-joined by new users, keyed by an arbitrary label."; + attrs = { }; }; group_memberships = { @@ -577,12 +610,22 @@ let required = true; description = "Key of the managed group (services.keycloak.runtime.groups.) the members are added to."; }; + members = { + attr = "members"; + targets = [ + { + collection = "users"; + field = "username"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Users to add to the group. Each entry is a managed user key (resolved to its username) or a literal username."; + }; }; - requiredAttrs = [ "members" ]; description = "Keycloak group memberships, keyed by an arbitrary label."; - attrs = { - members = oListStr "Usernames of users to add to the group."; - }; + attrs = { }; }; group_roles = { @@ -604,11 +647,22 @@ let required = true; description = "Key of the managed group (services.keycloak.runtime.groups.) to assign roles to."; }; + role_ids = { + attr = "role_ids"; + targets = [ + { + collection = "roles"; + field = "id"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Roles granted to the group. Each entry is a managed role key (resolved to its id) or a literal role UUID."; + }; }; - requiredAttrs = [ "role_ids" ]; description = "Role assignments for a group, keyed by an arbitrary label."; attrs = { - role_ids = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) granted to the group."; exhaustive = oBool "If true, only the listed roles remain assigned; if false, listed roles are added without removing others."; }; }; @@ -672,11 +726,22 @@ let required = true; description = "Key of the managed user (services.keycloak.runtime.users.) to assign roles to."; }; + role_ids = { + attr = "role_ids"; + targets = [ + { + collection = "roles"; + field = "id"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Roles granted to the user. Each entry is a managed role key (resolved to its id) or a literal role UUID."; + }; }; - requiredAttrs = [ "role_ids" ]; description = "Role assignments for a user, keyed by an arbitrary label."; attrs = { - role_ids = oListStr "Role UUIDs (or `\${keycloak_role.X.id}` refs) granted to the user."; exhaustive = oBool "If true, only the listed roles remain assigned; otherwise the listed roles are added without removing others."; }; }; @@ -700,11 +765,22 @@ let required = true; description = "Key of the managed user (services.keycloak.runtime.users.) to add to groups."; }; + group_ids = { + attr = "group_ids"; + targets = [ + { + collection = "groups"; + field = "id"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Groups the user joins. Each entry is a managed group key (resolved to its id) or a literal group UUID."; + }; }; - requiredAttrs = [ "group_ids" ]; description = "Group memberships for a user, keyed by an arbitrary label."; attrs = { - group_ids = oListStr "Group UUIDs (or `\${keycloak_group.X.id}` refs) the user joins."; exhaustive = oBool "If true, only the listed groups remain joined; otherwise the listed groups are added without removing others."; }; }; @@ -851,12 +927,22 @@ let required = true; description = "Key of the managed OpenID client (services.keycloak.runtime.openid_clients.) the scope binding applies to."; }; + default_scopes = { + attr = "default_scopes"; + targets = [ + { + collection = "openid_client_scopes"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Scopes attached by default. Each entry is a managed openid_client_scope key (resolved to its name) or a literal scope name (built-ins like 'profile' / 'email' work as literals)."; + }; }; - requiredAttrs = [ "default_scopes" ]; description = "Default OAuth2 scopes auto-attached to a client, keyed by an arbitrary label."; - attrs = { - default_scopes = oListStr "Names of scopes attached by default."; - }; + attrs = { }; }; openid_client_optional_scopes = { @@ -878,12 +964,22 @@ let required = true; description = "Key of the managed OpenID client (services.keycloak.runtime.openid_clients.) the scope binding applies to."; }; + optional_scopes = { + attr = "optional_scopes"; + targets = [ + { + collection = "openid_client_scopes"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Optionally-attached scopes. Each entry is a managed openid_client_scope key (resolved to its name) or a literal scope name."; + }; }; - requiredAttrs = [ "optional_scopes" ]; description = "Optional OAuth2 scopes available to a client, keyed by an arbitrary label."; - attrs = { - optional_scopes = oListStr "Names of optionally-attached scopes."; - }; + attrs = { }; }; openid_client_service_account_roles = { @@ -1018,12 +1114,22 @@ let required = true; description = "Key of the managed SAML client (services.keycloak.runtime.saml_clients.) the scope binding applies to."; }; + default_scopes = { + attr = "default_scopes"; + targets = [ + { + collection = "saml_client_scopes"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "SAML scopes attached by default. Each entry is a managed saml_client_scope key (resolved to its name) or a literal scope name."; + }; }; - requiredAttrs = [ "default_scopes" ]; description = "Default SAML scopes auto-attached to a SAML client, keyed by an arbitrary label."; - attrs = { - default_scopes = oListStr "Names of SAML scopes attached by default."; - }; + attrs = { }; }; # OpenID protocol mappers: each is its own resource type, keyed by the @@ -2748,12 +2854,28 @@ let prefix = "realm_default_client_scopes"; nameAttr = null; scope = null; - refs.realm = realmRef; - requiredAttrs = [ "default_scopes" ]; - description = "Realm-wide default client-scope binding (set of scope names), keyed by an arbitrary label. Distinct from realms..default_default_client_scopes, which is a free-form realm attribute."; - attrs = { - default_scopes = oListStr "Names of scopes auto-attached as default to every new client."; + refs = { + realm = realmRef; + default_scopes = { + attr = "default_scopes"; + targets = [ + { + collection = "openid_client_scopes"; + field = "name"; + } + { + collection = "saml_client_scopes"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Scope names auto-attached as default to every new client. Each entry is a managed openid/saml client_scope key (resolved to its name) or a literal scope name."; + }; }; + description = "Realm-wide default client-scope binding (set of scope names), keyed by an arbitrary label. Distinct from realms..default_default_client_scopes, which is a free-form realm attribute."; + attrs = { }; }; realm_optional_client_scopes = { @@ -2761,12 +2883,28 @@ let prefix = "realm_optional_client_scopes"; nameAttr = null; scope = null; - refs.realm = realmRef; - requiredAttrs = [ "optional_scopes" ]; - description = "Realm-wide optional client-scope binding (set of scope names), keyed by an arbitrary label."; - attrs = { - optional_scopes = oListStr "Names of scopes available as optional to every new client."; + refs = { + realm = realmRef; + optional_scopes = { + attr = "optional_scopes"; + targets = [ + { + collection = "openid_client_scopes"; + field = "name"; + } + { + collection = "saml_client_scopes"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Scope names available as optional to every new client. Each entry is a managed openid/saml client_scope key (resolved to its name) or a literal scope name."; + }; }; + description = "Realm-wide optional client-scope binding (set of scope names), keyed by an arbitrary label."; + attrs = { }; }; organizations = { @@ -2879,8 +3017,22 @@ let prefix = "realm_client_policy_profile_policy"; nameAttr = "name"; scope = null; - refs.realm = realmRef; - requiredAttrs = [ "profiles" ]; + refs = { + realm = realmRef; + profiles = { + attr = "profiles"; + targets = [ + { + collection = "realm_client_policy_profiles"; + field = "name"; + } + ]; + managedOnly = false; + required = true; + list = true; + description = "Names of client-policy profiles this policy applies. Each entry is a managed realm_client_policy_profile key (resolved to its name) or a literal profile name."; + }; + }; description = "Realm client-policy policy binding a set of profiles to a set of conditions."; attrs = { name = oStr "Policy name. Defaults to the attribute key."; @@ -2890,7 +3042,6 @@ let name = rStr "Condition provider-id (e.g. 'client-roles')."; configuration = oAttrsStr "Condition-specific configuration."; } "List of conditions; the policy applies when all conditions match."; - profiles = oListStr "Names of client-policy profiles this policy applies."; }; }; @@ -3033,14 +3184,17 @@ let (spec.attrs or { }) // lib.mapAttrs ( _: refSpec: + let + base = if refSpec.list or false then lib.types.listOf lib.types.str else lib.types.str; + in if refSpec.required or false then lib.mkOption { - type = lib.types.str; + type = base; description = refSpec.description; } else lib.mkOption { - type = lib.types.nullOr lib.types.str; + type = lib.types.nullOr base; default = null; description = refSpec.description; } @@ -3213,8 +3367,12 @@ let }; refAttrs = lib.concatMapAttrs ( refName: refSpec: - lib.optionalAttrs (item.${refName} or null != null) { - ${refSpec.attr} = resolveRef refSpec item.${refName}; + let + v = item.${refName} or null; + in + lib.optionalAttrs (v != null) { + ${refSpec.attr} = + if refSpec.list or false then map (resolveRef refSpec) v else resolveRef refSpec v; } ) spec.refs; # A required secret must be supplied via either the literal or its file. From aef8e16c5b5ce72dce8a0b58514dad590d252973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 09:27:18 +0200 Subject: [PATCH 30/50] docs(services/keycloak): refresh README for the full ~95 resource surface - Resources section groups every typed collection by family (realms / roles+groups+users / clients+scopes / mappers / IdPs / auth / authorization / federation / keystores / realm-level config) with the `keycloak_*` type and reference inputs. - Adds a worked example exercising managed-key list refs (default_roles, group_roles.role_ids), parent refs (realms, openid_clients, identity providers, IdP mappers), and nested-secret indirection at multiple depths (smtp_server.auth.passwordFile, users..initial_password.valueFile, openid_clients.. client_secretFile). - Security note expanded to enumerate every secret attribute that has File support (top-level and nested). --- services/keycloak/README.md | 315 ++++++++++++++++++++++++++++++++++-- 1 file changed, 299 insertions(+), 16 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index 3eefc69..1e1cf8e 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -121,6 +121,89 @@ does not create or modify the client and reads them directly. } ``` +### Realm with users, groups, an OIDC client, and an IdP + +A flavoured example exercising managed-key list refs, parent refs, and +nested-secret indirection at once. + +```nix +{ + services.keycloak = { + enable = true; + initialAdminPassword = "REPLACE_ME"; + settings.hostname = "sso.example.com"; + database.passwordFile = "/run/secrets/keycloak-db-password"; + + runtime = { + enable = true; + bootstrapAdminPasswordFile = "/run/secrets/keycloak-admin-password"; + + realms.staff = { + display_name = "Staff SSO"; + smtp_server = { + host = "smtp.example.com"; + from = "noreply@example.com"; + auth = { + username = "noreply"; + passwordFile = "/run/secrets/staff-smtp-password"; # nested File + }; + }; + }; + + roles.engineer = { realm = "staff"; description = "Engineering"; }; + groups.eng = { realm = "staff"; name = "engineering"; }; + + # managed list-refs: scope/role names resolve to managed siblings + # (built-in names like "offline_access" pass through as literals). + default_roles.staff = { + realm = "staff"; + default_roles = [ "engineer" "offline_access" "uma_authorization" ]; + }; + group_roles.eng = { + realm = "staff"; + group = "eng"; + role_ids = [ "engineer" ]; # managed key, resolved to id + }; + + users.alice = { + realm = "staff"; + username = "alice"; + email = "alice@example.com"; + # nested initial-password File: + initial_password = { + valueFile = "/run/secrets/staff-alice-initial-password"; + temporary = true; + }; + }; + + openid_clients.app = { + realm = "staff"; + client_id = "app"; + access_type = "CONFIDENTIAL"; + client_secretFile = "/run/secrets/staff-app-client-secret"; + valid_redirect_uris = [ "https://app.example.com/*" ]; + web_origins = [ "https://app.example.com" ]; + standard_flow_enabled = true; + }; + + oidc_google_identity_providers.staff_google = { + realm = "staff"; + client_id = "google-client-id"; + client_secretFile = "/run/secrets/staff-google-secret"; + }; + + attribute_importer_identity_provider_mappers.staff_google_email = { + realm = "staff"; + identity_provider = "staff_google"; # managed IdP key + name = "google-email"; + user_attribute = "email"; + claim_name = "email"; + }; + }; + }; +} +``` + ## Module options (`services.keycloak.runtime`) | Option | Type | Default | Purpose | @@ -136,15 +219,201 @@ Plus one collection option per provider resource (next section). ## Resources -Every [`keycloak/keycloak`][provider] resource is exposed as a collection -keyed by an arbitrary handle. First-wave coverage: - -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| -------- | --------------------- | ------------ | ---------------- | -| `realms` | `realm` | `realm` | — | - -Subsequent waves add clients, scopes, roles, groups, users, identity -providers, and protocol mappers. +Every [`keycloak/keycloak`][provider] resource is exposed as a typed +collection under `services.keycloak.runtime..`. Where a +parent reference is a managed sibling, supply the sibling's key and the +generator emits the right Terraform interpolation in apply order; where +a reference accepts either a managed key or a literal name/id (the +`managedOnly = false` form), built-in / external values just pass +through. + +### Realms + +| Option | `keycloak_*` resource | Key defaults | +| -------- | --------------------- | ------------ | +| `realms` | `realm` | `realm` | + +The `realms` collection covers ~50 flat realm attributes and the nested +blocks `smtp_server`, `internationalization`, `security_defenses`, +`otp_policy`, `web_authn_policy`, `web_authn_passwordless_policy`. + +### Roles, groups, users + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ------------------- | --------------------- | ------------ | ----------------------------------------------------------- | +| `roles` | `role` | `name` | `realm`, `composite_roles` → roles | +| `default_roles` | `default_roles` | — | `realm`, `default_roles` → roles | +| `groups` | `group` | `name` | `realm`, `parent` → groups | +| `default_groups` | `default_groups` | — | `realm`, `group_ids` → groups | +| `group_memberships` | `group_memberships` | — | `realm`, `group` → groups, `members` → users | +| `group_roles` | `group_roles` | — | `realm`, `group` → groups, `role_ids` → roles | +| `users` | `user` | `username` | `realm` | +| `user_roles` | `user_roles` | — | `realm`, `user` → users, `role_ids` → roles | +| `user_groups` | `user_groups` | — | `realm`, `user` → users, `group_ids` → groups | + +### Clients, scopes, service accounts + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| -------------------------------------------- | ---------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `openid_clients` | `openid_client` | `client_id` | `realm` | +| `openid_client_scopes` | `openid_client_scope` | `name` | `realm` | +| `openid_client_default_scopes` | `openid_client_default_scopes` | — | `realm`, `client` → openid_clients, `default_scopes` → openid_client_scopes | +| `openid_client_optional_scopes` | `openid_client_optional_scopes` | — | `realm`, `client` → openid_clients, `optional_scopes` → openid_client_scopes | +| `openid_client_service_account_roles` | `openid_client_service_account_role` | — | `realm`, `client` → openid_clients (target) | +| `openid_client_service_account_realm_roles` | `openid_client_service_account_realm_role` | — | `realm` | +| `openid_client_permissions` | `openid_client_permissions` | — | `realm`, `client` → openid_clients | +| `saml_clients` | `saml_client` | `client_id` | `realm` | +| `saml_client_scopes` | `saml_client_scope` | `name` | `realm` | +| `saml_client_default_scopes` | `saml_client_default_scopes` | — | `realm`, `client` → saml_clients, `default_scopes` → saml_client_scopes | + +### Protocol mappers (OpenID, SAML, generic) + +OpenID mappers (`realm` + optional `client` → openid_clients + +optional `client_scope` → openid_client_scopes): + +| Option | `keycloak_*` resource | +| ----------------------------------------------- | ---------------------------------------------- | +| `openid_user_attribute_protocol_mappers` | `openid_user_attribute_protocol_mapper` | +| `openid_user_property_protocol_mappers` | `openid_user_property_protocol_mapper` | +| `openid_group_membership_protocol_mappers` | `openid_group_membership_protocol_mapper` | +| `openid_full_name_protocol_mappers` | `openid_full_name_protocol_mapper` | +| `openid_sub_protocol_mappers` | `openid_sub_protocol_mapper` | +| `openid_hardcoded_claim_protocol_mappers` | `openid_hardcoded_claim_protocol_mapper` | +| `openid_audience_protocol_mappers` | `openid_audience_protocol_mapper` | +| `openid_audience_resolve_protocol_mappers` | `openid_audience_resolve_protocol_mapper` | +| `openid_hardcoded_role_protocol_mappers` | `openid_hardcoded_role_protocol_mapper` | +| `openid_user_realm_role_protocol_mappers` | `openid_user_realm_role_protocol_mapper` | +| `openid_user_client_role_protocol_mappers` | `openid_user_client_role_protocol_mapper` | +| `openid_user_session_note_protocol_mappers` | `openid_user_session_note_protocol_mapper` | +| `openid_script_protocol_mappers` | `openid_script_protocol_mapper` | + +SAML mappers (`realm` + optional `client` → saml_clients + optional +`client_scope` → saml_client_scopes): + +| Option | `keycloak_*` resource | +| --------------------------------------- | -------------------------------------- | +| `saml_user_attribute_protocol_mappers` | `saml_user_attribute_protocol_mapper` | +| `saml_user_property_protocol_mappers` | `saml_user_property_protocol_mapper` | +| `saml_script_protocol_mappers` | `saml_script_protocol_mapper` | + +Generic mappers (`realm` + optional `client` → openid/saml clients + +optional `client_scope` → openid/saml client_scopes, multi-target): + +| Option | `keycloak_*` resource | +| --------------------------------- | --------------------------------- | +| `generic_protocol_mappers` | `generic_protocol_mapper` | +| `generic_client_protocol_mappers` | `generic_client_protocol_mapper` | +| `generic_role_mappers` | `generic_role_mapper` | +| `generic_client_role_mappers` | `generic_client_role_mapper` | + +### Identity providers + mappers + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ----------------------------------- | ------------------------------------ | ------------ | ----------------------- | +| `oidc_identity_providers` | `oidc_identity_provider` | `alias` | `realm` (by realm name) | +| `saml_identity_providers` | `saml_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_google_identity_providers` | `oidc_google_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_facebook_identity_providers` | `oidc_facebook_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_github_identity_providers` | `oidc_github_identity_provider` | `alias` | `realm` (by realm name) | +| `kubernetes_identity_providers` | `kubernetes_identity_provider` | `alias` | `realm` (by realm name) | + +Identity-provider mappers (`realm` + `identity_provider` → any IdP +collection, multi-target with literal fallback): + +| Option | `keycloak_*` resource | +| --------------------------------------------------- | ------------------------------------------------------ | +| `hardcoded_attribute_identity_provider_mappers` | `hardcoded_attribute_identity_provider_mapper` | +| `hardcoded_group_identity_provider_mappers` | `hardcoded_group_identity_provider_mapper` | +| `hardcoded_role_identity_provider_mappers` | `hardcoded_role_identity_provider_mapper` | +| `attribute_importer_identity_provider_mappers` | `attribute_importer_identity_provider_mapper` | +| `attribute_to_role_identity_provider_mappers` | `attribute_to_role_identity_provider_mapper` | +| `user_template_importer_identity_provider_mappers` | `user_template_importer_identity_provider_mapper` | +| `custom_identity_provider_mappers` | `custom_identity_provider_mapper` | + +### Authentication + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ----------------------------------- | ---------------------------------- | ------------ | --------------------------------------------------------------------------- | +| `authentication_flows` | `authentication_flow` | `alias` | `realm` | +| `authentication_subflows` | `authentication_subflow` | `alias` | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | +| `authentication_executions` | `authentication_execution` | — | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | +| `authentication_execution_configs` | `authentication_execution_config` | `alias` | `realm`, `execution` → authentication_executions | +| `authentication_bindings` | `authentication_bindings` | — | `realm` | + +### Authorization (per-client fine-grained) + +| Option | `keycloak_*` resource | Key defaults | +| ------------------------------------------------------- | ----------------------------------------------------------- | ------------ | +| `openid_client_authorization_resources` | `openid_client_authorization_resource` | `name` | +| `openid_client_authorization_scopes` | `openid_client_authorization_scope` | `name` | +| `openid_client_authorization_permissions` | `openid_client_authorization_permission` | `name` | +| `openid_client_authorization_aggregate_policies` | `openid_client_authorization_aggregate_policy` | `name` | +| `openid_client_authorization_client_policies` | `openid_client_authorization_client_policy` | `name` | +| `openid_client_authorization_client_scope_policies` | `openid_client_authorization_client_scope_policy` | `name` | +| `openid_client_authorization_group_policies` | `openid_client_authorization_group_policy` | `name` | +| `openid_client_authorization_js_policies` | `openid_client_authorization_js_policy` | `name` | +| `openid_client_authorization_role_policies` | `openid_client_authorization_role_policy` | `name` | +| `openid_client_authorization_time_policies` | `openid_client_authorization_time_policy` | `name` | +| `openid_client_authorization_user_policies` | `openid_client_authorization_user_policy` | `name` | + +All authz resources share `realm` + `resource_server` → openid_clients +(the latter resolves to the client's computed `resource_server_id`, +populated once `services.keycloak.runtime.openid_clients..authorization` +is set). + +### Federation + +LDAP (`realm` + `ldap_user_federation` → ldap_user_federations for the +mappers): + +| Option | `keycloak_*` resource | Key defaults | +| -------------------------------------------- | ---------------------------------------------- | ------------ | +| `ldap_user_federations` | `ldap_user_federation` | `name` | +| `ldap_user_attribute_mappers` | `ldap_user_attribute_mapper` | `name` | +| `ldap_group_mappers` | `ldap_group_mapper` | `name` | +| `ldap_role_mappers` | `ldap_role_mapper` | `name` | +| `ldap_hardcoded_role_mappers` | `ldap_hardcoded_role_mapper` | `name` | +| `ldap_hardcoded_attribute_mappers` | `ldap_hardcoded_attribute_mapper` | `name` | +| `ldap_hardcoded_group_mappers` | `ldap_hardcoded_group_mapper` | `name` | +| `ldap_msad_user_account_control_mappers` | `ldap_msad_user_account_control_mapper` | `name` | +| `ldap_msad_lds_user_account_control_mappers` | `ldap_msad_lds_user_account_control_mapper` | `name` | +| `ldap_full_name_mappers` | `ldap_full_name_mapper` | `name` | +| `ldap_custom_mappers` | `ldap_custom_mapper` | `name` | + +Other federation: + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ----------------------------- | ------------------------------- | ------------ | ----------------------------- | +| `custom_user_federations` | `custom_user_federation` | `name` | `realm` | +| `hardcoded_attribute_mappers` | `hardcoded_attribute_mapper` | `name` | `realm`, `ldap_user_federation` → ldap_user_federations | + +### Realm keys + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ------------------------------------- | ---------------------------------- | ------------ | ---------------- | +| `realm_keystore_aes_generateds` | `realm_keystore_aes_generated` | `name` | `realm` | +| `realm_keystore_ecdsa_generateds` | `realm_keystore_ecdsa_generated` | `name` | `realm` | +| `realm_keystore_hmac_generateds` | `realm_keystore_hmac_generated` | `name` | `realm` | +| `realm_keystore_java_keystores` | `realm_keystore_java_keystore` | `name` | `realm` | +| `realm_keystore_rsas` | `realm_keystore_rsa` | `name` | `realm` | +| `realm_keystore_rsa_generateds` | `realm_keystore_rsa_generated` | `name` | `realm` | + +### Realm-level config + permissions + +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ---------------------------------------------------- | ----------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `required_actions` | `required_action` | `alias` | `realm` | +| `realm_events` | `realm_events` | — | `realm` | +| `realm_localizations` | `realm_localization` | `locale` | `realm` | +| `realm_default_client_scopes` | `realm_default_client_scopes` | — | `realm`, `default_scopes` → openid/saml client_scopes (multi-target) | +| `realm_optional_client_scopes` | `realm_optional_client_scopes` | — | `realm`, `optional_scopes` → openid/saml client_scopes (multi-target) | +| `organizations` | `organization` | `name` | `realm` (by realm name) | +| `identity_provider_token_exchange_scope_permissions` | `identity_provider_token_exchange_scope_permission` | — | `realm` | +| `realm_user_profiles` | `realm_user_profile` | — | `realm` | +| `realm_client_policy_profiles` | `realm_client_policy_profile` | `name` | `realm` | +| `realm_client_policy_profile_policies` | `realm_client_policy_profile_policy` | `name` | `realm`, `profiles` → realm_client_policy_profiles | +| `group_permissions` | `group_permissions` | — | `realm`, `group` → groups | +| `users_permissions` | `users_permissions` | — | `realm` | ## State directory note @@ -166,10 +435,24 @@ world-readable Nix store**. They are read at runtime via systemd `LoadCredential=` and passed to OpenTofu as `sensitive = true` Terraform input variables. The generated `.tf.json` (under `/var/lib/keycloak/declarative-terraform/`) contains only `${var.…}` -placeholders for these values. - -The same `` / `File` mechanism will protect secret-valued -resource attributes (e.g. `openid_client.client_secret`, -`user.initial_password`, `ldap_user_federation.bind_credential`) as those -resources land in subsequent waves; the first wave (realm only) declares -no secret attributes. +placeholders. + +The same `` / `File` mechanism protects every secret-valued +resource attribute — including nested-Sensitive fields buried inside +submodules, at any depth. Examples: + +- `openid_clients..client_secret` ↔ `client_secretFile` +- `users..initial_password.value` ↔ `valueFile` (nested) +- `realms..smtp_server.auth.password` ↔ `passwordFile` (nested) +- `realms..smtp_server.token_auth.client_secret` ↔ `client_secretFile` (nested) +- `ldap_user_federations..bind_credential` ↔ `bind_credentialFile` +- `realm_keystore_rsas..private_key` ↔ `private_keyFile` +- `realm_keystore_java_keystores..{keystore_password,key_password}` ↔ `*File` +- `oidc_*_identity_providers..client_secret` ↔ `client_secretFile` +- `saml_clients..signing_private_key` ↔ `signing_private_keyFile` + +For each, set either the literal _or_ its `File` sibling (they're +mutually exclusive). The renderer walks the value tree at apply time, +substitutes a sensitive Terraform variable at the nested location, and +collects the host path into the credentials map so systemd +`LoadCredential=` can read it. From caacf25ee4a677302687d4d38eb6ff82a65d49fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 09:49:48 +0200 Subject: [PATCH 31/50] test(services/keycloak): split into 5 per-family tests - keycloak (VM, with specialisation): core boot -> bootstrap -> reconcile chain + a config-change reroll. Minimal one-realm fixture. The only test that needs full QEMU (specialisations only work in nodes.). - keycloak-rbac (container): roles, groups (with nesting), users, role + group bindings via managed-key list refs. - keycloak-clients (container): openid_client_scopes, openid_clients, default-scope binding, a protocol mapper. - keycloak-realm-extras (container): extended realm attrs, smtp_server with nested-secret indirection, security_defenses (nested-in- nested), otp_policy, realm_keystore_rsa_generated, required_action, realm_localization, realm_user_profile (nested-in-list). - keycloak-idp (container): google IdP + secret indirection, attribute_importer IdP mapper, authentication_flow. Containers boot via systemd-nspawn, so the 4 non-specialisation tests start faster and run in parallel (start_all). Each test is self-contained; the shared mkHost helper carries the common keycloak service config and pyHelpers (admin_token / admin_get / get_realm). Treefmt reflowed the README Resources table to even column widths (no content change); folded into the same commit. --- services/keycloak/README.md | 250 +++---- services/keycloak/checks.nix | 1179 +++++++++++++++++----------------- 2 files changed, 706 insertions(+), 723 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index 1e1cf8e..62f1e08 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -239,122 +239,122 @@ blocks `smtp_server`, `internationalization`, `security_defenses`, ### Roles, groups, users -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ------------------- | --------------------- | ------------ | ----------------------------------------------------------- | -| `roles` | `role` | `name` | `realm`, `composite_roles` → roles | -| `default_roles` | `default_roles` | — | `realm`, `default_roles` → roles | -| `groups` | `group` | `name` | `realm`, `parent` → groups | -| `default_groups` | `default_groups` | — | `realm`, `group_ids` → groups | -| `group_memberships` | `group_memberships` | — | `realm`, `group` → groups, `members` → users | -| `group_roles` | `group_roles` | — | `realm`, `group` → groups, `role_ids` → roles | -| `users` | `user` | `username` | `realm` | -| `user_roles` | `user_roles` | — | `realm`, `user` → users, `role_ids` → roles | -| `user_groups` | `user_groups` | — | `realm`, `user` → users, `group_ids` → groups | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ------------------- | --------------------- | ------------ | --------------------------------------------- | +| `roles` | `role` | `name` | `realm`, `composite_roles` → roles | +| `default_roles` | `default_roles` | — | `realm`, `default_roles` → roles | +| `groups` | `group` | `name` | `realm`, `parent` → groups | +| `default_groups` | `default_groups` | — | `realm`, `group_ids` → groups | +| `group_memberships` | `group_memberships` | — | `realm`, `group` → groups, `members` → users | +| `group_roles` | `group_roles` | — | `realm`, `group` → groups, `role_ids` → roles | +| `users` | `user` | `username` | `realm` | +| `user_roles` | `user_roles` | — | `realm`, `user` → users, `role_ids` → roles | +| `user_groups` | `user_groups` | — | `realm`, `user` → users, `group_ids` → groups | ### Clients, scopes, service accounts -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| -------------------------------------------- | ---------------------------------------------- | ------------ | ---------------------------------------------------------------- | -| `openid_clients` | `openid_client` | `client_id` | `realm` | -| `openid_client_scopes` | `openid_client_scope` | `name` | `realm` | -| `openid_client_default_scopes` | `openid_client_default_scopes` | — | `realm`, `client` → openid_clients, `default_scopes` → openid_client_scopes | -| `openid_client_optional_scopes` | `openid_client_optional_scopes` | — | `realm`, `client` → openid_clients, `optional_scopes` → openid_client_scopes | -| `openid_client_service_account_roles` | `openid_client_service_account_role` | — | `realm`, `client` → openid_clients (target) | -| `openid_client_service_account_realm_roles` | `openid_client_service_account_realm_role` | — | `realm` | -| `openid_client_permissions` | `openid_client_permissions` | — | `realm`, `client` → openid_clients | -| `saml_clients` | `saml_client` | `client_id` | `realm` | -| `saml_client_scopes` | `saml_client_scope` | `name` | `realm` | -| `saml_client_default_scopes` | `saml_client_default_scopes` | — | `realm`, `client` → saml_clients, `default_scopes` → saml_client_scopes | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ------------------------------------------- | ------------------------------------------ | ------------ | ---------------------------------------------------------------------------- | +| `openid_clients` | `openid_client` | `client_id` | `realm` | +| `openid_client_scopes` | `openid_client_scope` | `name` | `realm` | +| `openid_client_default_scopes` | `openid_client_default_scopes` | — | `realm`, `client` → openid_clients, `default_scopes` → openid_client_scopes | +| `openid_client_optional_scopes` | `openid_client_optional_scopes` | — | `realm`, `client` → openid_clients, `optional_scopes` → openid_client_scopes | +| `openid_client_service_account_roles` | `openid_client_service_account_role` | — | `realm`, `client` → openid_clients (target) | +| `openid_client_service_account_realm_roles` | `openid_client_service_account_realm_role` | — | `realm` | +| `openid_client_permissions` | `openid_client_permissions` | — | `realm`, `client` → openid_clients | +| `saml_clients` | `saml_client` | `client_id` | `realm` | +| `saml_client_scopes` | `saml_client_scope` | `name` | `realm` | +| `saml_client_default_scopes` | `saml_client_default_scopes` | — | `realm`, `client` → saml_clients, `default_scopes` → saml_client_scopes | ### Protocol mappers (OpenID, SAML, generic) OpenID mappers (`realm` + optional `client` → openid_clients + optional `client_scope` → openid_client_scopes): -| Option | `keycloak_*` resource | -| ----------------------------------------------- | ---------------------------------------------- | -| `openid_user_attribute_protocol_mappers` | `openid_user_attribute_protocol_mapper` | -| `openid_user_property_protocol_mappers` | `openid_user_property_protocol_mapper` | -| `openid_group_membership_protocol_mappers` | `openid_group_membership_protocol_mapper` | -| `openid_full_name_protocol_mappers` | `openid_full_name_protocol_mapper` | -| `openid_sub_protocol_mappers` | `openid_sub_protocol_mapper` | -| `openid_hardcoded_claim_protocol_mappers` | `openid_hardcoded_claim_protocol_mapper` | -| `openid_audience_protocol_mappers` | `openid_audience_protocol_mapper` | -| `openid_audience_resolve_protocol_mappers` | `openid_audience_resolve_protocol_mapper` | -| `openid_hardcoded_role_protocol_mappers` | `openid_hardcoded_role_protocol_mapper` | -| `openid_user_realm_role_protocol_mappers` | `openid_user_realm_role_protocol_mapper` | -| `openid_user_client_role_protocol_mappers` | `openid_user_client_role_protocol_mapper` | -| `openid_user_session_note_protocol_mappers` | `openid_user_session_note_protocol_mapper` | -| `openid_script_protocol_mappers` | `openid_script_protocol_mapper` | +| Option | `keycloak_*` resource | +| ------------------------------------------- | ------------------------------------------ | +| `openid_user_attribute_protocol_mappers` | `openid_user_attribute_protocol_mapper` | +| `openid_user_property_protocol_mappers` | `openid_user_property_protocol_mapper` | +| `openid_group_membership_protocol_mappers` | `openid_group_membership_protocol_mapper` | +| `openid_full_name_protocol_mappers` | `openid_full_name_protocol_mapper` | +| `openid_sub_protocol_mappers` | `openid_sub_protocol_mapper` | +| `openid_hardcoded_claim_protocol_mappers` | `openid_hardcoded_claim_protocol_mapper` | +| `openid_audience_protocol_mappers` | `openid_audience_protocol_mapper` | +| `openid_audience_resolve_protocol_mappers` | `openid_audience_resolve_protocol_mapper` | +| `openid_hardcoded_role_protocol_mappers` | `openid_hardcoded_role_protocol_mapper` | +| `openid_user_realm_role_protocol_mappers` | `openid_user_realm_role_protocol_mapper` | +| `openid_user_client_role_protocol_mappers` | `openid_user_client_role_protocol_mapper` | +| `openid_user_session_note_protocol_mappers` | `openid_user_session_note_protocol_mapper` | +| `openid_script_protocol_mappers` | `openid_script_protocol_mapper` | SAML mappers (`realm` + optional `client` → saml_clients + optional `client_scope` → saml_client_scopes): -| Option | `keycloak_*` resource | -| --------------------------------------- | -------------------------------------- | -| `saml_user_attribute_protocol_mappers` | `saml_user_attribute_protocol_mapper` | -| `saml_user_property_protocol_mappers` | `saml_user_property_protocol_mapper` | -| `saml_script_protocol_mappers` | `saml_script_protocol_mapper` | +| Option | `keycloak_*` resource | +| -------------------------------------- | ------------------------------------- | +| `saml_user_attribute_protocol_mappers` | `saml_user_attribute_protocol_mapper` | +| `saml_user_property_protocol_mappers` | `saml_user_property_protocol_mapper` | +| `saml_script_protocol_mappers` | `saml_script_protocol_mapper` | Generic mappers (`realm` + optional `client` → openid/saml clients + optional `client_scope` → openid/saml client_scopes, multi-target): -| Option | `keycloak_*` resource | -| --------------------------------- | --------------------------------- | -| `generic_protocol_mappers` | `generic_protocol_mapper` | -| `generic_client_protocol_mappers` | `generic_client_protocol_mapper` | -| `generic_role_mappers` | `generic_role_mapper` | -| `generic_client_role_mappers` | `generic_client_role_mapper` | +| Option | `keycloak_*` resource | +| --------------------------------- | -------------------------------- | +| `generic_protocol_mappers` | `generic_protocol_mapper` | +| `generic_client_protocol_mappers` | `generic_client_protocol_mapper` | +| `generic_role_mappers` | `generic_role_mapper` | +| `generic_client_role_mappers` | `generic_client_role_mapper` | ### Identity providers + mappers -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ----------------------------------- | ------------------------------------ | ------------ | ----------------------- | -| `oidc_identity_providers` | `oidc_identity_provider` | `alias` | `realm` (by realm name) | -| `saml_identity_providers` | `saml_identity_provider` | `alias` | `realm` (by realm name) | -| `oidc_google_identity_providers` | `oidc_google_identity_provider` | `alias` | `realm` (by realm name) | -| `oidc_facebook_identity_providers` | `oidc_facebook_identity_provider` | `alias` | `realm` (by realm name) | -| `oidc_github_identity_providers` | `oidc_github_identity_provider` | `alias` | `realm` (by realm name) | -| `kubernetes_identity_providers` | `kubernetes_identity_provider` | `alias` | `realm` (by realm name) | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ---------------------------------- | --------------------------------- | ------------ | ----------------------- | +| `oidc_identity_providers` | `oidc_identity_provider` | `alias` | `realm` (by realm name) | +| `saml_identity_providers` | `saml_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_google_identity_providers` | `oidc_google_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_facebook_identity_providers` | `oidc_facebook_identity_provider` | `alias` | `realm` (by realm name) | +| `oidc_github_identity_providers` | `oidc_github_identity_provider` | `alias` | `realm` (by realm name) | +| `kubernetes_identity_providers` | `kubernetes_identity_provider` | `alias` | `realm` (by realm name) | Identity-provider mappers (`realm` + `identity_provider` → any IdP collection, multi-target with literal fallback): -| Option | `keycloak_*` resource | -| --------------------------------------------------- | ------------------------------------------------------ | -| `hardcoded_attribute_identity_provider_mappers` | `hardcoded_attribute_identity_provider_mapper` | -| `hardcoded_group_identity_provider_mappers` | `hardcoded_group_identity_provider_mapper` | -| `hardcoded_role_identity_provider_mappers` | `hardcoded_role_identity_provider_mapper` | -| `attribute_importer_identity_provider_mappers` | `attribute_importer_identity_provider_mapper` | -| `attribute_to_role_identity_provider_mappers` | `attribute_to_role_identity_provider_mapper` | -| `user_template_importer_identity_provider_mappers` | `user_template_importer_identity_provider_mapper` | -| `custom_identity_provider_mappers` | `custom_identity_provider_mapper` | +| Option | `keycloak_*` resource | +| -------------------------------------------------- | ------------------------------------------------- | +| `hardcoded_attribute_identity_provider_mappers` | `hardcoded_attribute_identity_provider_mapper` | +| `hardcoded_group_identity_provider_mappers` | `hardcoded_group_identity_provider_mapper` | +| `hardcoded_role_identity_provider_mappers` | `hardcoded_role_identity_provider_mapper` | +| `attribute_importer_identity_provider_mappers` | `attribute_importer_identity_provider_mapper` | +| `attribute_to_role_identity_provider_mappers` | `attribute_to_role_identity_provider_mapper` | +| `user_template_importer_identity_provider_mappers` | `user_template_importer_identity_provider_mapper` | +| `custom_identity_provider_mappers` | `custom_identity_provider_mapper` | ### Authentication -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ----------------------------------- | ---------------------------------- | ------------ | --------------------------------------------------------------------------- | -| `authentication_flows` | `authentication_flow` | `alias` | `realm` | -| `authentication_subflows` | `authentication_subflow` | `alias` | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | -| `authentication_executions` | `authentication_execution` | — | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | -| `authentication_execution_configs` | `authentication_execution_config` | `alias` | `realm`, `execution` → authentication_executions | -| `authentication_bindings` | `authentication_bindings` | — | `realm` | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ---------------------------------- | --------------------------------- | ------------ | -------------------------------------------------------------------------------------- | +| `authentication_flows` | `authentication_flow` | `alias` | `realm` | +| `authentication_subflows` | `authentication_subflow` | `alias` | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | +| `authentication_executions` | `authentication_execution` | — | `realm`, `parent_flow` → authentication_flows / authentication_subflows (multi-target) | +| `authentication_execution_configs` | `authentication_execution_config` | `alias` | `realm`, `execution` → authentication_executions | +| `authentication_bindings` | `authentication_bindings` | — | `realm` | ### Authorization (per-client fine-grained) -| Option | `keycloak_*` resource | Key defaults | -| ------------------------------------------------------- | ----------------------------------------------------------- | ------------ | -| `openid_client_authorization_resources` | `openid_client_authorization_resource` | `name` | -| `openid_client_authorization_scopes` | `openid_client_authorization_scope` | `name` | -| `openid_client_authorization_permissions` | `openid_client_authorization_permission` | `name` | -| `openid_client_authorization_aggregate_policies` | `openid_client_authorization_aggregate_policy` | `name` | -| `openid_client_authorization_client_policies` | `openid_client_authorization_client_policy` | `name` | -| `openid_client_authorization_client_scope_policies` | `openid_client_authorization_client_scope_policy` | `name` | -| `openid_client_authorization_group_policies` | `openid_client_authorization_group_policy` | `name` | -| `openid_client_authorization_js_policies` | `openid_client_authorization_js_policy` | `name` | -| `openid_client_authorization_role_policies` | `openid_client_authorization_role_policy` | `name` | -| `openid_client_authorization_time_policies` | `openid_client_authorization_time_policy` | `name` | -| `openid_client_authorization_user_policies` | `openid_client_authorization_user_policy` | `name` | +| Option | `keycloak_*` resource | Key defaults | +| --------------------------------------------------- | ------------------------------------------------- | ------------ | +| `openid_client_authorization_resources` | `openid_client_authorization_resource` | `name` | +| `openid_client_authorization_scopes` | `openid_client_authorization_scope` | `name` | +| `openid_client_authorization_permissions` | `openid_client_authorization_permission` | `name` | +| `openid_client_authorization_aggregate_policies` | `openid_client_authorization_aggregate_policy` | `name` | +| `openid_client_authorization_client_policies` | `openid_client_authorization_client_policy` | `name` | +| `openid_client_authorization_client_scope_policies` | `openid_client_authorization_client_scope_policy` | `name` | +| `openid_client_authorization_group_policies` | `openid_client_authorization_group_policy` | `name` | +| `openid_client_authorization_js_policies` | `openid_client_authorization_js_policy` | `name` | +| `openid_client_authorization_role_policies` | `openid_client_authorization_role_policy` | `name` | +| `openid_client_authorization_time_policies` | `openid_client_authorization_time_policy` | `name` | +| `openid_client_authorization_user_policies` | `openid_client_authorization_user_policy` | `name` | All authz resources share `realm` + `resource_server` → openid_clients (the latter resolves to the client's computed `resource_server_id`, @@ -366,54 +366,54 @@ is set). LDAP (`realm` + `ldap_user_federation` → ldap_user_federations for the mappers): -| Option | `keycloak_*` resource | Key defaults | -| -------------------------------------------- | ---------------------------------------------- | ------------ | -| `ldap_user_federations` | `ldap_user_federation` | `name` | -| `ldap_user_attribute_mappers` | `ldap_user_attribute_mapper` | `name` | -| `ldap_group_mappers` | `ldap_group_mapper` | `name` | -| `ldap_role_mappers` | `ldap_role_mapper` | `name` | -| `ldap_hardcoded_role_mappers` | `ldap_hardcoded_role_mapper` | `name` | -| `ldap_hardcoded_attribute_mappers` | `ldap_hardcoded_attribute_mapper` | `name` | -| `ldap_hardcoded_group_mappers` | `ldap_hardcoded_group_mapper` | `name` | -| `ldap_msad_user_account_control_mappers` | `ldap_msad_user_account_control_mapper` | `name` | -| `ldap_msad_lds_user_account_control_mappers` | `ldap_msad_lds_user_account_control_mapper` | `name` | -| `ldap_full_name_mappers` | `ldap_full_name_mapper` | `name` | -| `ldap_custom_mappers` | `ldap_custom_mapper` | `name` | +| Option | `keycloak_*` resource | Key defaults | +| -------------------------------------------- | ------------------------------------------- | ------------ | +| `ldap_user_federations` | `ldap_user_federation` | `name` | +| `ldap_user_attribute_mappers` | `ldap_user_attribute_mapper` | `name` | +| `ldap_group_mappers` | `ldap_group_mapper` | `name` | +| `ldap_role_mappers` | `ldap_role_mapper` | `name` | +| `ldap_hardcoded_role_mappers` | `ldap_hardcoded_role_mapper` | `name` | +| `ldap_hardcoded_attribute_mappers` | `ldap_hardcoded_attribute_mapper` | `name` | +| `ldap_hardcoded_group_mappers` | `ldap_hardcoded_group_mapper` | `name` | +| `ldap_msad_user_account_control_mappers` | `ldap_msad_user_account_control_mapper` | `name` | +| `ldap_msad_lds_user_account_control_mappers` | `ldap_msad_lds_user_account_control_mapper` | `name` | +| `ldap_full_name_mappers` | `ldap_full_name_mapper` | `name` | +| `ldap_custom_mappers` | `ldap_custom_mapper` | `name` | Other federation: -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ----------------------------- | ------------------------------- | ------------ | ----------------------------- | -| `custom_user_federations` | `custom_user_federation` | `name` | `realm` | -| `hardcoded_attribute_mappers` | `hardcoded_attribute_mapper` | `name` | `realm`, `ldap_user_federation` → ldap_user_federations | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ----------------------------- | ---------------------------- | ------------ | ------------------------------------------------------- | +| `custom_user_federations` | `custom_user_federation` | `name` | `realm` | +| `hardcoded_attribute_mappers` | `hardcoded_attribute_mapper` | `name` | `realm`, `ldap_user_federation` → ldap_user_federations | ### Realm keys -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ------------------------------------- | ---------------------------------- | ------------ | ---------------- | -| `realm_keystore_aes_generateds` | `realm_keystore_aes_generated` | `name` | `realm` | -| `realm_keystore_ecdsa_generateds` | `realm_keystore_ecdsa_generated` | `name` | `realm` | -| `realm_keystore_hmac_generateds` | `realm_keystore_hmac_generated` | `name` | `realm` | -| `realm_keystore_java_keystores` | `realm_keystore_java_keystore` | `name` | `realm` | -| `realm_keystore_rsas` | `realm_keystore_rsa` | `name` | `realm` | -| `realm_keystore_rsa_generateds` | `realm_keystore_rsa_generated` | `name` | `realm` | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| --------------------------------- | -------------------------------- | ------------ | ---------------- | +| `realm_keystore_aes_generateds` | `realm_keystore_aes_generated` | `name` | `realm` | +| `realm_keystore_ecdsa_generateds` | `realm_keystore_ecdsa_generated` | `name` | `realm` | +| `realm_keystore_hmac_generateds` | `realm_keystore_hmac_generated` | `name` | `realm` | +| `realm_keystore_java_keystores` | `realm_keystore_java_keystore` | `name` | `realm` | +| `realm_keystore_rsas` | `realm_keystore_rsa` | `name` | `realm` | +| `realm_keystore_rsa_generateds` | `realm_keystore_rsa_generated` | `name` | `realm` | ### Realm-level config + permissions -| Option | `keycloak_*` resource | Key defaults | Reference inputs | -| ---------------------------------------------------- | ----------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------- | -| `required_actions` | `required_action` | `alias` | `realm` | -| `realm_events` | `realm_events` | — | `realm` | -| `realm_localizations` | `realm_localization` | `locale` | `realm` | -| `realm_default_client_scopes` | `realm_default_client_scopes` | — | `realm`, `default_scopes` → openid/saml client_scopes (multi-target) | -| `realm_optional_client_scopes` | `realm_optional_client_scopes` | — | `realm`, `optional_scopes` → openid/saml client_scopes (multi-target) | -| `organizations` | `organization` | `name` | `realm` (by realm name) | -| `identity_provider_token_exchange_scope_permissions` | `identity_provider_token_exchange_scope_permission` | — | `realm` | -| `realm_user_profiles` | `realm_user_profile` | — | `realm` | -| `realm_client_policy_profiles` | `realm_client_policy_profile` | `name` | `realm` | -| `realm_client_policy_profile_policies` | `realm_client_policy_profile_policy` | `name` | `realm`, `profiles` → realm_client_policy_profiles | -| `group_permissions` | `group_permissions` | — | `realm`, `group` → groups | -| `users_permissions` | `users_permissions` | — | `realm` | +| Option | `keycloak_*` resource | Key defaults | Reference inputs | +| ---------------------------------------------------- | --------------------------------------------------- | ------------ | --------------------------------------------------------------------- | +| `required_actions` | `required_action` | `alias` | `realm` | +| `realm_events` | `realm_events` | — | `realm` | +| `realm_localizations` | `realm_localization` | `locale` | `realm` | +| `realm_default_client_scopes` | `realm_default_client_scopes` | — | `realm`, `default_scopes` → openid/saml client_scopes (multi-target) | +| `realm_optional_client_scopes` | `realm_optional_client_scopes` | — | `realm`, `optional_scopes` → openid/saml client_scopes (multi-target) | +| `organizations` | `organization` | `name` | `realm` (by realm name) | +| `identity_provider_token_exchange_scope_permissions` | `identity_provider_token_exchange_scope_permission` | — | `realm` | +| `realm_user_profiles` | `realm_user_profile` | — | `realm` | +| `realm_client_policy_profiles` | `realm_client_policy_profile` | `name` | `realm` | +| `realm_client_policy_profile_policies` | `realm_client_policy_profile_policy` | `name` | `realm`, `profiles` → realm_client_policy_profiles | +| `group_permissions` | `group_permissions` | — | `realm`, `group` → groups | +| `users_permissions` | `users_permissions` | — | `realm` | ## State directory note diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 27a697b..1c2083a 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -1,666 +1,649 @@ -# this tests provisioning of keycloak realms +# Per-resource-family keycloak tests. The `keycloak` test is a full VM +# (specialisations only work in QEMU nodes); the rest are nspawn containers +# for faster boot and tighter focus. { pkgs, self }: let + inherit (pkgs) lib; keycloakAdminPassword = "hackme"; + + # Python helpers; each takes the machine reference (`machine` for VMs, + # `keycloak` for containers) so the body is identical across both shapes. + pyHelpers = '' + import json + def admin_token(m): + resp = m.succeed( + "curl --fail -s -X POST " + "http://localhost:8080/realms/master/protocol/openid-connect/token " + "-d grant_type=password -d client_id=admin-cli " + "-d username=admin -d password=${keycloakAdminPassword}" + ) + return json.loads(resp)["access_token"] + def admin_get(m, path): + tok = admin_token(m) + return json.loads(m.succeed( + f"curl --fail -s -H 'Authorization: Bearer {tok}' " + f"http://localhost:8080/admin/realms/{path}" + )) + def get_realm(m, realm): + return admin_get(m, realm) + ''; + + # Common keycloak service config every test reuses. `runtime` carries + # the per-test resource fixture; `extraEtc` mocks operator secret files. + mkHost = + { + runtime, + extraEtc ? { }, + }: + { + config, + ... + }: + { + imports = [ self.nixosModules.default ]; + + networking.firewall.allowedTCPPorts = [ + config.services.keycloak.settings.http-port + ]; + + environment.etc = { + "keycloak-db-password".text = "hackme"; + "keycloak-admin-password".text = keycloakAdminPassword; + } + // extraEtc; + + services.keycloak = { + enable = true; + initialAdminPassword = keycloakAdminPassword; + settings = { + hostname = "keycloak"; + http-port = 8080; + http-enabled = true; # HTTP-only test deployment + hostname-strict = false; + }; + + database.passwordFile = "/etc/keycloak-db-password"; + + runtime = runtime // { + enable = true; + bootstrapAdminPasswordFile = "/etc/keycloak-admin-password"; + }; + }; + }; in { + # Core test: full VM proving the boot -> bootstrap -> reconcile chain + # plus config-change reconciliation via a specialisation. keycloak = pkgs.testers.runNixOSTest { name = "declarative-keycloak"; - # cannot use containers here because we use specialisations nodes.machine = - { config, ... }: - { - imports = [ self.nixosModules.default ]; - - # keycloak is thicc - virtualisation.memorySize = 3072; - - networking.firewall.allowedTCPPorts = [ - config.services.keycloak.settings.http-port - ]; - - # mock agenix secrets: the module expects passwords to be supplied as files - environment.etc."keycloak-db-password".text = "hackme"; - environment.etc."keycloak-admin-password".text = keycloakAdminPassword; - environment.etc."acme-google-secret".text = "fakesecret"; - environment.etc."acme-smtp-password".text = "verysecretpassword"; - - services.keycloak = { - enable = true; - initialAdminPassword = keycloakAdminPassword; - settings = { - hostname = "keycloak"; - http-port = 8080; - http-enabled = true; # HTTP-only test deployment - hostname-strict = false; + args: + lib.recursiveUpdate + (mkHost { + runtime.realms.acme = { + display_name = "ACME Corp."; + display_name_html = "ACME Corp."; }; + } args) + { + # keycloak is thicc -- only VMs accept memorySize. + virtualisation.memorySize = 3072; + specialisation.addRealm.configuration.services.keycloak.runtime.realms.delta = { + display_name = "Delta Realm"; + }; + }; - database.passwordFile = "/etc/keycloak-db-password"; - - runtime = { - enable = true; - bootstrapAdminPasswordFile = "/etc/keycloak-admin-password"; - - realms.acme = { - display_name = "ACME Corp."; - display_name_html = "ACME Corp."; - # exercises a representative cross-section of typed attrs - registration_allowed = true; - login_theme = "keycloak"; - ssl_required = "external"; - access_token_lifespan = "10m"; - password_policy = "length(8)"; - attributes = { - "userProfileEnabled" = "true"; - }; - # nested block; renderer wraps as `[{...}]` via blockAttrs. - # Internationalisation has no nested secret -- safe to set fully. - internationalization = { - supported_locales = [ - "en" - "de" - ]; - default_locale = "en"; - }; - # smtp_server with nested auth credentials: passwordFile is a - # host path read via LoadCredential=, never copied into the - # generated .tf.json (substituteSecrets walks the value tree - # and substitutes `${var.}` at the nested location). - smtp_server = { - host = "smtp.example.com"; - from = "noreply@example.com"; - port = "25"; - from_display_name = "ACME"; - auth = { - username = "noreply"; - passwordFile = "/etc/acme-smtp-password"; - }; - }; - # exercises the nested-in-nested block-list wrapping (the - # security_defenses outer block and its inner headers / - # brute_force_detection sub-blocks each need [{...}] wrap). - security_defenses = { - headers = { - x_frame_options = "DENY"; - strict_transport_security = "max-age=63072000; includeSubDomains; preload"; - }; - brute_force_detection = { - permanent_lockout = false; - max_login_failures = 5; - }; - }; - otp_policy = { - type = "totp"; - algorithm = "HmacSHA256"; - digits = 6; - period = 30; - initial_counter = 0; - look_ahead_window = 1; - }; - }; - - # realm-level role + default-roles binding - roles.acme_engineer = { - realm = "acme"; - name = "engineer"; - description = "ACME engineering role"; - }; - default_roles.acme = { - realm = "acme"; - default_roles = [ - "offline_access" - "uma_authorization" - "engineer" - ]; - }; - - # group hierarchy: parent_id resolves to a managed group - groups.acme_eng = { - realm = "acme"; - name = "engineering"; - attributes."team" = "infra"; - }; - groups.acme_eng_backend = { - realm = "acme"; - name = "backend"; - parent = "acme_eng"; - }; - group_roles.acme_eng_admins = { - realm = "acme"; - group = "acme_eng"; - role_ids = [ "acme_engineer" ]; # managed role key, resolved to .id - exhaustive = true; - }; - - # user + bindings; initial_password is a nested-block secret that - # needs renderer extension, so we set a required_action instead. - users.acme_alice = { - realm = "acme"; - username = "alice"; - email = "alice@acme.example"; - first_name = "Alice"; - last_name = "Anderson"; - email_verified = true; - required_actions = [ "UPDATE_PASSWORD" ]; - }; - user_roles.acme_alice = { - realm = "acme"; - user = "acme_alice"; - role_ids = [ "acme_engineer" ]; # managed role key - exhaustive = false; - }; - user_groups.acme_alice = { - realm = "acme"; - user = "acme_alice"; - group_ids = [ "acme_eng" ]; # managed group key - exhaustive = false; - }; + testScript = '' + ${pyHelpers} + machine.start() - openid_client_scopes.acme_profile = { - realm = "acme"; - name = "acme-profile"; - description = "ACME profile scope"; - consent_screen_text = "Access your ACME profile"; - include_in_token_scope = true; - gui_order = 10; - }; + # whole chain (keycloak -> bootstrap -> reconciler) must converge. + machine.wait_for_unit("declarative-keycloak.service") - # OpenID client with a literal secret + scope bindings. - openid_clients.acme_app = { - realm = "acme"; - client_id = "acme-app"; - name = "ACME App"; - access_type = "CONFIDENTIAL"; - client_secret = "topsecret"; - standard_flow_enabled = true; - direct_access_grants_enabled = true; - service_accounts_enabled = true; - valid_redirect_uris = [ "https://app.acme.example/*" ]; - web_origins = [ "https://app.acme.example" ]; - consent_required = false; - full_scope_allowed = true; - }; - openid_client_default_scopes.acme_app = { - realm = "acme"; - client = "acme_app"; - default_scopes = [ - "profile" - "email" - "acme-profile" - ]; - }; + with subtest("declared realm exists with display_name applied"): + acme = get_realm(machine, "acme") + assert acme.get("realm") == "acme", f"realm: {acme}" + assert acme.get("displayName") == "ACME Corp.", f"displayName: {acme}" + assert acme.get("displayNameHtml") == "ACME Corp.", \ + f"displayNameHtml: {acme}" - # OpenID protocol mapper attached to the acme-profile scope. - openid_user_attribute_protocol_mappers.team_claim = { - realm = "acme"; - client_scope = "acme_profile"; - name = "team"; - user_attribute = "team"; - claim_name = "team"; - claim_value_type = "String"; - add_to_id_token = true; - add_to_access_token = true; - add_to_userinfo = true; - }; + with subtest("admin password and minted client_secret kept out of .tf.json"): + tfjson = machine.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "${keycloakAdminPassword}" not in tfjson, \ + "admin password leaked into .tf.json" + client_secret = machine.succeed( + "cat /var/lib/declarative-keycloak-bootstrap/client_secret" + ).strip() + assert client_secret and client_secret not in tfjson, \ + "minted client_secret leaked into .tf.json" - # google IdP exercises the realmAliasRef ref-by-alias path and - # secret-file indirection on the new client_secret field. - oidc_google_identity_providers.acme_google = { - realm = "acme"; - client_id = "fake-client-id"; - client_secretFile = "/etc/acme-google-secret"; - }; + with subtest("tfstate file exists and is non-empty"): + machine.succeed( + "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" + ) - # IdP mapper exercises idpAliasRequiredRef across IdP collections. - attribute_importer_identity_provider_mappers.google_email = { - realm = "acme"; - identity_provider = "acme_google"; - name = "google-email"; - user_attribute = "email"; - claim_name = "email"; - }; + with subtest("reapplying the same config is a no-op"): + machine.succeed("systemctl restart declarative-keycloak.service") - # Top-level authentication flow. - authentication_flows.acme_passkey = { - realm = "acme"; - alias = "acme-passkey"; - description = "Passkey login flow"; - }; + with subtest("new realm is applied on config switch"): + machine.succeed( + "/run/current-system/specialisation/addRealm/bin/switch-to-configuration test" + ) + machine.wait_until_succeeds("curl --fail http://localhost:8080/realms/delta") + delta = get_realm(machine, "delta") + assert delta.get("displayName") == "Delta Realm", f"delta: {delta}" + ''; + }; - # additional realm RSA key. - realm_keystore_rsa_generateds.acme_extra_rsa = { - realm = "acme"; - name = "acme-extra-rsa"; - algorithm = "RS256"; - key_size = 2048; - priority = 50; - }; + # RBAC: roles, groups, users + bindings via managed-key list refs. + keycloak-rbac = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-rbac"; - # built-in required action toggle. - required_actions.acme_configure_totp = { - realm = "acme"; - alias = "CONFIGURE_TOTP"; - enabled = false; - default_action = false; - }; + containers.keycloak = mkHost { + runtime = { + realms.acme.display_name = "ACME"; - # custom realm localization texts. - realm_localizations.acme_en = { - realm = "acme"; - locale = "en"; - texts = { - loginAccountTitle = "ACME"; - }; - }; + roles.acme_engineer = { + realm = "acme"; + name = "engineer"; + description = "ACME engineering role"; + }; + default_roles.acme = { + realm = "acme"; + default_roles = [ + "offline_access" + "uma_authorization" + "acme_engineer" # managed key, resolves to role name "engineer" + ]; + }; - # realm_user_profile exercises a MaxItems:1 nested block inside a - # list element (attribute[].permissions); proves wrapBlocks - # recurses into list elements. - realm_user_profiles.acme = { - realm = "acme"; - unmanaged_attribute_policy = "ENABLED"; - # Keycloak refuses to drop the built-in attrs (username, - # email, firstName, lastName); declare them alongside the - # custom one. display_name uses Keycloak's `${i18n.key}` - # syntax which collides with Terraform interpolation, so we - # leave those off here. - attribute = [ - { - name = "username"; - permissions = { - view = [ - "admin" - "user" - ]; - edit = [ - "admin" - "user" - ]; - }; - validator = [ - { - name = "length"; - config = { - min = "3"; - max = "255"; - }; - } - ]; - } - { - name = "email"; - permissions = { - view = [ - "admin" - "user" - ]; - edit = [ - "admin" - "user" - ]; - }; - } - { - name = "firstName"; - permissions = { - view = [ - "admin" - "user" - ]; - edit = [ - "admin" - "user" - ]; - }; - } - { - name = "lastName"; - permissions = { - view = [ - "admin" - "user" - ]; - edit = [ - "admin" - "user" - ]; - }; - } - { - name = "team"; - display_name = "Team"; - group = "metadata"; - permissions = { - view = [ - "admin" - "user" - ]; - edit = [ "admin" ]; - }; - } - ]; - group = [ - { - name = "metadata"; - display_header = "Metadata"; - display_description = "ACME-internal user metadata"; - } - ]; - }; - }; + groups.acme_eng = { + realm = "acme"; + name = "engineering"; + attributes."team" = "infra"; + }; + groups.acme_eng_backend = { + realm = "acme"; + name = "backend"; + parent = "acme_eng"; + }; + group_roles.acme_eng_admins = { + realm = "acme"; + group = "acme_eng"; + role_ids = [ "acme_engineer" ]; # managed key + exhaustive = true; }; - specialisation.addRealm.configuration = { - services.keycloak.runtime.realms.delta = { - display_name = "Delta Realm"; - }; + users.acme_alice = { + realm = "acme"; + username = "alice"; + email = "alice@acme.example"; + first_name = "Alice"; + last_name = "Anderson"; + email_verified = true; + required_actions = [ "UPDATE_PASSWORD" ]; + }; + user_roles.acme_alice = { + realm = "acme"; + user = "acme_alice"; + role_ids = [ "acme_engineer" ]; + exhaustive = false; + }; + user_groups.acme_alice = { + realm = "acme"; + user = "acme_alice"; + group_ids = [ "acme_eng" ]; # managed key + exhaustive = false; }; }; + }; testScript = '' - import json - - def admin_token(): - resp = machine.succeed( - "curl --fail -s -X POST " - "http://localhost:8080/realms/master/protocol/openid-connect/token " - "-d grant_type=password -d client_id=admin-cli " - "-d username=admin -d password=${keycloakAdminPassword}" - ) - return json.loads(resp)["access_token"] - - def get_realm(realm): - tok = admin_token() - return json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/{realm}" - )) + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") - machine.start() - - # wait until keycloak.service is running and the bootstrap and the provisioning have finished - machine.wait_for_unit("declarative-keycloak.service") - - with subtest("declared realm exists"): - acme = get_realm("acme") - assert acme.get("realm") == "acme", f"realm name not applied: {acme}" - assert acme.get("displayName") == "ACME Corp.", f"display_name not applied: {acme}" - assert acme.get("displayNameHtml") == "ACME Corp.", \ - f"display_name_html not applied: {acme}" - - with subtest("extended realm attrs reach the API"): - assert acme.get("registrationAllowed") is True, f"registration_allowed: {acme}" - assert acme.get("loginTheme") == "keycloak", f"login_theme: {acme}" - assert acme.get("sslRequired") == "external", f"ssl_required: {acme}" - assert acme.get("accessTokenLifespan") == 600, f"access_token_lifespan: {acme}" - assert acme.get("passwordPolicy") == "length(8)", f"password_policy: {acme}" - assert acme.get("attributes", {}).get("userProfileEnabled") == "true", \ - f"attributes: {acme}" - - with subtest("realm nested blocks (smtp_server + internationalization) applied"): - smtp = acme.get("smtpServer", {}) - assert smtp.get("host") == "smtp.example.com", f"smtp.host: {smtp}" - assert smtp.get("from") == "noreply@example.com", f"smtp.from: {smtp}" - assert smtp.get("fromDisplayName") == "ACME", f"smtp.from_display_name: {smtp}" - assert acme.get("internationalizationEnabled") is True, \ - f"i18n not enabled: {acme}" - locales = set(acme.get("supportedLocales", [])) - assert {"en", "de"}.issubset(locales), f"supported_locales: {locales}" - assert acme.get("defaultLocale") == "en", f"default_locale: {acme}" - - with subtest("nested-in-nested blocks (security_defenses.headers + brute_force_detection) applied"): - headers = acme.get("browserSecurityHeaders", {}) - assert headers.get("xFrameOptions") == "DENY", f"headers: {headers}" - assert headers.get("strictTransportSecurity", "").startswith("max-age=63072000"), \ - f"headers: {headers}" - # brute-force settings land at realm top-level under camelCase names. - assert acme.get("failureFactor") == 5, f"max_login_failures: {acme.get('failureFactor')}" - # otp_policy fields also flatten to the realm representation. - assert acme.get("otpPolicyAlgorithm") == "HmacSHA256", \ - f"otp algorithm: {acme.get('otpPolicyAlgorithm')}" - - with subtest("realm role exists with description"): - tok = admin_token() - role = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/roles/engineer" - )) + with subtest("role exists with description"): + role = admin_get(keycloak, "acme/roles/engineer") assert role.get("description") == "ACME engineering role", f"role: {role}" - with subtest("default-roles binding includes the new role"): - tok = admin_token() - # the composite "default-roles-" role aggregates the realm's - # default roles; we read its composites to assert membership. - composites = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/roles/default-roles-acme/composites" - )) + with subtest("default-roles binding includes the managed role"): + composites = admin_get(keycloak, "acme/roles/default-roles-acme/composites") names = {r["name"] for r in composites} for r in ("offline_access", "uma_authorization", "engineer"): assert r in names, f"default role {r!r} missing from {names}" - with subtest("user exists with attributes, roles, and group membership"): - tok = admin_token() - users = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/users?username=alice" - )) + with subtest("group hierarchy + managed-key role binding"): + groups = admin_get(keycloak, "acme/groups") + eng = next((g for g in groups if g["name"] == "engineering"), None) + assert eng, f"engineering group missing: {[g['name'] for g in groups]}" + children = admin_get(keycloak, f"acme/groups/{eng['id']}/children") + assert any(c["name"] == "backend" for c in children), \ + f"backend subgroup missing: {children}" + eng_roles = admin_get(keycloak, f"acme/groups/{eng['id']}/role-mappings/realm") + assert any(r["name"] == "engineer" for r in eng_roles), \ + f"engineer role missing on engineering: {eng_roles}" + + with subtest("user attributes + managed-key role + group bindings"): + users = admin_get(keycloak, "acme/users?username=alice") alice = next((u for u in users if u["username"] == "alice"), None) assert alice, f"alice missing: {users}" - assert alice.get("email") == "alice@acme.example", f"alice: {alice}" - assert alice.get("firstName") == "Alice", f"alice: {alice}" - assert "UPDATE_PASSWORD" in alice.get("requiredActions", []), f"alice: {alice}" - alice_roles = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/acme/users/{alice['id']}/role-mappings/realm" - )) + assert alice.get("email") == "alice@acme.example" + assert alice.get("firstName") == "Alice" + assert "UPDATE_PASSWORD" in alice.get("requiredActions", []) + alice_roles = admin_get(keycloak, f"acme/users/{alice['id']}/role-mappings/realm") assert any(r["name"] == "engineer" for r in alice_roles), \ f"engineer role missing on alice: {alice_roles}" - alice_groups = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/acme/users/{alice['id']}/groups" - )) + alice_groups = admin_get(keycloak, f"acme/users/{alice['id']}/groups") assert any(g["name"] == "engineering" for g in alice_groups), \ f"engineering group missing on alice: {alice_groups}" + ''; + }; - with subtest("google IdP exists and client_secret stays out of .tf.json"): - tok = admin_token() - # alias defaults to the collection key (`acme_google`) via - # nameAttr; the provider then sets providerId="google". - idp = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/identity-provider/instances/acme_google" - )) - assert idp.get("providerId") == "google", f"google IdP: {idp}" - assert idp.get("alias") == "acme_google", f"google IdP: {idp}" - # operator-supplied secret loaded via LoadCredential must not - # leak into the generated config. - tfjson = machine.succeed( - "cat /var/lib/keycloak/declarative-terraform/main.tf.json" - ) - assert "fakesecret" not in tfjson, "google IdP client_secret leaked into .tf.json" + # OpenID clients + scopes + a protocol mapper + default-scope binding. + keycloak-clients = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-clients"; + + containers.keycloak = mkHost { + runtime = { + realms.acme.display_name = "ACME"; + + openid_client_scopes.acme_profile = { + realm = "acme"; + name = "acme-profile"; + description = "ACME profile scope"; + consent_screen_text = "Access your ACME profile"; + include_in_token_scope = true; + gui_order = 10; + }; - with subtest("required_action CONFIGURE_TOTP is disabled"): - tok = admin_token() - ras = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/authentication/required-actions" - )) - totp = next((r for r in ras if r.get("alias") == "CONFIGURE_TOTP"), None) - assert totp, f"CONFIGURE_TOTP not found: {[r.get('alias') for r in ras]}" - assert totp.get("enabled") is False, f"CONFIGURE_TOTP should be disabled: {totp}" + openid_clients.acme_app = { + realm = "acme"; + client_id = "acme-app"; + name = "ACME App"; + access_type = "CONFIDENTIAL"; + client_secret = "topsecret"; + standard_flow_enabled = true; + direct_access_grants_enabled = true; + service_accounts_enabled = true; + valid_redirect_uris = [ "https://app.acme.example/*" ]; + web_origins = [ "https://app.acme.example" ]; + consent_required = false; + full_scope_allowed = true; + }; + openid_client_default_scopes.acme_app = { + realm = "acme"; + client = "acme_app"; + default_scopes = [ + "profile" + "email" + "acme_profile" # managed key, resolves to scope name "acme-profile" + ]; + }; - with subtest("realm_user_profile attribute[].permissions block wrap reaches the API"): - tok = admin_token() - up = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/users/profile" - )) - attrs = {a["name"]: a for a in up.get("attributes", [])} - assert "team" in attrs, f"team attribute missing: {list(attrs)}" - team_perms = attrs["team"].get("permissions", {}) - assert set(team_perms.get("view", [])) == {"admin", "user"}, \ - f"team view perms: {team_perms}" - assert set(team_perms.get("edit", [])) == {"admin"}, \ - f"team edit perms: {team_perms}" - username_validators = attrs["username"].get("validations", {}) - assert "length" in username_validators, \ - f"length validator missing on username: {username_validators}" - assert up.get("unmanagedAttributePolicy") == "ENABLED", \ - f"unmanaged_attribute_policy: {up}" + # Protocol mapper attached to the managed scope via its key. + openid_user_attribute_protocol_mappers.team_claim = { + realm = "acme"; + client_scope = "acme_profile"; + name = "team"; + user_attribute = "team"; + claim_name = "team"; + claim_value_type = "String"; + add_to_id_token = true; + add_to_access_token = true; + add_to_userinfo = true; + }; + }; + }; - with subtest("realm localization message reaches the API"): - tok = admin_token() - texts = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/localization/en" - )) - assert texts.get("loginAccountTitle") == "ACME", f"localization texts: {texts}" + testScript = '' + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") - with subtest("realm RSA keystore appears in the keys endpoint"): - tok = admin_token() - keys = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/keys" - )) - # /keys returns { keys: [...], active: {...} }; look for our component - # by checking that an RS256 entry from our provider name exists. - providers = {k.get("providerId") for k in keys.get("keys", [])} - assert any( - "acme-extra-rsa" in str(k.get("providerId") or "") - for k in keys.get("keys", []) - ) or any( - k.get("algorithm") == "RS256" and k.get("status") == "ACTIVE" - for k in keys.get("keys", []) - ), f"acme-extra-rsa key not found, providers: {providers}" - - with subtest("authentication flow exists"): - tok = admin_token() - flows = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/authentication/flows" - )) - flow = next((f for f in flows if f.get("alias") == "acme-passkey"), None) - assert flow, f"acme-passkey flow missing: {[f.get('alias') for f in flows]}" - assert flow.get("description") == "Passkey login flow", f"flow: {flow}" + with subtest("openid client scope exists with declared attrs"): + scopes = admin_get(keycloak, "acme/client-scopes") + s = next((x for x in scopes if x["name"] == "acme-profile"), None) + assert s, f"acme-profile scope missing: {[x['name'] for x in scopes]}" + assert s.get("description") == "ACME profile scope", f"scope: {s}" + assert s.get("attributes", {}).get("consent.screen.text") == "Access your ACME profile", \ + f"consent_screen_text: {s}" - with subtest("IdP mapper attached to google via managed alias ref"): - tok = admin_token() - mappers = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/identity-provider/instances/acme_google/mappers" - )) - m = next((x for x in mappers if x["name"] == "google-email"), None) - assert m, f"google-email mapper missing: {mappers}" - # provider picks the right mapper type for the IdP variant - # (here `google-user-attribute-mapper`); just assert config reached it. - assert m.get("identityProviderAlias") == "acme_google", f"mapper: {m}" - cfg = m.get("config", {}) - assert cfg.get("user.attribute") == "email", f"mapper config: {cfg}" + with subtest("openid client exists with declared default scopes"): + clients = admin_get(keycloak, "acme/clients?clientId=acme-app") + app = clients[0] + assert app["enabled"] is True + bindings = admin_get(keycloak, f"acme/clients/{app['id']}/default-client-scopes") + names = {b["name"] for b in bindings} + assert "acme-profile" in names, \ + f"acme-profile not bound as default scope: {names}" with subtest("protocol mapper attached to client scope"): - tok = admin_token() - scopes = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/client-scopes" - )) + # protocolMappers travel with the client-scope representation. + scopes = admin_get(keycloak, "acme/client-scopes") s = next((x for x in scopes if x["name"] == "acme-profile"), None) - mappers = s.get("protocolMappers", []) if s else [] - mapper = next((m for m in mappers if m["name"] == "team"), None) - assert mapper, f"team mapper missing on acme-profile: {mappers}" + assert s, f"acme-profile scope missing: {[x['name'] for x in scopes]}" + mapper = next( + (m for m in (s.get("protocolMappers") or []) if m["name"] == "team"), + None, + ) + assert mapper, f"team mapper missing on acme-profile: {s.get('protocolMappers')}" assert mapper.get("protocolMapper") == "oidc-usermodel-attribute-mapper", \ f"mapper type mismatch: {mapper}" cfg = mapper.get("config", {}) assert cfg.get("user.attribute") == "team", f"mapper config: {cfg}" assert cfg.get("claim.name") == "team", f"mapper config: {cfg}" + ''; + }; - with subtest("openid client exists with declared scopes attached"): - tok = admin_token() - clients = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/clients?clientId=acme-app" - )) - app = clients[0] - assert app["clientId"] == "acme-app", f"app: {app}" - assert app["enabled"] is True, f"app: {app}" - default_scopes = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/acme/clients/{app['id']}/default-client-scopes" - )) - names = {s["name"] for s in default_scopes} - assert "acme-profile" in names, f"acme-profile not bound as default scope: {names}" + # Realm extras: extended realm attrs, nested-secret smtp, security + # defenses (nested-in-nested), otp_policy, realm_user_profile (nested + # in list elements), a keystore, required_action, localization. + keycloak-realm-extras = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-realm-extras"; + + containers.keycloak = mkHost { + extraEtc."acme-smtp-password".text = "verysecretpassword"; + runtime = { + realms.acme = { + display_name = "ACME Corp."; + display_name_html = "ACME Corp."; + # extended attrs + registration_allowed = true; + login_theme = "keycloak"; + ssl_required = "external"; + access_token_lifespan = "10m"; + password_policy = "length(8)"; + attributes."userProfileEnabled" = "true"; + internationalization = { + supported_locales = [ + "en" + "de" + ]; + default_locale = "en"; + }; + # smtp_server with nested-secret indirection. + smtp_server = { + host = "smtp.example.com"; + from = "noreply@example.com"; + port = "25"; + from_display_name = "ACME"; + auth = { + username = "noreply"; + passwordFile = "/etc/acme-smtp-password"; + }; + }; + # nested-in-nested block-list wrap (security_defenses.headers, + # security_defenses.brute_force_detection). + security_defenses = { + headers = { + x_frame_options = "DENY"; + strict_transport_security = "max-age=63072000; includeSubDomains; preload"; + }; + brute_force_detection = { + permanent_lockout = false; + max_login_failures = 5; + }; + }; + otp_policy = { + type = "totp"; + algorithm = "HmacSHA256"; + digits = 6; + period = 30; + initial_counter = 0; + look_ahead_window = 1; + }; + }; - with subtest("openid client scope exists with declared attrs"): - tok = admin_token() - scopes = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/client-scopes" - )) - s = next((x for x in scopes if x["name"] == "acme-profile"), None) - assert s, f"acme-profile scope missing: {[x['name'] for x in scopes]}" - assert s.get("description") == "ACME profile scope", f"scope: {s}" - assert s.get("attributes", {}).get("consent.screen.text") == "Access your ACME profile", \ - f"consent_screen_text: {s}" + realm_keystore_rsa_generateds.acme_extra_rsa = { + realm = "acme"; + name = "acme-extra-rsa"; + algorithm = "RS256"; + key_size = 2048; + priority = 50; + }; - with subtest("group hierarchy + role assignment"): - tok = admin_token() - groups = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - "http://localhost:8080/admin/realms/acme/groups" - )) - eng = next((g for g in groups if g["name"] == "engineering"), None) - assert eng, f"engineering group missing: {groups}" - # KC 26 returns subGroupCount but a paginated `subGroups` (empty by - # default); the /children endpoint gives the actual subgroup list. - children = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/acme/groups/{eng['id']}/children" - )) - assert any(c["name"] == "backend" for c in children), \ - f"backend subgroup missing under engineering: {children}" - eng_roles = json.loads(machine.succeed( - f"curl --fail -s -H 'Authorization: Bearer {tok}' " - f"http://localhost:8080/admin/realms/acme/groups/{eng['id']}/role-mappings/realm" - )) - assert any(r["name"] == "engineer" for r in eng_roles), \ - f"engineer role not assigned to engineering group: {eng_roles}" + required_actions.acme_configure_totp = { + realm = "acme"; + alias = "CONFIGURE_TOTP"; + enabled = false; + default_action = false; + }; - with subtest("secrets did not leak"): - tfjson = machine.succeed( - "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + realm_localizations.acme_en = { + realm = "acme"; + locale = "en"; + texts.loginAccountTitle = "ACME"; + }; + + # realm_user_profile exercises a MaxItems:1 nested block inside a + # list element (attribute[].permissions). Keycloak refuses to drop + # the built-in attrs; declare them alongside the custom one. + realm_user_profiles.acme = { + realm = "acme"; + unmanaged_attribute_policy = "ENABLED"; + attribute = [ + { + name = "username"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + validator = [ + { + name = "length"; + config = { + min = "3"; + max = "255"; + }; + } + ]; + } + { + name = "email"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "firstName"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "lastName"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + } + { + name = "team"; + display_name = "Team"; + group = "metadata"; + permissions = { + view = [ + "admin" + "user" + ]; + edit = [ "admin" ]; + }; + } + ]; + group = [ + { + name = "metadata"; + display_header = "Metadata"; + display_description = "ACME-internal user metadata"; + } + ]; + }; + }; + }; + + testScript = '' + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") + acme = get_realm(keycloak, "acme") + + with subtest("extended realm attrs reach the API"): + assert acme.get("registrationAllowed") is True, f"acme: {acme}" + assert acme.get("loginTheme") == "keycloak" + assert acme.get("sslRequired") == "external" + assert acme.get("accessTokenLifespan") == 600 + assert acme.get("passwordPolicy") == "length(8)" + assert acme.get("attributes", {}).get("userProfileEnabled") == "true" + + with subtest("smtp_server nested-secret stays out of .tf.json"): + tfjson = keycloak.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" ) - assert "${keycloakAdminPassword}" not in tfjson, "admin password leaked into generated .tf.json" - client_secret = machine.succeed( - "cat /var/lib/declarative-keycloak-bootstrap/client_secret" - ).strip() - assert client_secret, "bootstrap did not write client_secret" - assert client_secret not in tfjson, "client_secret leaked into generated .tf.json" - # nested-secret indirection: smtp_server.auth.passwordFile must - # leave the literal out of the generated config. assert "verysecretpassword" not in tfjson, \ - "smtp_server.auth.password leaked into generated .tf.json" + "smtp_server.auth.password leaked into .tf.json" assert "secret_realm_acme_smtp_server_auth_password" in tfjson, \ - "expected nested-secret var reference in generated .tf.json" + "nested-secret var reference missing in .tf.json" + smtp = acme.get("smtpServer", {}) + assert smtp.get("host") == "smtp.example.com", f"smtp: {smtp}" + assert smtp.get("from") == "noreply@example.com", f"smtp: {smtp}" - with subtest("tfstate file is not empty"): - machine.succeed( - "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" - ) + with subtest("internationalization applied"): + assert acme.get("internationalizationEnabled") is True + locales = set(acme.get("supportedLocales", [])) + assert {"en", "de"}.issubset(locales), f"locales: {locales}" + assert acme.get("defaultLocale") == "en" - with subtest("reapplying the same config works"): - machine.succeed("systemctl restart declarative-keycloak.service") + with subtest("nested-in-nested blocks (security_defenses + otp_policy) applied"): + headers = acme.get("browserSecurityHeaders", {}) + assert headers.get("xFrameOptions") == "DENY" + assert headers.get("strictTransportSecurity", "").startswith("max-age=63072000") + assert acme.get("failureFactor") == 5 + assert acme.get("otpPolicyAlgorithm") == "HmacSHA256" + + with subtest("realm RSA keystore appears in the keys endpoint"): + keys = admin_get(keycloak, "acme/keys") + assert any( + k.get("algorithm") == "RS256" and k.get("status") == "ACTIVE" + for k in keys.get("keys", []) + ), f"RS256 ACTIVE key missing: {keys}" - # simulate a subsequent deployment with another realm - machine.succeed( - "/run/current-system/specialisation/addRealm/bin/switch-to-configuration test" - ) + with subtest("required_action CONFIGURE_TOTP is disabled"): + ras = admin_get(keycloak, "acme/authentication/required-actions") + totp = next((r for r in ras if r.get("alias") == "CONFIGURE_TOTP"), None) + assert totp, f"CONFIGURE_TOTP not found: {[r.get('alias') for r in ras]}" + assert totp.get("enabled") is False, f"CONFIGURE_TOTP should be disabled: {totp}" - with subtest("new realm can be deployed"): - machine.wait_until_succeeds("curl --fail http://localhost:8080/realms/delta") - delta = get_realm("delta") - assert delta.get("displayName") == "Delta Realm", \ - f"delta realm display_name not applied: {delta}" + with subtest("realm localization message reaches the API"): + texts = admin_get(keycloak, "acme/localization/en") + assert texts.get("loginAccountTitle") == "ACME", f"localization texts: {texts}" + + with subtest("realm_user_profile attribute[].permissions block wrap works"): + up = admin_get(keycloak, "acme/users/profile") + attrs = {a["name"]: a for a in up.get("attributes", [])} + assert "team" in attrs, f"team attribute missing: {list(attrs)}" + team_perms = attrs["team"].get("permissions", {}) + assert set(team_perms.get("view", [])) == {"admin", "user"}, \ + f"team view: {team_perms}" + assert set(team_perms.get("edit", [])) == {"admin"}, \ + f"team edit: {team_perms}" + assert "length" in attrs["username"].get("validations", {}), \ + "length validator missing on username" + assert up.get("unmanagedAttributePolicy") == "ENABLED" + ''; + }; + + # Identity providers + IdP mappers + an authentication flow. + keycloak-idp = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-idp"; + + containers.keycloak = mkHost { + extraEtc."acme-google-secret".text = "fakesecret"; + runtime = { + realms.acme.display_name = "ACME"; + + # google IdP exercises realmAliasRef and secret-file indirection. + oidc_google_identity_providers.acme_google = { + realm = "acme"; + client_id = "fake-client-id"; + client_secretFile = "/etc/acme-google-secret"; + }; + + # IdP mapper exercises idpAliasRequiredRef across IdP collections. + attribute_importer_identity_provider_mappers.google_email = { + realm = "acme"; + identity_provider = "acme_google"; + name = "google-email"; + user_attribute = "email"; + claim_name = "email"; + }; + + authentication_flows.acme_passkey = { + realm = "acme"; + alias = "acme-passkey"; + description = "Passkey login flow"; + }; + }; + }; + + testScript = '' + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") + + with subtest("google IdP exists, client_secret kept out of .tf.json"): + # alias defaults to the collection key (`acme_google`) via nameAttr; + # the provider sets providerId="google". + idp = admin_get(keycloak, "acme/identity-provider/instances/acme_google") + assert idp.get("providerId") == "google", f"idp: {idp}" + assert idp.get("alias") == "acme_google" + tfjson = keycloak.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "fakesecret" not in tfjson, \ + "google IdP client_secret leaked into .tf.json" + + with subtest("IdP mapper attached to google via managed alias ref"): + mappers = admin_get(keycloak, "acme/identity-provider/instances/acme_google/mappers") + m = next((x for x in mappers if x["name"] == "google-email"), None) + assert m, f"google-email mapper missing: {mappers}" + assert m.get("identityProviderAlias") == "acme_google", f"mapper: {m}" + assert m.get("config", {}).get("user.attribute") == "email", f"mapper: {m}" + + with subtest("authentication flow exists with description"): + flows = admin_get(keycloak, "acme/authentication/flows") + flow = next((f for f in flows if f.get("alias") == "acme-passkey"), None) + assert flow, f"acme-passkey flow missing: {[f.get('alias') for f in flows]}" + assert flow.get("description") == "Passkey login flow" ''; }; } From d17ca399d9ae67d09087b61357d256c593e4aba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 10:10:19 +0200 Subject: [PATCH 32/50] docs(services/keycloak,modules/lib): simpler comment wording Trim the inline comments throughout the keycloak pairing and modules/lib/default.nix. Plain words over jargon ("wait for" instead of "gate on", "reuse a client" instead of "tolerate ... from a partial earlier bootstrap", "shared" instead of "provider-agnostic"), drop the multi-paragraph file-header preamble in modules/lib, and shorten every option description that ran to two or more sentences. Slips in one small correctness fix: ldap_user_federations.blockAttrs now lists `kerberos` and `cache` so the nested sub-blocks emit as `[{ ... }]`. No fixture exercises this today, so the tests are byte-identical. --- modules/lib/default.nix | 95 +++++++++---------- services/keycloak/checks.nix | 61 ++++++------ services/keycloak/lib.nix | 175 ++++++++++++++++------------------- services/keycloak/module.nix | 79 +++++++--------- 4 files changed, 190 insertions(+), 220 deletions(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 92dc8b3..205ec51 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -1,48 +1,42 @@ -# Shared, provider-agnostic helpers for the declarative-service pairings: the -# .tf.json label/file helpers and the run-once OpenTofu reconciler unit. -# -# Provider-specific pieces — the provider-wrapped executor, the .tf.json -# provider/resource generation, the token variable name — live in each pairing's -# services//lib.nix and are injected into the helpers below. +# shared helpers for the pairings: tf-label/file helpers and the run-once +# reconciler unit. provider-specific bits live in services//lib.nix. { pkgs }: let inherit (pkgs) lib; in rec { - # Sanitize an arbitrary string into a valid Terraform block label - # ([A-Za-z_][A-Za-z0-9_-]*). Always prefixed, so the result starts with a - # letter regardless of the input. + # turn an arbitrary string into a valid Terraform block label. always + # prefixed so the result starts with a letter. tfLabel = prefix: name: "${prefix}_" + lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_-]" c != null then c else "_") name; - # Render a config attrset to a .tf.json store file. Safe by construction: the - # config must carry no secrets (anything in the store is world-readable). + # write the config as a .tf.json file in the nix store. must contain no + # secrets -- the store is world-readable. tfJsonFile = name: config: pkgs.writeText "${name}.tf.json" (builtins.toJSON config); - # Build the run-once reconciler systemd service definition. + # build the run-once reconciler systemd service. # - # name unit + generated-config name (e.g. "declarative-forgejo") - # tfConfig the provider-specific config attrset to apply - # afterUnits units to order/require after (the service's primary unit) - # healthUrl URL polled until the service answers, before applying - # tokenFile runtime path to the admin token, exposed via LoadCredential - # executor OpenTofu wrapped with the pairing's provider (offline mirror) - # tokenVar Terraform input variable carrying the token; also the - # LoadCredential id, so `TF_VAR_` is fed from it - # credentials extra TF_VAR_ -> host file path pairs (per-resource - # secrets); each is LoadCredential'd and exported like the token - # user/group the base service's user/group the reconciler runs as - # stateDir the base service's primary state dir; Terraform state lives in - # a `declarative-terraform` subdir of it, co-located with the service - # dynamicUser set when the base service runs as systemd DynamicUser (so - # the `User=` name only exists per-unit). The reconciler then - # runs with `DynamicUser=true` too, so systemd allocates the - # same hashed UID as the primary unit, and the work dir is - # created via `StateDirectory=` (derived from `stateDir`) - # rather than `mkdir`. Requires `stateDir` to live under - # `/var/lib`; enforced at eval time. + # name unit + generated-config name (e.g. "declarative-forgejo") + # tfConfig the .tf.json config to apply + # afterUnits units to order/require after (the service's main unit) + # healthUrl url polled until the service answers, before applying + # tokenFile path to the admin token on the host, read via LoadCredential + # executor OpenTofu wrapped with the pairing's provider (offline) + # tokenVar name of the sensitive tf variable carrying the token; also + # the LoadCredential id -- exported as TF_VAR_ + # credentials extra TF_VAR_ -> host path pairs for per-resource + # secrets, handled the same way as tokenFile + # user/group the service's user/group; the reconciler runs as them + # stateDir base dir for the reconciler; tfstate lives in a + # `declarative-terraform` subdir of it + # dynamicUser set when the base service uses systemd DynamicUser=true + # (the `User=` name only exists per-unit). the reconciler + # then runs with DynamicUser=true too, so it picks up the + # same hashed UID as the main unit, and systemd creates + # the work dir via StateDirectory= (derived from stateDir). + # requires stateDir to live under /var/lib. mkReconcileService = { name, @@ -60,19 +54,17 @@ rec { }: let confFile = tfJsonFile name tfConfig; - # Admin token + any per-resource secret files, each kept out of the store - # and exposed to tofu as TF_VAR_ via systemd LoadCredential=. + # admin token + per-resource secrets, all read via LoadCredential + # so they never land in the world-readable store. allCredentials = { ${tokenVar} = tokenFile; } // credentials; - # Terraform state is co-located with the base service: a subdir of its - # primary state directory, created and owned by the service user. + # tfstate lives in this subdir of stateDir. workDir = "${stateDir}/declarative-terraform"; - # Under DynamicUser= the absolute work dir is also expressed as a - # relative `StateDirectory=` so systemd creates and owns it. Deriving - # both from `stateDir` is the single source of truth: the path the - # script `cd`s into and the path the unit declares can never drift. + # under DynamicUser= systemd creates the dir via StateDirectory= + # (relative to /var/lib). derive both from stateDir so the script + # path and the unit declaration cannot drift. stateDirectoryRelative = lib.removePrefix "/var/lib/" workDir; in assert lib.assertMsg (!dynamicUser || lib.hasPrefix "/var/lib/" stateDir) @@ -82,7 +74,7 @@ rec { after = afterUnits; requires = afterUnits; wantedBy = [ "multi-user.target" ]; - # Re-apply whenever the generated configuration changes. + # re-apply when the generated config changes. restartTriggers = [ confFile ]; path = [ executor @@ -96,17 +88,16 @@ rec { serviceConfig = { Type = "oneshot"; RemainAfterExit = true; - # Run as the base service's user so Terraform state can live in (and be - # backed up alongside) that service's primary state directory. + # run as the service's own user so tfstate sits next to its data. User = user; Group = group; - # Secrets stay out of the store: read from the credentials dir at runtime. + # secrets stay out of the store; loaded into $CREDENTIALS_DIRECTORY at runtime. LoadCredential = lib.mapAttrsToList (id: path: "${id}:${path}") allCredentials; } // lib.optionalAttrs dynamicUser { - # Bases like Keycloak ship no persistent state dir and run as - # systemd DynamicUser=true; the `User=` name is hashed to a stable UID - # that's reused across units, and systemd creates/owns the state dir. + # services like Keycloak use DynamicUser=true and have no + # persistent state dir. systemd hashes the User= name to a + # stable UID that's shared across units and owns the state dir. DynamicUser = true; StateDirectory = stateDirectoryRelative; StateDirectoryMode = "0700"; @@ -115,15 +106,15 @@ rec { set -euo pipefail umask 077 - # Work in a Terraform state dir under the base service's primary state - # directory, created 0700 on first run and owned by the service user. + # work in a subdir of the service's state dir; created 0700 on + # first run, owned by the service user. mkdir -p ${lib.escapeShellArg workDir} cd ${lib.escapeShellArg workDir} - # Refresh the generated config (state persists across runs). + # refresh the generated config (tfstate persists across runs). install -m 0600 ${confFile} ./main.tf.json - # Gate on the service actually answering before applying. + # wait for the service to actually answer before applying. for _ in $(seq 1 60); do if curl -fsS -o /dev/null "${healthUrl}"; then break @@ -131,7 +122,7 @@ rec { sleep 2 done - # Feed every credential to tofu as TF_VAR_, read from the creds dir. + # pass each credential to tofu as TF_VAR_. for id in ${lib.escapeShellArgs (lib.attrNames allCredentials)}; do export "TF_VAR_$id=$(cat "$CREDENTIALS_DIRECTORY/$id")" done diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 1c2083a..a18dcf6 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -1,13 +1,12 @@ -# Per-resource-family keycloak tests. The `keycloak` test is a full VM -# (specialisations only work in QEMU nodes); the rest are nspawn containers -# for faster boot and tighter focus. +# keycloak tests, one per resource family. the `keycloak` test boots a +# full VM (specialisations need QEMU). the rest run as nspawn containers. { pkgs, self }: let inherit (pkgs) lib; keycloakAdminPassword = "hackme"; - # Python helpers; each takes the machine reference (`machine` for VMs, - # `keycloak` for containers) so the body is identical across both shapes. + # python helpers; each takes the machine reference (`machine` in the VM + # test, `keycloak` in the container tests) so bodies match. pyHelpers = '' import json def admin_token(m): @@ -28,8 +27,8 @@ let return admin_get(m, realm) ''; - # Common keycloak service config every test reuses. `runtime` carries - # the per-test resource fixture; `extraEtc` mocks operator secret files. + # shared keycloak host config. `runtime` is the per-test fixture; + # `extraEtc` mocks operator-supplied secret files. mkHost = { runtime, @@ -58,7 +57,7 @@ let settings = { hostname = "keycloak"; http-port = 8080; - http-enabled = true; # HTTP-only test deployment + http-enabled = true; # http-only test deployment hostname-strict = false; }; @@ -72,8 +71,8 @@ let }; in { - # Core test: full VM proving the boot -> bootstrap -> reconcile chain - # plus config-change reconciliation via a specialisation. + # core test: full vm covering boot -> bootstrap -> reconcile, plus a + # specialisation switch that adds a second realm. keycloak = pkgs.testers.runNixOSTest { name = "declarative-keycloak"; @@ -87,7 +86,7 @@ in }; } args) { - # keycloak is thicc -- only VMs accept memorySize. + # keycloak is thicc; only VMs accept memorySize. virtualisation.memorySize = 3072; specialisation.addRealm.configuration.services.keycloak.runtime.realms.delta = { display_name = "Delta Realm"; @@ -98,7 +97,7 @@ in ${pyHelpers} machine.start() - # whole chain (keycloak -> bootstrap -> reconciler) must converge. + # wait for the chain: keycloak -> bootstrap -> reconciler. machine.wait_for_unit("declarative-keycloak.service") with subtest("declared realm exists with display_name applied"): @@ -138,7 +137,7 @@ in ''; }; - # RBAC: roles, groups, users + bindings via managed-key list refs. + # roles, groups, users + bindings via managed-key list refs. keycloak-rbac = pkgs.testers.runNixOSTest { name = "declarative-keycloak-rbac"; @@ -243,7 +242,7 @@ in ''; }; - # OpenID clients + scopes + a protocol mapper + default-scope binding. + # openid clients + scopes + protocol mapper + default-scope binding. keycloak-clients = pkgs.testers.runNixOSTest { name = "declarative-keycloak-clients"; @@ -284,7 +283,7 @@ in ]; }; - # Protocol mapper attached to the managed scope via its key. + # protocol mapper attached to the managed scope by key. openid_user_attribute_protocol_mappers.team_claim = { realm = "acme"; client_scope = "acme_profile"; @@ -322,7 +321,7 @@ in f"acme-profile not bound as default scope: {names}" with subtest("protocol mapper attached to client scope"): - # protocolMappers travel with the client-scope representation. + # protocolMappers ride along on the client-scope representation. scopes = admin_get(keycloak, "acme/client-scopes") s = next((x for x in scopes if x["name"] == "acme-profile"), None) assert s, f"acme-profile scope missing: {[x['name'] for x in scopes]}" @@ -339,9 +338,9 @@ in ''; }; - # Realm extras: extended realm attrs, nested-secret smtp, security - # defenses (nested-in-nested), otp_policy, realm_user_profile (nested - # in list elements), a keystore, required_action, localization. + # realm extras: extended realm attrs, smtp with nested-secret, + # security_defenses (nested-in-nested), otp_policy, realm_user_profile + # (nested-in-list), a keystore, required_action, localization. keycloak-realm-extras = pkgs.testers.runNixOSTest { name = "declarative-keycloak-realm-extras"; @@ -351,7 +350,7 @@ in realms.acme = { display_name = "ACME Corp."; display_name_html = "ACME Corp."; - # extended attrs + # cross-section of the extended realm attrs. registration_allowed = true; login_theme = "keycloak"; ssl_required = "external"; @@ -365,7 +364,7 @@ in ]; default_locale = "en"; }; - # smtp_server with nested-secret indirection. + # smtp with a nested-secret indirection (auth.passwordFile). smtp_server = { host = "smtp.example.com"; from = "noreply@example.com"; @@ -376,8 +375,8 @@ in passwordFile = "/etc/acme-smtp-password"; }; }; - # nested-in-nested block-list wrap (security_defenses.headers, - # security_defenses.brute_force_detection). + # nested-in-nested block wrap (headers + brute_force_detection + # inside security_defenses). security_defenses = { headers = { x_frame_options = "DENY"; @@ -419,9 +418,9 @@ in texts.loginAccountTitle = "ACME"; }; - # realm_user_profile exercises a MaxItems:1 nested block inside a - # list element (attribute[].permissions). Keycloak refuses to drop - # the built-in attrs; declare them alongside the custom one. + # realm_user_profile exercises a nested MaxItems:1 block inside a + # list element (attribute[].permissions). keycloak refuses to drop + # the built-in attrs, so declare them alongside the custom one. realm_user_profiles.acme = { realm = "acme"; unmanaged_attribute_policy = "ENABLED"; @@ -582,7 +581,7 @@ in ''; }; - # Identity providers + IdP mappers + an authentication flow. + # identity providers + IdP mappers + an authentication flow. keycloak-idp = pkgs.testers.runNixOSTest { name = "declarative-keycloak-idp"; @@ -591,14 +590,14 @@ in runtime = { realms.acme.display_name = "ACME"; - # google IdP exercises realmAliasRef and secret-file indirection. + # google IdP exercises realm-alias resolution + secret-file indirection. oidc_google_identity_providers.acme_google = { realm = "acme"; client_id = "fake-client-id"; client_secretFile = "/etc/acme-google-secret"; }; - # IdP mapper exercises idpAliasRequiredRef across IdP collections. + # IdP mapper exercises the multi-target idp-alias ref. attribute_importer_identity_provider_mappers.google_email = { realm = "acme"; identity_provider = "acme_google"; @@ -621,8 +620,8 @@ in keycloak.wait_for_unit("declarative-keycloak.service") with subtest("google IdP exists, client_secret kept out of .tf.json"): - # alias defaults to the collection key (`acme_google`) via nameAttr; - # the provider sets providerId="google". + # alias defaults to the collection key (`acme_google`); + # providerId is fixed to "google" by the resource type. idp = admin_get(keycloak, "acme/identity-provider/instances/acme_google") assert idp.get("providerId") == "google", f"idp: {idp}" assert idp.get("alias") == "acme_google" diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 98d0e5e..ce80c77 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -8,7 +8,7 @@ let provider = pkgs.terraform-providers.keycloak_keycloak; providerVersion = provider.version; - # credential names for the (less privileged) keycloak provisioner + # tf-var names for the service-account oauth2 client the reconciler uses. tokenVar = "keycloak_client_secret"; clientIdVar = "keycloak_client_id"; @@ -79,8 +79,8 @@ let inherit description; }; - # ref spec shared by almost every non-realm resource: realm_id is a numeric - # id the user can't know, so it must resolve to a managed realm by key. + # most non-realm resources reference their realm by numeric id, which + # the user can't know up front -- resolve it by managed key instead. realmRef = { attr = "realm_id"; targets = [ @@ -120,7 +120,7 @@ let required = false; description = "Optional managed OpenID client scope this mapper attaches to."; }; - # the four common openid-mapper attrs (every mapper has at least the first 3) + # attrs every openid mapper carries (some carry only the first 3). openidMapperCommonAttrs = { name = oStr "Mapper name. Defaults to the attribute key."; add_to_id_token = oBool "Include in ID token?"; @@ -128,7 +128,7 @@ let add_to_userinfo = oBool "Include in UserInfo?"; }; - # SAML mapper attachment refs (analogous to the openid pair above). + # SAML counterparts of the openid refs above. samlClientOptionalRef = { attr = "client_id"; targets = [ @@ -154,8 +154,8 @@ let description = "Optional managed SAML client scope this mapper attaches to."; }; - # identity providers reference the realm by its alias (name), not by id; - # `realm = ""` is how the provider wires them. + # identity providers reference the realm by its alias (name) -- the + # provider's `realm` attribute, not `realm_id`. realmAliasRef = { attr = "realm"; targets = [ @@ -169,8 +169,7 @@ let description = "Key of the managed realm (services.keycloak.runtime.realms.) the IdP lives in."; }; - # LDAP mappers reference their parent federation by managed key (numeric - # id). Used by every keycloak_ldap_*_mapper resource. + # every ldap_*_mapper resolves its parent federation by id. ldapFederationIdRef = { attr = "ldap_user_federation_id"; targets = [ @@ -184,8 +183,8 @@ let description = "Key of the managed LDAP user federation (services.keycloak.runtime.ldap_user_federations.) this mapper attaches to."; }; - # identity provider mappers reference an IdP by alias; the alias may - # belong to any of the six IdP variants we model. + # IdP mappers reference an IdP by alias; the alias can belong to + # any of the six IdP collections. idpAliasRequiredRef = { attr = "identity_provider_alias"; targets = [ @@ -219,14 +218,13 @@ let description = "Alias of the managed identity provider (in any IdP collection) this mapper attaches to, or a literal alias."; }; - # common attrs every IdP mapper carries. + # attrs every IdP mapper carries. commonIdpMapperAttrs = { name = oStr "Mapper name. Defaults to the attribute key."; extra_config = oAttrsStr "Free-form extra mapper config entries."; }; - # shared attrs every keycloak identity provider exposes (alias is the IdP - # key, display_name is human-readable, enabled toggles, etc.). + # attrs every IdP exposes (alias is the IdP key, etc.). commonIdpAttrs = { alias = oStr "Provider alias. Defaults to the attribute key."; display_name = oStr "Human-readable name shown on the login page."; @@ -246,9 +244,9 @@ let org_domain = oStr "Organization domain matched against the user's email."; }; - # generic mappers / role mappers attach to either an openid or a SAML - # client/scope; multi-target so a managed key from either collection - # resolves, and a literal id string falls through. + # generic mappers attach to either an openid or a saml client/scope. + # multi-target: a managed key from either collection resolves; an + # unknown string falls through as a literal. anyClientOptionalRef = { attr = "client_id"; targets = [ @@ -282,19 +280,17 @@ let description = "Optional managed client scope (openid or saml) this mapper attaches to."; }; - # The full keycloak/keycloak resource surface. Per - # resource: - # type the `keycloak_*` resource type - # prefix unique Terraform label prefix + # every keycloak resource type we expose. each entry: + # type `keycloak_*` resource name + # prefix tf-label prefix # nameAttr attribute defaulted from the collection key (or null) - # scope reserved for future per-resource scoping; null under - # client-credentials auth - # refs parent links resolved to references against managed - # siblings - # secrets secret-valued attributes gaining an `File` form - # requiredSecrets secrets the provider requires (one of ``/`File`) - # attrs the settable attributes, each a typed option (no - # freeform) + # scope reserved; currently unused + # refs parent links resolved to managed siblings + # blockAttrs dotted paths that wrap as `[ {...} ]` (MaxItems:1) + # secrets attrs that gain an `File` sibling + # requiredSecrets secrets that must be set as literal or File + # requiredAttrs attrs that must be set non-empty + # attrs settable attributes, all typed (no freeform) resourceTypes = { realms = { type = "keycloak_realm"; @@ -382,12 +378,9 @@ let default_default_client_scopes = oListStr "Default client scopes auto-granted to new clients."; default_optional_client_scopes = oListStr "Optional client scopes available to new clients."; - # nested blocks: rendered as [{ ... }] via the `blockAttrs` markup. - # Nested-Sensitive fields (smtp_server.auth.password, - # smtp_server.token_auth.client_secret) can be supplied either as - # a literal (lands in the world-readable store) or via the matching - # `File` sibling (host path resolved at apply time through - # systemd LoadCredential=, never copied to the store). + # nested blocks; emitted as `[{ ... }]` via blockAttrs. + # nested secrets (smtp.auth.password, smtp.token_auth.client_secret) + # accept either a literal or an `File` host path. smtp_server = oSub { host = rStr "SMTP host."; from = rStr "From address."; @@ -675,10 +668,9 @@ let refs.realm = realmRef; requiredAttrs = [ "username" ]; blockAttrs = [ "initial_password" ]; - # initial_password.value is Sensitive but supplied LITERAL today - # (nested-File support is a follow-up); avoid setting a real - # password until that lands. federated_identity is a TypeSet of - # nested blocks -- renders as a JSON array directly, no wrap needed. + # initial_password.value supports the `valueFile` indirection; + # federated_identity is a list of nested blocks (rendered as a + # JSON array, no wrap needed). description = "Keycloak users, keyed by username (must be lowercase)."; attrs = { username = oStr "Username (lowercase). Defaults to the attribute key."; @@ -829,8 +821,9 @@ let "authorization" "authentication_flow_binding_overrides" ]; - # Skips the write-only secret variants (client_secret_wo / - # client_secret_wo_version) -- those need a write-only renderer mode. + # write-only secret variants (client_secret_wo / + # client_secret_wo_version) are skipped -- they need a separate + # write-only renderer mode. description = "OpenID Connect clients (per-realm), keyed by clientId."; attrs = { client_id = oStr "OAuth2 clientId. Defaults to the attribute key."; @@ -1008,7 +1001,7 @@ let ]; description = "Grant a per-client role to a service-account user, keyed by an arbitrary label."; attrs = { - # Computed from the source client (`${keycloak_openid_client.X.service_account_user_id}`). + # supply via `${keycloak_openid_client..service_account_user_id}` service_account_user_id = oStr "Service-account user id (typically `\${keycloak_openid_client.X.service_account_user_id}`)."; role = oStr "Name of the role granted (must exist on the target client)."; }; @@ -1037,9 +1030,8 @@ let nameAttr = "client_id"; scope = null; refs.realm = realmRef; - # signing_private_key isn't marked Sensitive by the provider but is a - # private key in practice; expose File so operators can keep it - # out of the world-readable store. + # signing_private_key isn't marked Sensitive upstream but is a + # private key; expose File so it stays out of the store. secrets = [ "signing_private_key" ]; blockAttrs = [ "authentication_flow_binding_overrides" ]; description = "SAML clients (per-realm), keyed by clientId."; @@ -1132,8 +1124,8 @@ let attrs = { }; }; - # OpenID protocol mappers: each is its own resource type, keyed by the - # mapper name; all share the same realm + (client | client_scope) refs. + # OpenID protocol mappers: one collection per mapper type. all share + # realm + (client | client_scope) refs. openid_user_attribute_protocol_mappers = { type = "keycloak_openid_user_attribute_protocol_mapper"; prefix = "openid_user_attribute_mapper"; @@ -2320,9 +2312,10 @@ let "connection_url" "users_dn" ]; - # `kerberos` and `cache` are TypeList+MaxItems:1 nested blocks -- - # declared as oSub here, but their JSON emission will be wrong - # until R1 (block-list wrapping) lands. Avoid setting them. + blockAttrs = [ + "kerberos" + "cache" + ]; description = "LDAP user federations (per-realm), keyed by name."; attrs = { name = oStr "Federation name. Defaults to the attribute key."; @@ -2362,14 +2355,14 @@ let server_principal = oStr "Kerberos service principal of the LDAP server."; key_tab = oStr "Path to the keytab file."; use_kerberos_for_password_authentication = oBool "Use Kerberos for password auth."; - } "Kerberos integration sub-block. Needs R1 (block-list wrapping) to emit correctly."; + } "Kerberos integration."; cache = oSub { policy = oStr "Cache policy ('DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE')."; max_lifespan = oStr "Max lifespan (for MAX_LIFESPAN)."; eviction_day = oStr "Eviction day (for EVICT_WEEKLY)."; eviction_hour = oStr "Eviction hour."; eviction_minute = oStr "Eviction minute."; - } "Cache configuration sub-block. Needs R1 (block-list wrapping) to emit correctly."; + } "Cache configuration."; }; }; @@ -2706,8 +2699,8 @@ let nameAttr = "name"; scope = null; refs.realm = realmRef; - # private_key and certificate are PEM material; provider doesn't mark - # them Sensitive but operators want them out of the world-readable store. + # private_key and certificate are PEM material; expose File + # for both even though only private_key is technically secret. secrets = [ "private_key" "certificate" @@ -2920,9 +2913,8 @@ let enabled = oBool "Is the organization enabled?"; description = oStr "Organization description."; redirect_url = oStr "Optional redirect URL for organization-aware flows."; - # domain is TypeSet of nested blocks; user provides a list of objects - # and the renderer emits as a JSON array unchanged (no blockAttrs - # wrap needed because it's already a list). + # domain is a list of nested blocks; renders as a json array, + # no blockAttrs wrap needed. domain = oListSub { name = rStr "Domain name (e.g. acme.example)."; verified = oBool "Has the domain been verified?"; @@ -2955,9 +2947,8 @@ let nameAttr = null; scope = null; refs.realm = realmRef; - # `attribute[].permissions` is a MaxItems:1 nested block inside a list - # element; the recursive wrapBlocks walks into list elements, so the - # dotted path picks it up. + # nested block inside a list element; wrapBlocks recurses through + # the list, so the dotted path matches. blockAttrs = [ "attribute.permissions" ]; description = "Per-realm user-profile schema (attribute declarations + groups). Keyed by an arbitrary label (one resource per realm)."; attrs = { @@ -3065,7 +3056,7 @@ let description = "Key of the managed group these fine-grained permissions apply to."; }; }; - # every scope_* attr is a MaxItems:1 nested block per scopePermissionsSchema(). + # every scope_* attr is a MaxItems:1 nested block. blockAttrs = [ "view_scope" "manage_scope" @@ -3228,12 +3219,12 @@ let else v; - # build JSON config and credential map (id -> host path) - # secrets are provided at apply time + # build the .tf.json + the credentials map (id -> host path). + # secrets are passed in via $CREDENTIALS_DIRECTORY at apply time. keycloakTfConfig = cfg: let - # Var-safe id (Terraform variable name + LoadCredential id) for a secret. + # make a string valid as a tf variable / LoadCredential id. varSafe = lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_]" c != null then c else "_"); secretId = spec: key: attr: @@ -3262,13 +3253,12 @@ let else val; - # Walk a (cleaned) value tree, replacing every `File = "/path"` - # with ` = "${var.}"` and collecting `[{ id; file; }]` - # entries. Works at any depth -- top-level attrs, nested submodules - # and inside list elements. Throws when both `` and `File` - # are set on the same object. The id uses a dotted-path-encoded - # suffix so nested secrets (e.g. smtp_server.auth.password) get a - # unique var name (`secret_realm_acme_smtp_server_auth_password`). + # walk the value tree, swap every `File = "/path"` for + # ` = "${var.}"` and collect [{ id; file; }] entries. + # works at any depth (top-level attrs, nested submodules, list + # elements). throws if both `` and `File` are set. + # the id uses a dotted-path suffix so nested secrets get a unique + # name like `secret_realm_acme_smtp_server_auth_password`. substituteSecrets = spec: key: let @@ -3300,8 +3290,8 @@ let } ) fileEntries ); - # Walk each existing key: drop *File entries; for bare attrs - # in fileMap, replace with ${var.}; otherwise recurse. + # walk each key: drop `*File` entries; for bare attrs in + # fileMap, replace with `${var.}`; otherwise recurse. processed = lib.concatMapAttrs ( k: x: if lib.hasSuffix "File" k then @@ -3311,8 +3301,8 @@ let else { ${k} = (go (pathParts ++ [ k ]) x).value; } ) v; - # Synthesize bare attrs from fileMap that aren't present - # in v (i.e. user only supplied File, no literal). + # add bare attrs from fileMap that aren't already in v + # (user supplied `File` but no literal). synthesized = lib.listToAttrs ( map (a: lib.nameValuePair a fileMap.${a}.ref) ( builtins.filter (a: !(v ? ${a})) (builtins.attrNames fileMap) @@ -3357,9 +3347,8 @@ let renderItem = c: spec: key: item: let - # Drop ref virtuals (their values get re-injected via refAttrs). - # *File siblings are NOT dropped here -- substituteSecrets handles - # them via the value-tree walk after cleanNulls. + # drop ref keys (re-injected as refAttrs below). *File siblings + # are kept -- substituteSecrets handles them after cleanNulls. virtuals = builtins.attrNames spec.refs; base = removeAttrs item ([ "_module" ] ++ virtuals); nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { @@ -3375,7 +3364,7 @@ let if refSpec.list or false then map (resolveRef refSpec) v else resolveRef refSpec v; } ) spec.refs; - # A required secret must be supplied via either the literal or its file. + # required secret: literal or `File` must be set. reqSecretChecks = map ( attr: if (item.${attr} or null) == null && (item.${attr + "File"} or null) == null then @@ -3383,10 +3372,9 @@ let else null ) (spec.requiredSecrets or [ ]); - # Required map/list attributes: the module system gives `attrsOf`/ - # `listOf` an empty-value default ({}/[]) rather than treating a - # missing value as undefined, so a "required" collection is enforced - # here. + # required map / list attrs: nixos modules default attrsOf / listOf + # to {} / [] rather than treating "unset" as undefined; enforce + # non-empty here. reqAttrChecks = map ( attr: let @@ -3397,12 +3385,11 @@ let else null ) (spec.requiredAttrs or [ ]); - # wrap TypeList+MaxItems:1 nested blocks in [ obj ] so Terraform - # JSON gets block syntax. spec.blockAttrs lists dotted paths - # (e.g. "smtp_server", "security_defenses.headers", - # "attribute.permissions"); recursion walks into both attrsets - # and list elements -- so a nested block inside a list element - # (like realm_user_profile.attribute[].permissions) is wrapped. + # wrap nested MaxItems:1 blocks in `[ obj ]` so terraform reads + # them as blocks. spec.blockAttrs lists dotted paths (e.g. + # "smtp_server", "security_defenses.headers", + # "attribute.permissions"). recurses through attrsets and list + # elements, so a block inside a list element is wrapped too. wrapBlocks = path: v: if builtins.isAttrs v then @@ -3425,8 +3412,9 @@ let substituted = substituteSecrets spec key cleaned; wrapped = wrapBlocks "" substituted.value; in - # use deepSeq to force evaluation of checks - # (these are not config.assertions so they can be used outside a nixos system build) + # deepSeq forces the checks to run. + # (they're not nixos assertions because we generate the .tf.json + # outside a full system build too.) builtins.deepSeq [ reqSecretChecks reqAttrChecks ] { label = tfLabel spec.prefix key; value = wrapped; @@ -3434,7 +3422,7 @@ let }; nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; - # Per-collection: [ { label; value; secrets } ... ] for each managed item. + # for each collection: [ { label; value; secrets } ... ]. renderedPerCollection = lib.mapAttrs ( c: items: lib.mapAttrsToList (key: item: renderItem c resourceTypes.${c} key item) items ) (lib.intersectAttrs nonEmpty cfg); @@ -3445,7 +3433,8 @@ let ) ) renderedPerCollection; - # combine sensitive variables with (id -> host path) credential map + # every secret across the config (for sensitive tf vars + the + # id -> host path map fed to LoadCredential). allSecrets = lib.concatLists ( lib.concatLists (lib.mapAttrsToList (_: items: map (r: r.secrets) items) renderedPerCollection) ); diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index 5220638..12392e3 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -19,9 +19,9 @@ let defaultBaseUrl = "http://localhost:${toString keycloak.settings.http-port}"; - # Companion oneshot that creates the reconciler's service-account OIDC - # client. Only used when the operator does not supply their own - # (clientIdFile, clientSecretFile) pair. + # one-shot that creates the reconciler's service-account oauth2 client + # on first boot. skipped when the operator supplies their own + # (clientIdFile, clientSecretFile). bootstrapServiceName = "declarative-keycloak-bootstrap"; bootstrapClient = cfg.clientIdFile == null; effectiveClientIdFile = @@ -34,15 +34,14 @@ in { options.services.keycloak.runtime = { enable = mkEnableOption ( - "declarative Keycloak configuration, applied via OpenTofu and the keycloak/keycloak " - + "provider after keycloak.service starts" + "declarative keycloak runtime config, applied via OpenTofu after keycloak.service starts" ); baseUrl = mkOption { type = types.str; default = defaultBaseUrl; defaultText = literalExpression ''"http://localhost:''${toString config.services.keycloak.settings.http-port}"''; - description = "Base URL of the local Keycloak admin API the provider targets."; + description = "Base URL of the local keycloak admin API."; }; bootstrapAdminPasswordFile = mkOption { @@ -50,16 +49,13 @@ in default = null; example = "/run/secrets/keycloak-admin-password"; description = '' - Host path to a file containing the password of an existing - realm-admin user (default `admin`) in the `master` realm. The - path is resolved on the target host (e.g. provisioned by sops-nix - or agenix), NOT a store path: it is handed to the bootstrap - oneshot via systemd `LoadCredential=` and never copied into the - Nix store. - - Required when `clientIdFile`/`clientSecretFile` are unset (the - default), since the bootstrap uses this admin to mint a dedicated - service-account client. Ignored when both are supplied. + Host path to a file with the master-realm admin password (the + user `admin` by default). Read via systemd `LoadCredential=`; + never copied into the nix store. + + Required when `clientIdFile` / `clientSecretFile` are unset -- + the bootstrap uses this admin to mint the service-account client. + Ignored otherwise. ''; }; @@ -67,11 +63,10 @@ in type = types.str; default = "declarative-keycloak"; description = '' - Service-account client `clientId` the bootstrap oneshot creates - in the `master` realm. The matching service-account user is - granted the `realm-admin` role of the `realm-management` client - so the reconciler can manage every realm. Unused when - `clientIdFile`/`clientSecretFile` are set directly. + clientId of the service-account client the bootstrap creates in + the `master` realm. Its service-account user gets the realm-level + `admin` role so the reconciler can manage every realm. Unused + when `clientIdFile` / `clientSecretFile` are set directly. ''; }; @@ -80,10 +75,9 @@ in default = null; example = "/run/secrets/keycloak-tf-client-id"; description = '' - Host path to a file containing the OIDC `client_id` the - reconciler authenticates as. Set together with `clientSecretFile` - to bypass the bootstrap and supply your own service-account - client. + Host path to a file with the oauth2 `client_id` the reconciler + uses. Set together with `clientSecretFile` to skip the bootstrap + and supply your own service-account client. ''; }; @@ -92,9 +86,9 @@ in default = null; example = "/run/secrets/keycloak-tf-client-secret"; description = '' - Host path to a file containing the OIDC `client_secret` paired - with `clientIdFile`. Read via systemd `LoadCredential=`, never - copied into the store. + Host path to a file with the oauth2 `client_secret` paired with + `clientIdFile`. Read via systemd `LoadCredential=`; never copied + into the store. ''; }; } @@ -131,10 +125,9 @@ in tokenFile = effectiveClientSecretFile; user = "keycloak"; group = "keycloak"; - # Upstream keycloak runs as DynamicUser=true with no persistent - # state dir; allocate a dedicated one owned by the same hashed UID - # via systemd StateDirectory= (derived by mkReconcileService from - # stateDir, which must live under /var/lib for that derivation). + # upstream keycloak uses DynamicUser=true and has no state dir. + # mkReconcileService will create /var/lib/keycloak via + # StateDirectory= and reuse the same hashed UID as keycloak.service. stateDir = "/var/lib/keycloak"; dynamicUser = true; }; @@ -152,9 +145,8 @@ in pkgs.coreutils ]; environment = { - # kcadm.sh places its token cache under $HOME/.keycloak; under - # DynamicUser there is no real home, so direct it into the - # StateDirectory (writable and owned by the same hashed UID). + # kcadm.sh writes its token cache under $HOME/.keycloak; + # DynamicUser has no real home, so point HOME at our state dir. HOME = "/var/lib/${bootstrapServiceName}"; }; serviceConfig = { @@ -174,13 +166,13 @@ in client_id_file="$STATE_DIRECTORY/client_id" client_secret_file="$STATE_DIRECTORY/client_secret" - # the persisted credential pair is the "already bootstrapped" marker. - # /var/lib survives reboots, so this won't run on every boot after the first successful run + # the saved credential pair is the "already bootstrapped" + # marker. /var/lib persists across reboots. if [ -s "$client_id_file" ] && [ -s "$client_secret_file" ]; then exit 0 fi - # poll max 3 minutes until keycloak has fully booted + # wait up to 3 minutes for keycloak to come up. for _ in $(seq 1 90); do if curl -fsS -o /dev/null "${cfg.baseUrl}/realms/master"; then break @@ -194,7 +186,7 @@ in --user admin \ --password "$(cat "$CREDENTIALS_DIRECTORY/admin-password")" - # Tolerate a client left over from a partial earlier bootstrap. + # reuse a client left over from a partial earlier bootstrap. existing_uuid="$(kcadm.sh get clients -r master \ -q clientId=${lib.escapeShellArg cfg.clientName} \ | jq -r '.[0].id // empty')" @@ -216,9 +208,8 @@ in sa_uid="$(kcadm.sh get clients/$client_uuid/service-account-user -r master \ | jq -r .id)" - # The master realm's `admin` grants global admin across every realm; - # assigning it to the service-account user gives the reconciler full access. - # Idempotent: kcadm tolerates re-grant. + # the master-realm `admin` role grants global admin across every + # realm. kcadm tolerates re-grant. kcadm.sh add-roles -r master \ --uid "$sa_uid" \ --rolename admin @@ -226,8 +217,8 @@ in client_secret="$(kcadm.sh get clients/$client_uuid/client-secret -r master \ | jq -r .value)" - # Atomic write: rename only succeeds once both tempfiles exist, so - # the idempotency check above never observes a half-written pair. + # write tempfiles first, then rename, so the "already + # bootstrapped" check above never sees a half-written pair. printf '%s' ${lib.escapeShellArg cfg.clientName} > "$client_id_file.tmp" printf '%s' "$client_secret" > "$client_secret_file.tmp" mv "$client_id_file.tmp" "$client_id_file" From ac92e21573da4b03930fa6b1fa983d2223191710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 10:13:55 +0200 Subject: [PATCH 33/50] ci: add GitHub Actions workflow running nix flake check Uses Determinate Systems' nix-installer-action plus magic-nix-cache-action to install Nix and cache builds across runs. Triggers on push and pull_request. The single `nix flake check -L` covers the forgejo VM test, the five keycloak tests (one VM, four nspawn containers), and treefmt formatting. Standard GitHub Actions / Forgejo-Actions-compatible syntax, so the workflow ports unchanged if hosting moves. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11d4bcb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@main + + - uses: DeterminateSystems/magic-nix-cache-action@main + + - name: nix flake check + run: nix flake check -L From a0469fafa67e4a38dccf8428390ed8dce3b81cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 10:30:37 +0200 Subject: [PATCH 34/50] docs: rename project to `declarative-runtime` A descriptive name that calls out the load-bearing concept (declarative *runtime* state, distinct from build-time config). Updates: - flake.nix description. - top-level README title + tagline. - flake-input examples in services/forgejo/README.md and services/keycloak/README.md (input variable `declarative-runtime`, URL `github:/declarative-runtime`). The directory rename and GitHub repo rename are external steps; no in-tree code references the old name. --- README.md | 3 ++- flake.nix | 2 +- services/forgejo/README.md | 8 ++++---- services/keycloak/README.md | 8 ++++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f6ab1e3..d43a2ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -# Declarative NixOS services via paired Terraform providers +# declarative-runtime +**Declarative NixOS service runtime config via paired OpenTofu providers.** Make NixOS services **more declaratively configurable** than upstream Nixpkgs modules allow, by pairing each service with its Terraform provider and reconciling the service's _runtime state_ once it is up. diff --git a/flake.nix b/flake.nix index 827e62b..5357a49 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Declarative NixOS service configuration via paired Terraform providers"; + description = "declarative-runtime: declarative NixOS service runtime config via paired OpenTofu providers"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; diff --git a/services/forgejo/README.md b/services/forgejo/README.md index 8041fcd..954700f 100644 --- a/services/forgejo/README.md +++ b/services/forgejo/README.md @@ -20,17 +20,17 @@ step with the rest of your system. nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; # This repository. - declarative-services.url = "github:youruser/terraform-providers"; - declarative-services.inputs.nixpkgs.follows = "nixpkgs"; + declarative-runtime.url = "github:youruser/declarative-runtime"; + declarative-runtime.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = - { nixpkgs, declarative-services, ... }: + { nixpkgs, declarative-runtime, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ - declarative-services.nixosModules.forgejo + declarative-runtime.nixosModules.forgejo ./host.nix ]; }; diff --git a/services/keycloak/README.md b/services/keycloak/README.md index 62f1e08..f30c624 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -19,17 +19,17 @@ step with the rest of your system. inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - declarative-services.url = "github:applicative-systems/terraform-providers"; - declarative-services.inputs.nixpkgs.follows = "nixpkgs"; + declarative-runtime.url = "github:applicative-systems/declarative-runtime"; + declarative-runtime.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = - { nixpkgs, declarative-services, ... }: + { nixpkgs, declarative-runtime, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ - declarative-services.nixosModules.keycloak + declarative-runtime.nixosModules.keycloak ./host.nix ]; }; From e8d16247a3a224fb4d8cc6017e82403f60e5ecea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 10:32:00 +0200 Subject: [PATCH 35/50] ci: enable auto-allocate-uids + cgroups for nspawn container tests The 4 keycloak container tests need systemd-nspawn's UID-range allocation (gated behind the auto-allocate-uids experimental feature) plus the cgroups feature + use-cgroups setting for nspawn's container management. Wire both into the installer action's extra-conf. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11d4bcb..ca189e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,12 @@ jobs: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main + with: + # nspawn container tests need auto-allocate-uids + cgroups. + extra-conf: | + extra-experimental-features = auto-allocate-uids cgroups + auto-allocate-uids = true + use-cgroups = true - uses: DeterminateSystems/magic-nix-cache-action@main From dd7ef2e1acdaaf3da09b017c708ecd1e8edbd718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 10:47:37 +0200 Subject: [PATCH 36/50] docs(README): cover the keycloak pairing alongside forgejo - Status block lists both pairings with their resource-count scope and headline features (keycloak's bootstrap + nested File). - Top-of-file example shows a forgejo and a keycloak runtime side-by-side so the per-pairing usage is visible from the entry README. - Usage section links the keycloak README and mentions the bootstrap vs operator-supplied paths. - Repository-layout tree adds the keycloak/ directory. --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d43a2ae..45e1eeb 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,17 @@ Make NixOS services **more declaratively configurable** than upstream Nixpkgs modules allow, by pairing each service with its Terraform provider and reconciling the service's _runtime state_ once it is up. -> **Status:** the pattern is implemented and the **Forgejo pairing is the -> worked reference** (see [`services/forgejo`](services/forgejo/README.md)). +> **Status:** two pairings implemented. +> +> - **[Forgejo](services/forgejo/README.md)** — 15 resource types +> (organizations, users, repositories, teams, action secrets/variables, +> webhooks, branch protection, SSH/GPG/deploy keys, collaborators). +> - **[Keycloak](services/keycloak/README.md)** — ~95 resource types +> (realms, clients, scopes, ~20 protocol mappers, identity providers, +> IdP mappers, roles, groups, users, authentication flows, fine-grained +> authorization + policies, LDAP federation + mappers, realm keystores, +> realm-level config). Includes a service-account bootstrap and nested +> `File` indirection for every secret attribute at any depth. ## The gap this closes @@ -32,6 +41,24 @@ services.forgejo = { }; }; }; + +services.keycloak = { + enable = true; + initialAdminPassword = "REPLACE_ME"; + database.passwordFile = "/run/secrets/keycloak-db-password"; + runtime = { + enable = true; + bootstrapAdminPasswordFile = "/run/secrets/keycloak-admin-password"; + realms.staff.display_name = "Staff SSO"; + openid_clients.app = { + realm = "staff"; + client_id = "app"; + access_type = "CONFIDENTIAL"; + client_secretFile = "/run/secrets/staff-app-client-secret"; + valid_redirect_uris = [ "https://app.example.com/*" ]; + }; + }; +}; ``` A pairing only makes sense when a service has **admin-declarative runtime state @@ -54,11 +81,15 @@ unit_ visibly (`systemctl status`) without tearing down the service. ## Usage Add this flake as an input and import the pairing's NixOS module -(`nixosModules.forgejo`, or `nixosModules.default` for all pairings). Full -installation, configuration examples, the option reference, the resource table, -and the secrets guide live in the per-pairing README: +(`nixosModules.forgejo`, `nixosModules.keycloak`, or +`nixosModules.default` for all pairings). Full installation, configuration +examples, the option reference, the resource table, and the secrets guide +live in the per-pairing README: - [Forgejo pairing](services/forgejo/README.md) +- [Keycloak pairing](services/keycloak/README.md) — includes the + service-account bootstrap flow and the operator-supplied client + override. ### Secrets @@ -72,18 +103,23 @@ path — prefer it over the literal for any real secret. ## Repository layout ``` -flake.nix # outputs: nixosModules, checks, formatter -treefmt.nix # treefmt + nixfmt config +flake.nix # outputs: nixosModules, checks, formatter +treefmt.nix # treefmt + nixfmt config modules/ - default.nix # aggregates per-pairing modules into nixosModules.default - lib/ # provider-agnostic helpers: tf-label/file, run-once reconciler -services/ # one directory per service<->provider pairing - forgejo/ # the worked Forgejo <-> svalabs/forgejo pairing - module.nix # NixOS module: services.forgejo.runtime + systemd wiring - lib.nix # provider specifics: wrapped executor + .tf.json generation - pkg.nix # vendor the provider (not in nixpkgs) - checks.nix # NixOS VM test - README.md # usage docs + default.nix # aggregates per-pairing modules into nixosModules.default + lib/ # shared helpers: tf-label/file, run-once reconciler +services/ # one directory per service<->provider pairing + forgejo/ # Forgejo <-> svalabs/forgejo + module.nix # NixOS module: services.forgejo.runtime + systemd wiring + lib.nix # provider specifics: wrapped executor + .tf.json generation + pkg.nix # vendor the provider (not in nixpkgs) + checks.nix # NixOS VM test + README.md # usage docs + keycloak/ # Keycloak <-> keycloak/keycloak (in nixpkgs) + module.nix # services.keycloak.runtime + reconciler + bootstrap unit + lib.nix # ~95 typed resourceTypes + the value-tree renderer + checks.nix # 1 VM + 4 nspawn-container tests, one per resource family + README.md # usage docs ``` ## Development From 92efec487cc537a3904a8f7db931bf4bf9978732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 11:54:31 +0200 Subject: [PATCH 37/50] refactor(modules/lib): hoist the renderer + helpers shared with forgejo Both `services//lib.nix` files had ~270 lines of copy-pasted renderer machinery (option helpers, resourceOptions generator, cleanNulls, the tf-config builder with resolveRef + substituteSecrets + renderItem + the credential map). Move every shared piece into `modules/lib/default.nix`: - option helpers (oStr/oBool/oInt/oListStr/oAttrsStr/oSub/oListSub/ rStr/rBool/rMapStr) -- the union of what either pairing used. - cleanNulls. - resourceOptions, now a function of `resourceTypes`. - mkTfConfig: takes resourceTypes + a per-provider record (providerName, providerSource, providerVersion, providerBlock, runtimePrefix, tokenVar, extraSensitiveVars) and returns cfg -> { config; credentials; }. The keycloak renderer (recursive substituteSecrets walk + list-of-managed-refs + blockAttrs wrapping) becomes the canonical one -- a strict superset of forgejo's flat flavour, and forgejo uses none of the extensions so behaviour is identical. Net -245 lines; no behaviour change. forgejo + the 5 keycloak tests cached green (rendered output byte-identical). services/forgejo/lib.nix and services/keycloak/lib.nix now keep only their provider import, executor, tokenVar (+ clientIdVar for keycloak), provider-specific shared refs (realmRef etc.), the resourceTypes record, and the genlib.mkTfConfig call. forgejo's dormant requiredScopes stays in forgejo's lib. --- modules/lib/default.nix | 421 ++++++++++++++++++++++++++++++++++++- services/forgejo/lib.nix | 357 +++----------------------------- services/keycloak/lib.nix | 423 +++----------------------------------- 3 files changed, 478 insertions(+), 723 deletions(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 205ec51..d78ead1 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -1,10 +1,91 @@ -# shared helpers for the pairings: tf-label/file helpers and the run-once -# reconciler unit. provider-specific bits live in services//lib.nix. +# shared helpers for the pairings: tf-label/file helpers, option helpers, +# resourceTypes -> nixos-options generator, .tf.json renderer + secret +# walker, and the run-once reconciler unit. provider-specific bits +# (resourceTypes contents, the provider block) live in services//lib.nix. { pkgs }: let inherit (pkgs) lib; + ty = lib.types; in rec { + + # --------------------------------------------------------------------------- + # option helpers + # --------------------------------------------------------------------------- + + # o* for optional, r* for required. + oStr = + description: + lib.mkOption { + type = ty.nullOr ty.str; + default = null; + inherit description; + }; + oBool = + description: + lib.mkOption { + type = ty.nullOr ty.bool; + default = null; + inherit description; + }; + oInt = + description: + lib.mkOption { + type = ty.nullOr ty.int; + default = null; + inherit description; + }; + oListStr = + description: + lib.mkOption { + type = ty.nullOr (ty.listOf ty.str); + default = null; + inherit description; + }; + oAttrsStr = + description: + lib.mkOption { + type = ty.nullOr (ty.attrsOf ty.str); + default = null; + inherit description; + }; + oSub = + options: description: + lib.mkOption { + type = ty.nullOr (ty.submodule { inherit options; }); + default = null; + inherit description; + }; + oListSub = + options: description: + lib.mkOption { + type = ty.nullOr (ty.listOf (ty.submodule { inherit options; })); + default = null; + inherit description; + }; + rStr = + description: + lib.mkOption { + type = ty.str; + inherit description; + }; + rBool = + description: + lib.mkOption { + type = ty.bool; + inherit description; + }; + rMapStr = + description: + lib.mkOption { + type = ty.attrsOf ty.str; + inherit description; + }; + + # --------------------------------------------------------------------------- + # tf-label + tf-json helpers + # --------------------------------------------------------------------------- + # turn an arbitrary string into a valid Terraform block label. always # prefixed so the result starts with a letter. tfLabel = @@ -16,6 +97,342 @@ rec { # secrets -- the store is world-readable. tfJsonFile = name: config: pkgs.writeText "${name}.tf.json" (builtins.toJSON config); + # drop null-valued attrs (unset options) and the `_module` bookkeeping + # key recursively, so the generated JSON carries only what was set. + cleanNulls = + v: + if builtins.isAttrs v then + lib.mapAttrs (_: cleanNulls) (lib.filterAttrs (_: x: x != null) (removeAttrs v [ "_module" ])) + else if builtins.isList v then + map cleanNulls v + else + v; + + # --------------------------------------------------------------------------- + # resourceTypes -> nixos options + # --------------------------------------------------------------------------- + + # one option collection per resource: attrsOf a typed submodule. options + # are the resource's typed attrs + ref inputs + `File` siblings + # for each top-level secret. no freeformType -- unknown attrs are eval + # errors. + resourceOptions = + resourceTypes: + lib.mapAttrs ( + _: spec: + lib.mkOption { + type = ty.attrsOf ( + ty.submodule { + options = + (spec.attrs or { }) + // lib.mapAttrs ( + _: refSpec: + let + base = if refSpec.list or false then ty.listOf ty.str else ty.str; + in + if refSpec.required or false then + lib.mkOption { + type = base; + description = refSpec.description; + } + else + lib.mkOption { + type = ty.nullOr base; + default = null; + description = refSpec.description; + } + ) spec.refs + // lib.listToAttrs ( + map ( + attr: + lib.nameValuePair "${attr}File" ( + lib.mkOption { + type = ty.nullOr ty.str; + default = null; + description = "Runtime path to a file holding `${attr}` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `${attr}`."; + } + ) + ) (spec.secrets or [ ]) + ); + } + ); + default = { }; + description = spec.description; + } + ) resourceTypes; + + # --------------------------------------------------------------------------- + # tf-config renderer + # --------------------------------------------------------------------------- + + # build the .tf.json + credentials map for one pairing. + # + # resourceTypes per-service resource specs + # providerName "forgejo" | "keycloak" — tf provider block name + # providerSource "svalabs/forgejo" | "keycloak/keycloak" + # providerVersion pinned to the packaged provider's version + # providerBlock cfg -> attrs (provider's tf block contents) + # runtimePrefix "services..runtime" — for error messages + # tokenVar name of the primary sensitive tf variable + # extraSensitiveVars extra sensitive-tf-var names (default []) + # + # returns: cfg -> { config; credentials; } + mkTfConfig = + { + resourceTypes, + providerName, + providerSource, + providerVersion, + providerBlock, + runtimePrefix, + tokenVar, + extraSensitiveVars ? [ ], + }: + cfg: + let + # make a string valid as a tf variable / LoadCredential id. + varSafe = lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_]" c != null then c else "_"); + secretId = + spec: key: attr: + "secret_${spec.prefix}_${varSafe key}_${attr}"; + + resolveRef = + refSpec: val: + let + tryTarget = + t: + let + tspec = resourceTypes.${t.collection}; + in + if (cfg.${t.collection} or { }) ? ${val} then + "\${" + tspec.type + "." + tfLabel tspec.prefix val + "." + t.field + "}" + else + null; + hits = builtins.filter (x: x != null) (map tryTarget refSpec.targets); + in + if hits != [ ] then + builtins.head hits + else if refSpec.managedOnly then + throw "${runtimePrefix}: reference '${val}' does not match any managed ${ + lib.concatMapStringsSep " or " (t: t.collection) refSpec.targets + }" + else + val; + + # walk the value tree, swap every `File = "/path"` for + # ` = "${var.}"` and collect [{ id; file; }] entries. + # works at any depth (top-level attrs, nested submodules, list + # elements). throws if both `` and `File` are set. + substituteSecrets = + spec: key: + let + mkId = pathParts: secretId spec key (varSafe (lib.concatStringsSep "_" pathParts)); + go = + pathParts: v: + if builtins.isAttrs v then + let + fileKeys = builtins.filter (k: lib.hasSuffix "File" k) (builtins.attrNames v); + fileEntries = map ( + k: + let + attr = lib.removeSuffix "File" k; + id = mkId (pathParts ++ [ attr ]); + in + { + inherit attr id; + file = v.${k}; + bareConflict = v ? ${attr}; + } + ) fileKeys; + conflict = builtins.filter (e: e.bareConflict) fileEntries; + fileMap = lib.listToAttrs ( + map ( + e: + lib.nameValuePair e.attr { + inherit (e) id; + ref = "\${var.${e.id}}"; + } + ) fileEntries + ); + # walk each key: drop `*File` entries; for bare attrs in + # fileMap, replace with `${var.}`; otherwise recurse. + processed = lib.concatMapAttrs ( + k: x: + if lib.hasSuffix "File" k then + { } + else if fileMap ? ${k} then + { ${k} = fileMap.${k}.ref; } + else + { ${k} = (go (pathParts ++ [ k ]) x).value; } + ) v; + # add bare attrs from fileMap that aren't already in v + # (user supplied `File` but no literal). + synthesized = lib.listToAttrs ( + map (a: lib.nameValuePair a fileMap.${a}.ref) ( + builtins.filter (a: !(v ? ${a})) (builtins.attrNames fileMap) + ) + ); + localSecrets = map (e: { inherit (e) id file; }) fileEntries; + childSecrets = lib.concatLists ( + lib.mapAttrsToList ( + k: x: if lib.hasSuffix "File" k || fileMap ? ${k} then [ ] else (go (pathParts ++ [ k ]) x).secrets + ) v + ); + in + if conflict != [ ] then + throw "${runtimePrefix}.${spec.prefix}.${key}: set either '${ + lib.concatStringsSep "." (pathParts ++ [ (builtins.head conflict).attr ]) + }' or '${ + lib.concatStringsSep "." (pathParts ++ [ ((builtins.head conflict).attr + "File") ]) + }', not both" + else + { + value = processed // synthesized; + secrets = localSecrets ++ childSecrets; + } + else if builtins.isList v then + let + mapped = map (e: go pathParts e) v; + in + { + value = map (m: m.value) mapped; + secrets = lib.concatLists (map (m: m.secrets) mapped); + } + else + { + value = v; + secrets = [ ]; + }; + in + go [ ]; + + renderItem = + c: spec: key: item: + let + # drop ref keys (re-injected as refAttrs below). *File siblings + # are kept -- substituteSecrets handles them after cleanNulls. + virtuals = builtins.attrNames spec.refs; + base = removeAttrs item ([ "_module" ] ++ virtuals); + nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { + ${spec.nameAttr} = key; + }; + refAttrs = lib.concatMapAttrs ( + refName: refSpec: + let + v = item.${refName} or null; + in + lib.optionalAttrs (v != null) { + ${refSpec.attr} = + if refSpec.list or false then map (resolveRef refSpec) v else resolveRef refSpec v; + } + ) spec.refs; + # required secret: literal or `File` must be set. + reqSecretChecks = map ( + attr: + if (item.${attr} or null) == null && (item.${attr + "File"} or null) == null then + throw "${runtimePrefix}.${c}.${key}: set either '${attr}' or '${attr}File' (required)" + else + null + ) (spec.requiredSecrets or [ ]); + # required map / list attrs: nixos modules default attrsOf / listOf + # to {} / [] rather than treating "unset" as undefined; enforce + # non-empty here. + reqAttrChecks = map ( + attr: + let + v = item.${attr} or null; + in + if v == null || v == { } || v == [ ] then + throw "${runtimePrefix}.${c}.${key}: '${attr}' is required and must be non-empty" + else + null + ) (spec.requiredAttrs or [ ]); + # wrap nested MaxItems:1 blocks in `[ obj ]` so terraform reads + # them as blocks. spec.blockAttrs lists dotted paths; recurses + # through attrsets and list elements. + wrapBlocks = + path: v: + if builtins.isAttrs v then + lib.mapAttrs ( + k: x: + let + childPath = if path == "" then k else "${path}.${k}"; + wrapped = wrapBlocks childPath x; + in + if builtins.elem childPath (spec.blockAttrs or [ ]) && builtins.isAttrs wrapped then + [ wrapped ] + else + wrapped + ) v + else if builtins.isList v then + map (wrapBlocks path) v + else + v; + cleaned = cleanNulls (base // nameInject // refAttrs); + substituted = substituteSecrets spec key cleaned; + wrapped = wrapBlocks "" substituted.value; + in + # deepSeq forces the checks to run. + # (they're not nixos assertions because we generate the .tf.json + # outside a full system build too.) + builtins.deepSeq [ reqSecretChecks reqAttrChecks ] { + label = tfLabel spec.prefix key; + value = wrapped; + inherit (substituted) secrets; + }; + + nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; + # for each collection: [ { label; value; secrets } ... ]. + renderedPerCollection = lib.mapAttrs ( + c: items: lib.mapAttrsToList (key: item: renderItem c resourceTypes.${c} key item) items + ) (lib.intersectAttrs nonEmpty cfg); + resourceBlocks = lib.mapAttrs' ( + c: items: + lib.nameValuePair resourceTypes.${c}.type ( + lib.listToAttrs (map (r: lib.nameValuePair r.label r.value) items) + ) + ) renderedPerCollection; + + # every secret across the config (for sensitive tf vars + the + # id -> host path map fed to LoadCredential). + allSecrets = lib.concatLists ( + lib.concatLists (lib.mapAttrsToList (_: items: map (r: r.secrets) items) renderedPerCollection) + ); + secretIds = map (e: e.id) allSecrets; + + sensitiveVar = { + type = "string"; + sensitive = true; + }; + + config = { + terraform.required_providers.${providerName} = { + source = providerSource; + version = providerVersion; + }; + variable = { + ${tokenVar} = sensitiveVar; + } + // lib.listToAttrs (map (v: lib.nameValuePair v sensitiveVar) extraSensitiveVars) + // lib.listToAttrs (map (e: lib.nameValuePair e.id sensitiveVar) allSecrets); + provider.${providerName} = providerBlock cfg; + } + // lib.optionalAttrs (resourceBlocks != { }) { resource = resourceBlocks; }; + + credentials = + if lib.length secretIds != lib.length (lib.unique secretIds) then + throw "${runtimePrefix}: secret credential id collision (${toString secretIds}); rename the colliding resource keys" + else + lib.listToAttrs (map (e: lib.nameValuePair e.id e.file) allSecrets); + in + { + inherit config credentials; + }; + + # --------------------------------------------------------------------------- + # run-once reconciler systemd service + # --------------------------------------------------------------------------- + # build the run-once reconciler systemd service. # # name unit + generated-config name (e.g. "declarative-forgejo") diff --git a/services/forgejo/lib.nix b/services/forgejo/lib.nix index 90354b0..b4681ea 100644 --- a/services/forgejo/lib.nix +++ b/services/forgejo/lib.nix @@ -1,102 +1,25 @@ -# Forgejo-provider specifics: the forgejo-wrapped OpenTofu executor and the -# .tf.json generation for a Forgejo pairing. The provider-agnostic helpers -# (label/file/reconciler) live in modules/lib and are specialized here for the -# svalabs/forgejo provider (vendored in ./pkg.nix). -# -# Every svalabs/forgejo *resource* is exposed as an option collection -# (`resourceOptions`): `attrsOf` a submodule whose options are the resource's -# settable attributes, each declared with the NixOS type the corresponding -# provider attribute accepts (so a wrong name, type, or missing required field is -# an eval-time error -- `nix flake check` -- not an apply-time one). The option -# set is derived from the provider schema (`tofu providers schema -json` for -# svalabs/forgejo 1.5.0); computed/output-only attributes are omitted. The -# attrset key becomes the Terraform label (and, for name-bearing resources, the -# default name/login). Parent links are resolved to Terraform references against -# other managed resources (see `resourceTypes..refs`), which both wires the -# `*_id` numeric attributes a user cannot know and orders `tofu apply` correctly. -# -# Imported as `import ./lib.nix { inherit pkgs; }` from the Forgejo module and -# checks. +# forgejo-provider specifics: executor, resource types, provider block. +# shared helpers (option helpers, renderer, reconciler) live in modules/lib. { pkgs }: let - inherit (pkgs) lib; genlib = import ../../modules/lib { inherit pkgs; }; - inherit (genlib) tfLabel; + inherit (genlib) + oStr + oBool + oInt + oListStr + oSub + rStr + rBool + rMapStr + ; + inherit (pkgs) lib; provider = import ./pkg.nix { inherit pkgs; }; - - # Pin required_providers to the vendored provider version so the manifest - # always matches the offline mirror. providerVersion = provider.version; - - # Terraform input variable (and LoadCredential id) carrying the admin token. tokenVar = "forgejo_api_token"; - - # OpenTofu wrapped with the svalabs/forgejo provider. The provider lives in - # the wrapper's NIX_TERRAFORM_PLUGIN_DIR, so `tofu init`/`apply` resolve it - # with no registry access. executor = pkgs.opentofu.withPlugins (_: [ provider ]); - ty = lib.types; - - # Per-attribute option constructors. `o*` declare an *optional* attribute - # (`nullOr T`, default null -> omitted from the generated `.tf.json` when - # unset); `r*` declare a *required* attribute (no default -> a missing value is - # an eval-time error). Each carries the exact value shape the provider accepts. - oStr = - description: - lib.mkOption { - type = ty.nullOr ty.str; - default = null; - inherit description; - }; - oBool = - description: - lib.mkOption { - type = ty.nullOr ty.bool; - default = null; - inherit description; - }; - oInt = - description: - lib.mkOption { - type = ty.nullOr ty.int; - default = null; - inherit description; - }; - oListStr = - description: - lib.mkOption { - type = ty.nullOr (ty.listOf ty.str); - default = null; - inherit description; - }; - oSub = - options: description: - lib.mkOption { - type = ty.nullOr (ty.submodule { inherit options; }); - default = null; - inherit description; - }; - rStr = - description: - lib.mkOption { - type = ty.str; - inherit description; - }; - rBool = - description: - lib.mkOption { - type = ty.bool; - inherit description; - }; - rMapStr = - description: - lib.mkOption { - type = ty.attrsOf ty.str; - inherit description; - }; - # Reference specs, reused across resources. `attr` is the Terraform attribute # emitted; `targets` are the managed collections (in priority order) whose key # the user names; `field` is the referenced attribute. `managedOnly` references @@ -484,59 +407,11 @@ let }; }; - # One option collection per resource: an `attrsOf` strictly-typed submodule. - # The submodule's options are the resource's settable attributes, plus the - # reference inputs (resolved into Terraform references at generation) and one - # `File` input per secret. No `freeformType`: an undeclared attribute is - # a definition error. - resourceOptions = lib.mapAttrs ( - _: spec: - lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = - (spec.attrs or { }) - // lib.mapAttrs ( - _: refSpec: - if refSpec.required or false then - lib.mkOption { - type = lib.types.str; - description = refSpec.description; - } - else - lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = refSpec.description; - } - ) spec.refs - // lib.listToAttrs ( - map ( - attr: - lib.nameValuePair "${attr}File" ( - lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Runtime path to a file holding `${attr}` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `${attr}`."; - } - ) - ) (spec.secrets or [ ]) - ); - } - ); - default = { }; - description = spec.description; - } - ) resourceTypes; - - # Comma-separated union of token scopes for the declared resource collections - # (a least-privilege set for the config). - # - # Currently unused: the bootstrap mints a maximally-scoped ("all") token to - # avoid ever having to re-mint (see module.nix). Retained for Forgejo >= 16, - # where the admin token API (PR #12323, v16.0.0) lets the bootstrap re-mint - # cleanly on scope change and we can request `requiredScopes cfg` + write:admin - # instead of the maximal scope. + # union of token scopes for the declared resource collections (least- + # privilege set for the config). currently dormant: the bootstrap mints + # a maximally-scoped ("all") token to avoid having to re-mint. switch + # to `requiredScopes cfg` + write:admin once on Forgejo >= 16, where + # the admin token API can re-mint cleanly on scope change. requiredScopes = cfg: let @@ -545,193 +420,19 @@ let in if scopes == [ ] then "write:organization" else lib.concatStringsSep "," scopes; - # Recursively drop null-valued attributes (unset options) and the submodule - # bookkeeping key `_module`, so the generated JSON carries only what the user - # actually set -- at every nesting level, including the typed nested objects - # (external_tracker, ...). - cleanNulls = - v: - if builtins.isAttrs v then - lib.mapAttrs (_: cleanNulls) (lib.filterAttrs (_: x: x != null) (removeAttrs v [ "_module" ])) - else if builtins.isList v then - map cleanNulls v - else - v; - - # Build the Terraform JSON config for a Forgejo pairing from the module's cfg, - # together with the (id -> host path) credential map for any host-file-sourced - # secrets. Returns { config; credentials; }. - # - # Contains NO provider secret: the admin token and every `File` secret - # are supplied at apply time as sensitive input variables fed from systemd - # `LoadCredential=`, never written to the store. A *literal* secret attribute - # (e.g. `data`/`password` set directly) still lands in the world-readable - # store -- use the matching `File` option to avoid that. - forgejoTfConfig = - cfg: - let - # Var-safe id (Terraform variable name + LoadCredential id) for a secret. - varSafe = lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_]" c != null then c else "_"); - secretId = - spec: key: attr: - "secret_${spec.prefix}_${varSafe key}_${attr}"; - - resolveRef = - refSpec: val: - let - tryTarget = - t: - let - tspec = resourceTypes.${t.collection}; - in - if (cfg.${t.collection} or { }) ? ${val} then - "\${" + tspec.type + "." + tfLabel tspec.prefix val + "." + t.field + "}" - else - null; - hits = builtins.filter (x: x != null) (map tryTarget refSpec.targets); - in - if hits != [ ] then - builtins.head hits - else if refSpec.managedOnly then - throw "services.forgejo.runtime: reference '${val}' does not match any managed ${ - lib.concatMapStringsSep " or " (t: t.collection) refSpec.targets - }" - else - val; - - # Host-file-sourced secrets of one item: [{ attr; id; path; }]. Throws if - # both the literal attribute and its `File` are set. - itemSecrets = - c: spec: key: item: - lib.concatMap ( - attr: - let - file = item.${attr + "File"} or null; - in - lib.optionals (file != null) ( - if (item.${attr} or null) != null then - throw "services.forgejo.runtime.${c}.${key}: set either '${attr}' or '${attr}File', not both" - else - [ - { - inherit attr; - id = secretId spec key attr; - path = file; - } - ] - ) - ) (spec.secrets or [ ]); - - renderItem = - c: spec: key: item: - let - secretEntries = itemSecrets c spec key item; - virtuals = builtins.attrNames spec.refs ++ map (s: "${s}File") (spec.secrets or [ ]); - base = removeAttrs item ([ "_module" ] ++ virtuals); - nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { - ${spec.nameAttr} = key; - }; - refAttrs = lib.concatMapAttrs ( - refName: refSpec: - lib.optionalAttrs (item.${refName} or null != null) { - ${refSpec.attr} = resolveRef refSpec item.${refName}; - } - ) spec.refs; - secretAttrs = lib.listToAttrs (map (e: lib.nameValuePair e.attr "\${var.${e.id}}") secretEntries); - # A required secret must be supplied via either the literal or its file. - reqSecretChecks = map ( - attr: - if (item.${attr} or null) == null && (item.${attr + "File"} or null) == null then - throw "services.forgejo.runtime.${c}.${key}: set either '${attr}' or '${attr}File' (required)" - else - null - ) (spec.requiredSecrets or [ ]); - # Required map/list attributes: the module system gives `attrsOf`/ - # `listOf` an empty-value default ({}/[]) rather than treating a missing - # value as undefined, so a "required" collection is enforced here. - reqAttrChecks = map ( - attr: - let - v = item.${attr} or null; - in - if v == null || v == { } || v == [ ] then - throw "services.forgejo.runtime.${c}.${key}: '${attr}' is required and must be non-empty" - else - null - ) (spec.requiredAttrs or [ ]); - in - # deepSeq forces the validation thunks (whose results are otherwise unused) - # so a violated check `throw`s here. These live in the generator, not in - # NixOS `config.assertions`, because assertions only fire during a full - # NixOS system evaluation -- whereas `forgejoTfConfig` is also called - # standalone (e.g. tests, `nix eval`), where assertions would be silently - # skipped and a malformed config would surface opaquely at `tofu apply`. - lib.nameValuePair (tfLabel spec.prefix key) ( - builtins.deepSeq [ reqSecretChecks reqAttrChecks ] ( - cleanNulls (base // nameInject // refAttrs // secretAttrs) - ) - ); - - nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; - resourceBlocks = lib.mapAttrs' ( - c: spec: lib.nameValuePair spec.type (lib.mapAttrs' (renderItem c spec) cfg.${c}) - ) nonEmpty; - - # Every host-file-sourced secret across the config, for the sensitive input - # variables and the (id -> host path) credential map. - allSecrets = lib.concatLists ( - lib.mapAttrsToList ( - c: spec: lib.concatLists (lib.mapAttrsToList (key: item: itemSecrets c spec key item) cfg.${c}) - ) nonEmpty - ); - secretIds = map (e: e.id) allSecrets; - - config = { - terraform.required_providers.forgejo = { - source = "svalabs/forgejo"; - version = providerVersion; - }; - variable = { - ${tokenVar} = { - type = "string"; - sensitive = true; - }; - } - // lib.listToAttrs ( - map ( - e: - lib.nameValuePair e.id { - type = "string"; - sensitive = true; - } - ) allSecrets - ); - provider.forgejo = { - host = cfg.baseUrl; - api_token = "\${var.${tokenVar}}"; - }; - } - // lib.optionalAttrs (resourceBlocks != { }) { resource = resourceBlocks; }; - - credentials = - if lib.length secretIds != lib.length (lib.unique secretIds) then - throw "services.forgejo.runtime: secret credential id collision (${toString secretIds}); rename the colliding resource keys" - else - lib.listToAttrs (map (e: lib.nameValuePair e.id e.path) allSecrets); - in - { - inherit config credentials; + forgejoTfConfig = genlib.mkTfConfig { + inherit resourceTypes providerVersion tokenVar; + providerName = "forgejo"; + providerSource = "svalabs/forgejo"; + runtimePrefix = "services.forgejo.runtime"; + providerBlock = cfg: { + host = cfg.baseUrl; + api_token = "\${var.${tokenVar}}"; }; + }; in { - inherit - resourceTypes - resourceOptions - requiredScopes - forgejoTfConfig - ; - - # The generic run-once reconciler, specialized with the forgejo executor and - # the forgejo_api_token credential. + inherit resourceTypes requiredScopes forgejoTfConfig; + resourceOptions = genlib.resourceOptions resourceTypes; mkReconcileService = args: genlib.mkReconcileService (args // { inherit executor tokenVar; }); } diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index ce80c77..87f201f 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -1,9 +1,20 @@ -# FIXME this only provisions keycloak_realm resources for now +# keycloak-provider specifics: executor, resource types, provider block. +# shared helpers (option helpers, renderer, reconciler) live in modules/lib. { pkgs }: let inherit (pkgs) lib; genlib = import ../../modules/lib { inherit pkgs; }; - inherit (genlib) tfLabel; + inherit (genlib) + oStr + oBool + oInt + oListStr + oAttrsStr + oSub + oListSub + rStr + rBool + ; provider = pkgs.terraform-providers.keycloak_keycloak; providerVersion = provider.version; @@ -14,71 +25,6 @@ let executor = pkgs.opentofu.withPlugins (_: [ provider ]); - ty = lib.types; - - # o* for optional, r* for required - oStr = - description: - lib.mkOption { - type = ty.nullOr ty.str; - default = null; - inherit description; - }; - oBool = - description: - lib.mkOption { - type = ty.nullOr ty.bool; - default = null; - inherit description; - }; - oInt = - description: - lib.mkOption { - type = ty.nullOr ty.int; - default = null; - inherit description; - }; - oListStr = - description: - lib.mkOption { - type = ty.nullOr (ty.listOf ty.str); - default = null; - inherit description; - }; - oAttrsStr = - description: - lib.mkOption { - type = ty.nullOr (ty.attrsOf ty.str); - default = null; - inherit description; - }; - rStr = - description: - lib.mkOption { - type = ty.str; - inherit description; - }; - rBool = - description: - lib.mkOption { - type = ty.bool; - inherit description; - }; - oListSub = - options: description: - lib.mkOption { - type = ty.nullOr (ty.listOf (ty.submodule { inherit options; })); - default = null; - inherit description; - }; - oSub = - options: description: - lib.mkOption { - type = ty.nullOr (ty.submodule { inherit options; }); - default = null; - inherit description; - }; - # most non-realm resources reference their realm by numeric id, which # the user can't know up front -- resolve it by managed key instead. realmRef = { @@ -409,7 +355,7 @@ let internationalization = oSub { supported_locales = lib.mkOption { - type = ty.listOf ty.str; + type = lib.types.listOf lib.types.str; description = "Locales the realm supports."; }; default_locale = rStr "Default locale."; @@ -2963,11 +2909,11 @@ let required_for_scopes = oListStr "Scopes for which the attribute is required."; permissions = oSub { view = lib.mkOption { - type = ty.listOf ty.str; + type = lib.types.listOf lib.types.str; description = "Roles that can view the attribute (e.g. \"admin\", \"user\")."; }; edit = lib.mkOption { - type = ty.listOf ty.str; + type = lib.types.listOf lib.types.str; description = "Roles that can edit the attribute."; }; } "View / edit permissions for the attribute."; @@ -3165,331 +3111,22 @@ let }; }; - # generate nixos options for resources from resourceTypes - resourceOptions = lib.mapAttrs ( - _: spec: - lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = - (spec.attrs or { }) - // lib.mapAttrs ( - _: refSpec: - let - base = if refSpec.list or false then lib.types.listOf lib.types.str else lib.types.str; - in - if refSpec.required or false then - lib.mkOption { - type = base; - description = refSpec.description; - } - else - lib.mkOption { - type = lib.types.nullOr base; - default = null; - description = refSpec.description; - } - ) spec.refs - // lib.listToAttrs ( - map ( - attr: - lib.nameValuePair "${attr}File" ( - lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Runtime path to a file holding `${attr}` (loaded via systemd LoadCredential=; never copied to the store). Mutually exclusive with a literal `${attr}`."; - } - ) - ) (spec.secrets or [ ]) - ); - } - ); - default = { }; - description = spec.description; - } - ) resourceTypes; - - # remove null-valued and _module attributes - cleanNulls = - v: - if builtins.isAttrs v then - lib.mapAttrs (_: cleanNulls) (lib.filterAttrs (_: x: x != null) (removeAttrs v [ "_module" ])) - else if builtins.isList v then - map cleanNulls v - else - v; - - # build the .tf.json + the credentials map (id -> host path). - # secrets are passed in via $CREDENTIALS_DIRECTORY at apply time. - keycloakTfConfig = - cfg: - let - # make a string valid as a tf variable / LoadCredential id. - varSafe = lib.stringAsChars (c: if builtins.match "[A-Za-z0-9_]" c != null then c else "_"); - secretId = - spec: key: attr: - "secret_${spec.prefix}_${varSafe key}_${attr}"; - - resolveRef = - refSpec: val: - let - tryTarget = - t: - let - tspec = resourceTypes.${t.collection}; - in - if (cfg.${t.collection} or { }) ? ${val} then - "\${" + tspec.type + "." + tfLabel tspec.prefix val + "." + t.field + "}" - else - null; - hits = builtins.filter (x: x != null) (map tryTarget refSpec.targets); - in - if hits != [ ] then - builtins.head hits - else if refSpec.managedOnly then - throw "services.keycloak.runtime: reference '${val}' does not match any managed ${ - lib.concatMapStringsSep " or " (t: t.collection) refSpec.targets - }" - else - val; - - # walk the value tree, swap every `File = "/path"` for - # ` = "${var.}"` and collect [{ id; file; }] entries. - # works at any depth (top-level attrs, nested submodules, list - # elements). throws if both `` and `File` are set. - # the id uses a dotted-path suffix so nested secrets get a unique - # name like `secret_realm_acme_smtp_server_auth_password`. - substituteSecrets = - spec: key: - let - mkId = pathParts: secretId spec key (varSafe (lib.concatStringsSep "_" pathParts)); - go = - pathParts: v: - if builtins.isAttrs v then - let - fileKeys = builtins.filter (k: lib.hasSuffix "File" k) (builtins.attrNames v); - fileEntries = map ( - k: - let - attr = lib.removeSuffix "File" k; - id = mkId (pathParts ++ [ attr ]); - in - { - inherit attr id; - file = v.${k}; - bareConflict = v ? ${attr}; - } - ) fileKeys; - conflict = builtins.filter (e: e.bareConflict) fileEntries; - fileMap = lib.listToAttrs ( - map ( - e: - lib.nameValuePair e.attr { - inherit (e) id; - ref = "\${var.${e.id}}"; - } - ) fileEntries - ); - # walk each key: drop `*File` entries; for bare attrs in - # fileMap, replace with `${var.}`; otherwise recurse. - processed = lib.concatMapAttrs ( - k: x: - if lib.hasSuffix "File" k then - { } - else if fileMap ? ${k} then - { ${k} = fileMap.${k}.ref; } - else - { ${k} = (go (pathParts ++ [ k ]) x).value; } - ) v; - # add bare attrs from fileMap that aren't already in v - # (user supplied `File` but no literal). - synthesized = lib.listToAttrs ( - map (a: lib.nameValuePair a fileMap.${a}.ref) ( - builtins.filter (a: !(v ? ${a})) (builtins.attrNames fileMap) - ) - ); - localSecrets = map (e: { - inherit (e) id file; - }) fileEntries; - childSecrets = lib.concatLists ( - lib.mapAttrsToList ( - k: x: if lib.hasSuffix "File" k || fileMap ? ${k} then [ ] else (go (pathParts ++ [ k ]) x).secrets - ) v - ); - in - if conflict != [ ] then - throw "services.keycloak.runtime.${spec.prefix}.${key}: set either '${ - lib.concatStringsSep "." (pathParts ++ [ (builtins.head conflict).attr ]) - }' or '${ - lib.concatStringsSep "." (pathParts ++ [ ((builtins.head conflict).attr + "File") ]) - }', not both" - else - { - value = processed // synthesized; - secrets = localSecrets ++ childSecrets; - } - else if builtins.isList v then - let - mapped = map (e: go pathParts e) v; - in - { - value = map (m: m.value) mapped; - secrets = lib.concatLists (map (m: m.secrets) mapped); - } - else - { - value = v; - secrets = [ ]; - }; - in - go [ ]; - - renderItem = - c: spec: key: item: - let - # drop ref keys (re-injected as refAttrs below). *File siblings - # are kept -- substituteSecrets handles them after cleanNulls. - virtuals = builtins.attrNames spec.refs; - base = removeAttrs item ([ "_module" ] ++ virtuals); - nameInject = lib.optionalAttrs (spec.nameAttr != null && (item.${spec.nameAttr} or null) == null) { - ${spec.nameAttr} = key; - }; - refAttrs = lib.concatMapAttrs ( - refName: refSpec: - let - v = item.${refName} or null; - in - lib.optionalAttrs (v != null) { - ${refSpec.attr} = - if refSpec.list or false then map (resolveRef refSpec) v else resolveRef refSpec v; - } - ) spec.refs; - # required secret: literal or `File` must be set. - reqSecretChecks = map ( - attr: - if (item.${attr} or null) == null && (item.${attr + "File"} or null) == null then - throw "services.keycloak.runtime.${c}.${key}: set either '${attr}' or '${attr}File' (required)" - else - null - ) (spec.requiredSecrets or [ ]); - # required map / list attrs: nixos modules default attrsOf / listOf - # to {} / [] rather than treating "unset" as undefined; enforce - # non-empty here. - reqAttrChecks = map ( - attr: - let - v = item.${attr} or null; - in - if v == null || v == { } || v == [ ] then - throw "services.keycloak.runtime.${c}.${key}: '${attr}' is required and must be non-empty" - else - null - ) (spec.requiredAttrs or [ ]); - # wrap nested MaxItems:1 blocks in `[ obj ]` so terraform reads - # them as blocks. spec.blockAttrs lists dotted paths (e.g. - # "smtp_server", "security_defenses.headers", - # "attribute.permissions"). recurses through attrsets and list - # elements, so a block inside a list element is wrapped too. - wrapBlocks = - path: v: - if builtins.isAttrs v then - lib.mapAttrs ( - k: x: - let - childPath = if path == "" then k else "${path}.${k}"; - wrapped = wrapBlocks childPath x; - in - if builtins.elem childPath (spec.blockAttrs or [ ]) && builtins.isAttrs wrapped then - [ wrapped ] - else - wrapped - ) v - else if builtins.isList v then - map (wrapBlocks path) v - else - v; - cleaned = cleanNulls (base // nameInject // refAttrs); - substituted = substituteSecrets spec key cleaned; - wrapped = wrapBlocks "" substituted.value; - in - # deepSeq forces the checks to run. - # (they're not nixos assertions because we generate the .tf.json - # outside a full system build too.) - builtins.deepSeq [ reqSecretChecks reqAttrChecks ] { - label = tfLabel spec.prefix key; - value = wrapped; - inherit (substituted) secrets; - }; - - nonEmpty = lib.filterAttrs (c: _: (cfg.${c} or { }) != { }) resourceTypes; - # for each collection: [ { label; value; secrets } ... ]. - renderedPerCollection = lib.mapAttrs ( - c: items: lib.mapAttrsToList (key: item: renderItem c resourceTypes.${c} key item) items - ) (lib.intersectAttrs nonEmpty cfg); - resourceBlocks = lib.mapAttrs' ( - c: items: - lib.nameValuePair resourceTypes.${c}.type ( - lib.listToAttrs (map (r: lib.nameValuePair r.label r.value) items) - ) - ) renderedPerCollection; - - # every secret across the config (for sensitive tf vars + the - # id -> host path map fed to LoadCredential). - allSecrets = lib.concatLists ( - lib.concatLists (lib.mapAttrsToList (_: items: map (r: r.secrets) items) renderedPerCollection) - ); - secretIds = map (e: e.id) allSecrets; - - config = { - terraform.required_providers.keycloak = { - source = "keycloak/keycloak"; - version = providerVersion; - }; - variable = { - ${tokenVar} = { - type = "string"; - sensitive = true; - }; - ${clientIdVar} = { - type = "string"; - sensitive = true; - }; - } - // lib.listToAttrs ( - map ( - e: - lib.nameValuePair e.id { - type = "string"; - sensitive = true; - } - ) allSecrets - ); - provider.keycloak = { - url = cfg.baseUrl; - realm = "master"; - client_id = "\${var.${clientIdVar}}"; - client_secret = "\${var.${tokenVar}}"; - }; - } - // lib.optionalAttrs (resourceBlocks != { }) { resource = resourceBlocks; }; - - credentials = - if lib.length secretIds != lib.length (lib.unique secretIds) then - throw "services.keycloak.runtime: secret credential id collision (${toString secretIds}); rename the colliding resource keys" - else - lib.listToAttrs (map (e: lib.nameValuePair e.id e.file) allSecrets); - in - { - inherit config credentials; + keycloakTfConfig = genlib.mkTfConfig { + inherit resourceTypes providerVersion tokenVar; + providerName = "keycloak"; + providerSource = "keycloak/keycloak"; + runtimePrefix = "services.keycloak.runtime"; + extraSensitiveVars = [ clientIdVar ]; + providerBlock = cfg: { + url = cfg.baseUrl; + realm = "master"; + client_id = "\${var.${clientIdVar}}"; + client_secret = "\${var.${tokenVar}}"; }; + }; in { - inherit - resourceTypes - resourceOptions - keycloakTfConfig - clientIdVar - ; - + inherit resourceTypes keycloakTfConfig clientIdVar; + resourceOptions = genlib.resourceOptions resourceTypes; mkReconcileService = args: genlib.mkReconcileService (args // { inherit executor tokenVar; }); } From 2c968e877dde75f1cb07710f6877990dd64b986f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 13:23:22 +0200 Subject: [PATCH 38/50] test: drop plain-text secrets from fixtures, use File indirection - services/keycloak/checks.nix: openid_clients.acme_app.client_secret "topsecret" -> client_secretFile = /etc/acme-app-client-secret. Asserts the literal stays out of the generated .tf.json. - services/forgejo/checks.nix: users.alice.password "hackme" in the widenScope specialisation -> passwordFile = /etc/forgejo-alice-password. (bob already used passwordFile; alice was the one literal left.) The fixtures now exclusively exercise the secret-file indirection, so the tests stay honest examples for operators. --- services/forgejo/checks.nix | 7 ++++--- services/keycloak/checks.nix | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/services/forgejo/checks.nix b/services/forgejo/checks.nix index d3987b3..6f4af17 100644 --- a/services/forgejo/checks.nix +++ b/services/forgejo/checks.nix @@ -24,9 +24,10 @@ # curl drives the post-convergence API assertions. environment.systemPackages = [ pkgs.curl ]; - # Stand-in for an operator-managed secret file (sops/agenix in production): - # bob's password, fed to the reconciler via LoadCredential, never the store. + # mock agenix secrets: passwords supplied as host files, fed to the + # reconciler via LoadCredential and never the world-readable store. environment.etc."forgejo-bob-password".text = "hackme"; + environment.etc."forgejo-alice-password".text = "hackme"; services.forgejo = { enable = true; @@ -98,7 +99,7 @@ specialisation.widenScope.configuration = { services.forgejo.runtime.users.alice = { email = "alice@localhost.localdomain"; - password = "hackme"; + passwordFile = "/etc/forgejo-alice-password"; must_change_password = false; }; }; diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index a18dcf6..6fc7ef6 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -247,6 +247,7 @@ in name = "declarative-keycloak-clients"; containers.keycloak = mkHost { + extraEtc."acme-app-client-secret".text = "topsecret"; runtime = { realms.acme.display_name = "ACME"; @@ -264,7 +265,7 @@ in client_id = "acme-app"; name = "ACME App"; access_type = "CONFIDENTIAL"; - client_secret = "topsecret"; + client_secretFile = "/etc/acme-app-client-secret"; standard_flow_enabled = true; direct_access_grants_enabled = true; service_accounts_enabled = true; @@ -320,6 +321,13 @@ in assert "acme-profile" in names, \ f"acme-profile not bound as default scope: {names}" + with subtest("client_secret was supplied via File, never written to .tf.json"): + tfjson = keycloak.succeed( + "cat /var/lib/keycloak/declarative-terraform/main.tf.json" + ) + assert "topsecret" not in tfjson, \ + "openid_clients.acme_app.client_secret leaked into .tf.json" + with subtest("protocol mapper attached to client scope"): # protocolMappers ride along on the client-scope representation. scopes = admin_get(keycloak, "acme/client-scopes") From dc6cb5d0302045959835a007ae4a3e1e22fd054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:32:40 +0200 Subject: [PATCH 39/50] fix(services/keycloak): drop _authorization_ infix from 7 policy types The keycloak/keycloak v5.7.0 schema registers these policy resources without the `_authorization_` infix; the spec had it wrong: - aggregate_policy, client_policy, group_policy, js_policy, role_policy, time_policy, user_policy Only `keycloak_openid_client_authorization_client_scope_policy` keeps the infix (and was already correct). Renames the 7 collections, types, and tf-label prefixes to match the schema; README's resources table updated to mirror. No fixture exercises these collections, so no test churn -- but a user reaching for one would have hit `Invalid resource type` at apply. --- services/keycloak/README.md | 14 ++++++------- services/keycloak/lib.nix | 42 ++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index f30c624..76854e0 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -347,14 +347,14 @@ collection, multi-target with literal fallback): | `openid_client_authorization_resources` | `openid_client_authorization_resource` | `name` | | `openid_client_authorization_scopes` | `openid_client_authorization_scope` | `name` | | `openid_client_authorization_permissions` | `openid_client_authorization_permission` | `name` | -| `openid_client_authorization_aggregate_policies` | `openid_client_authorization_aggregate_policy` | `name` | -| `openid_client_authorization_client_policies` | `openid_client_authorization_client_policy` | `name` | +| `openid_client_aggregate_policies` | `openid_client_aggregate_policy` | `name` | +| `openid_client_client_policies` | `openid_client_client_policy` | `name` | | `openid_client_authorization_client_scope_policies` | `openid_client_authorization_client_scope_policy` | `name` | -| `openid_client_authorization_group_policies` | `openid_client_authorization_group_policy` | `name` | -| `openid_client_authorization_js_policies` | `openid_client_authorization_js_policy` | `name` | -| `openid_client_authorization_role_policies` | `openid_client_authorization_role_policy` | `name` | -| `openid_client_authorization_time_policies` | `openid_client_authorization_time_policy` | `name` | -| `openid_client_authorization_user_policies` | `openid_client_authorization_user_policy` | `name` | +| `openid_client_group_policies` | `openid_client_group_policy` | `name` | +| `openid_client_js_policies` | `openid_client_js_policy` | `name` | +| `openid_client_role_policies` | `openid_client_role_policy` | `name` | +| `openid_client_time_policies` | `openid_client_time_policy` | `name` | +| `openid_client_user_policies` | `openid_client_user_policy` | `name` | All authz resources share `realm` + `resource_server` → openid_clients (the latter resolves to the client's computed `resource_server_id`, diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 87f201f..f4c6590 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -1983,9 +1983,9 @@ let }; }; - openid_client_authorization_aggregate_policies = { - type = "keycloak_openid_client_authorization_aggregate_policy"; - prefix = "openid_client_authz_aggregate_policy"; + openid_client_aggregate_policies = { + type = "keycloak_openid_client_aggregate_policy"; + prefix = "openid_client_aggregate_policy"; nameAttr = "name"; scope = null; refs = { @@ -2017,9 +2017,9 @@ let }; }; - openid_client_authorization_client_policies = { - type = "keycloak_openid_client_authorization_client_policy"; - prefix = "openid_client_authz_client_policy"; + openid_client_client_policies = { + type = "keycloak_openid_client_client_policy"; + prefix = "openid_client_client_policy"; nameAttr = "name"; scope = null; refs = { @@ -2088,9 +2088,9 @@ let }; }; - openid_client_authorization_group_policies = { - type = "keycloak_openid_client_authorization_group_policy"; - prefix = "openid_client_authz_group_policy"; + openid_client_group_policies = { + type = "keycloak_openid_client_group_policy"; + prefix = "openid_client_group_policy"; nameAttr = "name"; scope = null; refs = { @@ -2127,9 +2127,9 @@ let }; }; - openid_client_authorization_js_policies = { - type = "keycloak_openid_client_authorization_js_policy"; - prefix = "openid_client_authz_js_policy"; + openid_client_js_policies = { + type = "keycloak_openid_client_js_policy"; + prefix = "openid_client_js_policy"; nameAttr = "name"; scope = null; refs = { @@ -2162,9 +2162,9 @@ let }; }; - openid_client_authorization_role_policies = { - type = "keycloak_openid_client_authorization_role_policy"; - prefix = "openid_client_authz_role_policy"; + openid_client_role_policies = { + type = "keycloak_openid_client_role_policy"; + prefix = "openid_client_role_policy"; nameAttr = "name"; scope = null; refs = { @@ -2201,9 +2201,9 @@ let }; }; - openid_client_authorization_time_policies = { - type = "keycloak_openid_client_authorization_time_policy"; - prefix = "openid_client_authz_time_policy"; + openid_client_time_policies = { + type = "keycloak_openid_client_time_policy"; + prefix = "openid_client_time_policy"; nameAttr = "name"; scope = null; refs = { @@ -2707,9 +2707,9 @@ let }; }; - openid_client_authorization_user_policies = { - type = "keycloak_openid_client_authorization_user_policy"; - prefix = "openid_client_authz_user_policy"; + openid_client_user_policies = { + type = "keycloak_openid_client_user_policy"; + prefix = "openid_client_user_policy"; nameAttr = "name"; scope = null; refs = { From 48d038ea8084abf4f9c3101c9a6ca6d412908381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:33:30 +0200 Subject: [PATCH 40/50] fix(modules/lib): reqAttrChecks honours nameInject + rejects empty strings - When nameAttr is also in requiredAttrs (e.g. users. with nameAttr=username), the check now sees the post-nameInject value rather than the raw item, so the documented key->name default works end-to-end. Previously the check threw for any user-id pair that relied on the default. - Add empty string to the not-set list. Without it, requiredAttrs = ["client_id"] paired with `client_id = ""` slipped through and hit the provider as an opaque 400 at apply. --- modules/lib/default.nix | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index d78ead1..364238c 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -340,9 +340,11 @@ rec { reqAttrChecks = map ( attr: let - v = item.${attr} or null; + # nameAttr inherits the collection key when the user omits it, + # so check the post-injection value -- not the raw item. + v = (item // nameInject).${attr} or null; in - if v == null || v == { } || v == [ ] then + if v == null || v == { } || v == [ ] || v == "" then throw "${runtimePrefix}.${c}.${key}: '${attr}' is required and must be non-empty" else null From 423fbd96bbf9a1bb70d04bf595a671ea93432849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:35:09 +0200 Subject: [PATCH 41/50] fix(modules/lib): substituteSecrets uses collection name in error + indexes list elements in path Two related renderer hygiene fixes: - The conflict throw interpolated `spec.prefix` (tf-label prefix, e.g. `realm`/`user`) instead of the option-path `c` (the collection name, e.g. `realms`/`users`). The sibling reqSecret and reqAttr throws already used `c`. Thread `c` into substituteSecrets and use it. - Walking into list elements re-used the parent `pathParts`, so two list elements with the same `File` key would collide on the credential id. Latent today (no current oListSub carries a *File sibling) but a future webhook-style list would trigger the uniqueness throw with the wrong remediation hint. Walk with `lib.imap0` and include the index in the path. No fixture output changes (top-level secret ids unchanged); all keycloak tests cached green. --- modules/lib/default.nix | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 364238c..a2a11d9 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -224,7 +224,7 @@ rec { # works at any depth (top-level attrs, nested submodules, list # elements). throws if both `` and `File` are set. substituteSecrets = - spec: key: + c: spec: key: let mkId = pathParts: secretId spec key (varSafe (lib.concatStringsSep "_" pathParts)); go = @@ -280,7 +280,7 @@ rec { ); in if conflict != [ ] then - throw "${runtimePrefix}.${spec.prefix}.${key}: set either '${ + throw "${runtimePrefix}.${c}.${key}: set either '${ lib.concatStringsSep "." (pathParts ++ [ (builtins.head conflict).attr ]) }' or '${ lib.concatStringsSep "." (pathParts ++ [ ((builtins.head conflict).attr + "File") ]) @@ -292,7 +292,9 @@ rec { } else if builtins.isList v then let - mapped = map (e: go pathParts e) v; + # include the list index in the path so two elements with + # the same `File` key don't collide on credential id. + mapped = lib.imap0 (i: e: go (pathParts ++ [ (toString i) ]) e) v; in { value = map (m: m.value) mapped; @@ -371,7 +373,7 @@ rec { else v; cleaned = cleanNulls (base // nameInject // refAttrs); - substituted = substituteSecrets spec key cleaned; + substituted = substituteSecrets c spec key cleaned; wrapped = wrapBlocks "" substituted.value; in # deepSeq forces the checks to run. From 6f8eac5ce200d57ddbe1cb2de087ba84bbf00b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:36:07 +0200 Subject: [PATCH 42/50] fix(services/keycloak): make realm_user_profile permissions.{view,edit} nullable Both fields are Required at the provider, but were declared as bare `listOf str` -- which the nixos module system defaults to `[]`. A user who set permissions = { view = ["admin"]; } (forgot edit) would get the .tf.json emitted with edit = [], silently stripping every editor role for the attribute. Switch to oListStr (nullable, default null). cleanNulls drops the key, terraform sees the field as missing, and apply errors with the provider's required-field message instead of letting a silent role wipe through. --- services/keycloak/lib.nix | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index f4c6590..29e4435 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -2907,15 +2907,13 @@ let enabled_when_scope = oListStr "Scopes that make the attribute available."; required_for_roles = oListStr "Roles for which the attribute is required."; required_for_scopes = oListStr "Scopes for which the attribute is required."; + # both are Required upstream; declared as oListStr (nullable, + # default null) so cleanNulls drops them when unset and apply + # errors -- vs `listOf str` which would silently default to [] + # and *strip* every role from keycloak's side. permissions = oSub { - view = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "Roles that can view the attribute (e.g. \"admin\", \"user\")."; - }; - edit = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "Roles that can edit the attribute."; - }; + view = oListStr "Roles that can view the attribute (e.g. \"admin\", \"user\")."; + edit = oListStr "Roles that can edit the attribute."; } "View / edit permissions for the attribute."; validator = oListSub { name = rStr "Validator id (e.g. \"length\", \"pattern\")."; From 5734fb984d96aee45af990b15c2b1a168104819e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:39:43 +0200 Subject: [PATCH 43/50] feat(services/keycloak): expose adminRealm escape hatch for non-master clients --- services/keycloak/lib.nix | 2 +- services/keycloak/module.nix | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 29e4435..6c065cd 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -3117,7 +3117,7 @@ let extraSensitiveVars = [ clientIdVar ]; providerBlock = cfg: { url = cfg.baseUrl; - realm = "master"; + realm = cfg.adminRealm; client_id = "\${var.${clientIdVar}}"; client_secret = "\${var.${tokenVar}}"; }; diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index 12392e3..7e12077 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -44,6 +44,18 @@ in description = "Base URL of the local keycloak admin API."; }; + adminRealm = mkOption { + type = types.str; + default = "master"; + description = '' + Realm the reconciler's service-account client lives in. The + provider block authenticates against this realm. Defaults to + `master`; the self-bootstrap flow only supports `master`, so + when overriding this you must also supply `clientIdFile` / + `clientSecretFile` for a client you've provisioned yourself. + ''; + }; + bootstrapAdminPasswordFile = mkOption { type = types.nullOr types.str; default = null; @@ -108,6 +120,10 @@ in assertion = (cfg.clientIdFile != null) || (cfg.bootstrapAdminPasswordFile != null); message = "services.keycloak.runtime: when no client credentials are supplied, bootstrapAdminPasswordFile is required to mint them."; } + { + assertion = cfg.adminRealm == "master" || cfg.clientIdFile != null; + message = "services.keycloak.runtime: adminRealm != \"master\" requires operator-supplied clientIdFile/clientSecretFile (the self-bootstrap flow assumes master)."; + } ]; systemd.services = { @@ -121,7 +137,7 @@ in "keycloak.service" ] ++ lib.optional bootstrapClient "${bootstrapServiceName}.service"; - healthUrl = "${cfg.baseUrl}/realms/master"; + healthUrl = "${cfg.baseUrl}/realms/${cfg.adminRealm}"; tokenFile = effectiveClientSecretFile; user = "keycloak"; group = "keycloak"; From 30739ff5a4bbae55a2afc736c777ecb446903aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:44:37 +0200 Subject: [PATCH 44/50] test(checks): assert reapply reports 0 added / 0 changed / 0 destroyed --- services/forgejo/checks.nix | 10 +++++++++- services/keycloak/checks.nix | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/services/forgejo/checks.nix b/services/forgejo/checks.nix index 6f4af17..3bdbe45 100644 --- a/services/forgejo/checks.nix +++ b/services/forgejo/checks.nix @@ -134,8 +134,16 @@ owner = machine.succeed("stat -c %U /var/lib/forgejo/declarative-terraform/terraform.tfstate").strip() assert owner == "forgejo", f"tfstate not under forgejo's state dir / not forgejo-owned: {owner}" - # Re-applying must be idempotent (a second run must also succeed). + # Re-applying must be idempotent: a second run must succeed *and* + # report 0/0/0 (the last 'Apply complete!' line in the journal). machine.succeed("systemctl restart declarative-forgejo.service") + apply_lines = machine.succeed( + "journalctl -u declarative-forgejo.service --no-pager --output=cat " + "| grep 'Apply complete'" + ).strip().splitlines() + assert apply_lines, "no 'Apply complete!' line in journal" + assert "0 added, 0 changed, 0 destroyed" in apply_lines[-1], \ + f"reapply was not a no-op: {apply_lines[-1]}" # Adding an admin-scoped resource (a user needs write:admin + read:user) # mus work because the scopen is the maximal "all" token diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index 6fc7ef6..c8bcad3 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -25,6 +25,16 @@ let )) def get_realm(m, realm): return admin_get(m, realm) + def assert_noop_apply(m, unit): + # last "Apply complete!" line in the unit's journal must report + # 0 added / 0 changed / 0 destroyed -- otherwise the reconciler + # is not idempotent under an unchanged config. + lines = m.succeed( + f"journalctl -u {unit} --no-pager --output=cat | grep 'Apply complete'" + ).strip().splitlines() + assert lines, f"no 'Apply complete!' line in journal for {unit}" + assert "0 added, 0 changed, 0 destroyed" in lines[-1], \ + f"reapply was not a no-op: {lines[-1]}" ''; # shared keycloak host config. `runtime` is the per-test fixture; @@ -126,6 +136,7 @@ in with subtest("reapplying the same config is a no-op"): machine.succeed("systemctl restart declarative-keycloak.service") + assert_noop_apply(machine, "declarative-keycloak.service") with subtest("new realm is applied on config switch"): machine.succeed( From 35bfd444a4838d61729119d4c4d8f2dcf94c269c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:47:46 +0200 Subject: [PATCH 45/50] feat(modules/lib): add oneOfRefs enforcement to the renderer --- modules/lib/default.nix | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/lib/default.nix b/modules/lib/default.nix index a2a11d9..c6034a4 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -351,6 +351,22 @@ rec { else null ) (spec.requiredAttrs or [ ]); + # exactly-one-of ref groups: each entry is a list of ref names of + # which exactly one must be set (provider rejects zero or two). + oneOfChecks = map ( + group: + let + setRefs = builtins.filter (refName: (item.${refName} or null) != null) group; + n = builtins.length setRefs; + joined = lib.concatStringsSep ", " group; + in + if n == 0 then + throw "${runtimePrefix}.${c}.${key}: exactly one of [${joined}] must be set" + else if n > 1 then + throw "${runtimePrefix}.${c}.${key}: [${joined}] are mutually exclusive; set exactly one" + else + null + ) (spec.oneOfRefs or [ ]); # wrap nested MaxItems:1 blocks in `[ obj ]` so terraform reads # them as blocks. spec.blockAttrs lists dotted paths; recurses # through attrsets and list elements. @@ -379,7 +395,7 @@ rec { # deepSeq forces the checks to run. # (they're not nixos assertions because we generate the .tf.json # outside a full system build too.) - builtins.deepSeq [ reqSecretChecks reqAttrChecks ] { + builtins.deepSeq [ reqSecretChecks reqAttrChecks oneOfChecks ] { label = tfLabel spec.prefix key; value = wrapped; inherit (substituted) secrets; From 623cb4c576a167949e984467dcbf14860911937b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:47:49 +0200 Subject: [PATCH 46/50] fix(services/keycloak): require exactly one of client / client_scope on protocol mappers --- services/keycloak/lib.nix | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix index 6c065cd..0b4ad91 100644 --- a/services/keycloak/lib.nix +++ b/services/keycloak/lib.nix @@ -74,6 +74,15 @@ let add_to_userinfo = oBool "Include in UserInfo?"; }; + # protocol mappers attach to a client *or* a client scope -- never + # both, never neither (the provider rejects either). + clientOrScopeOneOf = [ + [ + "client" + "client_scope" + ] + ]; + # SAML counterparts of the openid refs above. samlClientOptionalRef = { attr = "client_id"; @@ -1082,6 +1091,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "user_attribute" "claim_name" @@ -1106,6 +1116,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "user_property" "claim_name" @@ -1128,6 +1139,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "claim_name" ]; description = "OpenID protocol mapper that maps group memberships to a claim."; attrs = openidMapperCommonAttrs // { @@ -1146,6 +1158,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; description = "OpenID protocol mapper that emits the user's full name as a single claim."; attrs = openidMapperCommonAttrs; }; @@ -1160,6 +1173,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; description = "OpenID protocol mapper for the `sub` claim."; attrs = { name = oStr "Mapper name. Defaults to the attribute key."; @@ -1178,6 +1192,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "claim_name" "claim_value" @@ -1200,6 +1215,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; description = "OpenID protocol mapper that adds an audience to issued tokens (exactly one of `included_client_audience` / `included_custom_audience`)."; attrs = { name = oStr "Mapper name. Defaults to the attribute key."; @@ -1220,6 +1236,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; description = "OpenID audience-resolve mapper (derives audience from client roles)."; attrs = { name = oStr "Mapper name. Defaults to the attribute key."; @@ -1236,6 +1253,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "role_id" ]; description = "OpenID protocol mapper that adds a hardcoded role to issued tokens."; attrs = { @@ -1254,6 +1272,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "claim_name" ]; description = "OpenID protocol mapper that maps the user's realm roles to a claim."; attrs = openidMapperCommonAttrs // { @@ -1275,6 +1294,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "claim_name" ]; description = "OpenID protocol mapper that maps the user's roles on a specific client to a claim."; attrs = openidMapperCommonAttrs // { @@ -1296,6 +1316,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "claim_name" "session_note" @@ -1321,6 +1342,7 @@ let client = openidClientOptionalRef; client_scope = openidClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "script" "claim_name" @@ -1344,6 +1366,7 @@ let client = samlClientOptionalRef; client_scope = samlClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "user_attribute" "saml_attribute_name" @@ -1369,6 +1392,7 @@ let client = samlClientOptionalRef; client_scope = samlClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "user_property" "saml_attribute_name" @@ -1393,6 +1417,7 @@ let client = samlClientOptionalRef; client_scope = samlClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "script" "saml_attribute_name" @@ -1418,6 +1443,7 @@ let client = anyClientOptionalRef; client_scope = anyClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "protocol" "protocol_mapper" @@ -1442,6 +1468,7 @@ let client = anyClientOptionalRef; client_scope = anyClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "protocol" "protocol_mapper" @@ -1466,6 +1493,7 @@ let client = anyClientOptionalRef; client_scope = anyClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "role_id" ]; description = "Generic role-scope mapper that attaches a role to a client / client scope, keyed by an arbitrary label."; attrs = { @@ -1483,6 +1511,7 @@ let client = anyClientOptionalRef; client_scope = anyClientScopeOptionalRef; }; + oneOfRefs = clientOrScopeOneOf; requiredAttrs = [ "role_id" ]; description = "Generic role-scope mapper attached to a specific client (deprecated alias kept for completeness)."; attrs = { From df70e5fea196b32ff4b481ab8046e5acd916ed1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 24 Jun 2026 14:52:23 +0200 Subject: [PATCH 47/50] fix(services/keycloak): refresh bootstrap credentials when secret rotates server-side --- services/keycloak/README.md | 9 ++++++--- services/keycloak/module.nix | 23 +++++++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/services/keycloak/README.md b/services/keycloak/README.md index 76854e0..efa3d1f 100644 --- a/services/keycloak/README.md +++ b/services/keycloak/README.md @@ -54,9 +54,12 @@ realm). The pairing offers two paths: `admin` composite role, and persists the resulting `client_id`/`client_secret` 0600 under `/var/lib/declarative-keycloak-bootstrap/`. The reconciler picks them up - via systemd `LoadCredential=` on every run; the file pair is the - "already bootstrapped" marker, so the oneshot is a no-op on subsequent - boots. + via systemd `LoadCredential=` on every run. On subsequent boots the + oneshot probes the saved pair against keycloak's token endpoint -- + if the secret has been rotated server-side (e.g. through the admin + console) the pair is refreshed in place from the existing client. + Recovery is therefore a `systemctl restart declarative-keycloak-bootstrap` + away. 2. **Operator-supplied.** Set both `clientIdFile` and `clientSecretFile` to host paths for a service-account client you've created externally. The diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix index 7e12077..7b64a39 100644 --- a/services/keycloak/module.nix +++ b/services/keycloak/module.nix @@ -182,12 +182,6 @@ in client_id_file="$STATE_DIRECTORY/client_id" client_secret_file="$STATE_DIRECTORY/client_secret" - # the saved credential pair is the "already bootstrapped" - # marker. /var/lib persists across reboots. - if [ -s "$client_id_file" ] && [ -s "$client_secret_file" ]; then - exit 0 - fi - # wait up to 3 minutes for keycloak to come up. for _ in $(seq 1 90); do if curl -fsS -o /dev/null "${cfg.baseUrl}/realms/master"; then @@ -196,6 +190,23 @@ in sleep 2 done + # saved credential pair exists -- probe it against the token + # endpoint. valid means already-bootstrapped; otherwise the + # secret has been rotated server-side and we fall through to + # re-fetch the current one (same client, same id). + if [ -s "$client_id_file" ] && [ -s "$client_secret_file" ]; then + saved_id="$(cat "$client_id_file")" + saved_secret="$(cat "$client_secret_file")" + if curl -fsS -o /dev/null -X POST \ + ${lib.escapeShellArg "${cfg.baseUrl}/realms/master/protocol/openid-connect/token"} \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode "client_id=$saved_id" \ + --data-urlencode "client_secret=$saved_secret"; then + exit 0 + fi + echo "saved service-account credentials no longer accepted; refreshing from keycloak admin." >&2 + fi + kcadm.sh config credentials \ --server ${lib.escapeShellArg cfg.baseUrl} \ --realm master \ From 7500bfd4b9ef33e71ef66bd3a6d25c2c3d05fabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Thu, 25 Jun 2026 11:09:12 +0200 Subject: [PATCH 48/50] feat(examples): add keycloak-forgejo VM example Boots both pairings together with a custom keycloak login theme, SSO from forgejo into keycloak, a private internal repo, and per-user avatars. Inert until wired into the flake (next commit). --- examples/keycloak-forgejo/README.md | 87 +++++ .../keycloak-forgejo/avatars/jhalpert.svg | 7 + examples/keycloak-forgejo/configuration.nix | 301 ++++++++++++++++++ .../login/messages/messages_en.properties | 4 + .../login/resources/css/styles.css | 49 +++ .../dunder_mifflin/login/theme.properties | 4 + 6 files changed, 452 insertions(+) create mode 100644 examples/keycloak-forgejo/README.md create mode 100644 examples/keycloak-forgejo/avatars/jhalpert.svg create mode 100644 examples/keycloak-forgejo/configuration.nix create mode 100644 examples/keycloak-forgejo/themes/dunder_mifflin/login/messages/messages_en.properties create mode 100644 examples/keycloak-forgejo/themes/dunder_mifflin/login/resources/css/styles.css create mode 100644 examples/keycloak-forgejo/themes/dunder_mifflin/login/theme.properties diff --git a/examples/keycloak-forgejo/README.md b/examples/keycloak-forgejo/README.md new file mode 100644 index 0000000..10f3600 --- /dev/null +++ b/examples/keycloak-forgejo/README.md @@ -0,0 +1,87 @@ +# Example: keycloak + forgejo (Dunder Mifflin Paper Co.) + +A disposable NixOS VM that exercises both pairings together: +`services.keycloak.runtime.*` and `services.forgejo.runtime.*` plus +the SSO loop wiring Keycloak as a Forgejo OAuth2 login source. + +## Run it + +From the repository root: + +```sh +nix run .#keycloak-forgejo +``` + +Boot takes ~3-4 min. The console auto-logs in as `root` / `hackme`. +A `scranton.qcow2` lands in CWD; delete it to start over. Exit with +`Ctrl+a x` or `poweroff` from the guest. + +## Forwarded ports + +| Host port | Guest | What | +| --------- | ----- | ----------------------------------------------------- | +| 2222 | 22 | SSH (`ssh -p 2222 root@localhost`, password `hackme`) | +| 8080 | 8080 | Keycloak | +| 3000 | 3000 | Forgejo | +| 8888 | 8888 | Static avatar host (`jhalpert.png`) | + +## What it demonstrates + +**Keycloak (`services.keycloak.runtime`):** + +- Realm `dunder_mifflin` with a custom `dunder_mifflin` login theme + (Scranton blue on corporate beige -- see `themes/dunder_mifflin/`). +- Declarative user-profile schema (`realm_user_profiles`) so the + `picture` attribute survives Keycloak v24+'s default + `unmanaged_attribute_policy = DISABLED`. +- User `jhalpert` (Jim Halpert) with `initial_password.valueFile` + indirection and a `picture` attribute pointing at the avatar host. +- OIDC client `dunder-mifflin-infinity` with `client_secretFile` + indirection and a redirect URI pointed at Forgejo's OAuth2 callback. + +**Forgejo (`services.forgejo.runtime`):** + +- Org `dunder_mifflin`, public repo `scranton_branch`, private repo + `intranet`. +- Pre-created users `jhalpert` and `dschrute` (passwords via + `passwordFile`). +- Collaborator binding granting `jhalpert` write on `intranet`. + +**Glue (plain systemd one-shots, not runtime-state):** + +- `nginx` serving the rasterised PNG avatar at + `http://localhost:8888/jhalpert.png`. +- `forgejo-oauth-setup` calling `forgejo admin auth add-oauth` to + register Keycloak as a Forgejo login source named + `DunderMifflinInfinity` (the svalabs/forgejo terraform provider + doesn't model auth sources, so the runtime layer can't reach it). + +## Try it + +1. **Forgejo (local user).** -> login + `dschrute` / `hackme`. See the org and the public `scranton_branch` + repo; the private `intranet` repo is not visible. +2. **Keycloak account console.** + -> login + `jhalpert` / `hackme`. The themed login page (beige + Scranton + blue) and Jim's pre-populated profile. +3. **SSO loop.** Sign out of Forgejo. Visit + and click **Sign in with + DunderMifflinInfinity** (below the local login form) -> log in + as `jhalpert` / `hackme` on the themed Keycloak page -> Forgejo + links the SSO identity to the pre-created `jhalpert` (matched by + email), pulls his avatar via the OIDC `picture` claim, and lands + you on his dashboard. +4. **Internal repo.** As Jim, navigate to + . He has write + access. Sign out (or sign in as `dschrute`), the URL returns 404. + +## Known wrinkles + +- **First-boot avatar.** Tofu creates `keycloak_user` and + `keycloak_realm_user_profile` in parallel; the user-create request + can race the schema, so on first apply Keycloak drops the `picture` + attribute silently. Restarting `declarative-keycloak.service` (or + rebooting) refreshes drift and re-PUTs the attribute. The fix is to + emit a `depends_on` edge from user resources to their realm's + user-profile -- a renderer change worth landing soon. diff --git a/examples/keycloak-forgejo/avatars/jhalpert.svg b/examples/keycloak-forgejo/avatars/jhalpert.svg new file mode 100644 index 0000000..609f746 --- /dev/null +++ b/examples/keycloak-forgejo/avatars/jhalpert.svg @@ -0,0 +1,7 @@ + + + JH + diff --git a/examples/keycloak-forgejo/configuration.nix b/examples/keycloak-forgejo/configuration.nix new file mode 100644 index 0000000..1e8eadd --- /dev/null +++ b/examples/keycloak-forgejo/configuration.nix @@ -0,0 +1,301 @@ +{ + config, + pkgs, + modulesPath, + ... +}: +{ + imports = [ "${modulesPath}/virtualisation/qemu-vm.nix" ]; + + networking.hostName = "scranton"; + networking.firewall.enable = false; + time.timeZone = "America/New_York"; + + services.openssh.enable = true; + services.openssh.settings.PermitRootLogin = "yes"; + users.users.root.password = "hackme"; + services.getty.autologinUser = "root"; + + environment.systemPackages = with pkgs; [ + curl + jq + ]; + + # demo-only: stand-ins for the host-file secrets that File options + # reach for via systemd LoadCredential. real deployments source these + # from sops-nix / agenix; never the world-readable nix store. + environment.etc = { + "secrets/keycloak-db-password".text = "hackme"; + "secrets/keycloak-admin-password".text = "hackme"; + "secrets/jhalpert-password".text = "hackme"; + "secrets/dunder-mifflin-app-client-secret".text = "topsecret"; + "secrets/dschrute-password".text = "hackme"; + "secrets/jhalpert-forgejo-password".text = "hackme"; + }; + + virtualisation = { + memorySize = 4096; + diskSize = 8192; + graphics = false; + forwardPorts = [ + { + from = "host"; + host.port = 2222; + guest.port = 22; + } + { + from = "host"; + host.port = 8080; + guest.port = 8080; + } + { + from = "host"; + host.port = 3000; + guest.port = 3000; + } + { + from = "host"; + host.port = 8888; + guest.port = 8888; + } + ]; + }; + + # static avatar host. svg rasterised to png at build time so forgejo's + # Go image decoder (no svg support) can ingest it on SSO. virtualHost + # name doubles as server_name; "localhost" matches the Host header from + # both the host browser and forgejo's avatar fetcher inside the vm. + services.nginx = { + enable = true; + virtualHosts.localhost = { + default = true; + listen = [ + { + addr = "0.0.0.0"; + port = 8888; + } + ]; + root = pkgs.runCommand "avatars" { nativeBuildInputs = [ pkgs.librsvg ]; } '' + mkdir -p $out + rsvg-convert -w 200 -h 200 -o $out/jhalpert.png ${./avatars/jhalpert.svg} + ''; + }; + }; + + services.keycloak = { + enable = true; + initialAdminPassword = "hackme"; + settings = { + hostname = "localhost"; + http-port = 8080; + http-enabled = true; + hostname-strict = false; + }; + database.passwordFile = "/etc/secrets/keycloak-db-password"; + + themes.dunder_mifflin = pkgs.runCommand "keycloak-theme-dunder-mifflin" { } '' + cp -r ${./themes/dunder_mifflin} $out + ''; + + runtime = { + enable = true; + bootstrapAdminPasswordFile = "/etc/secrets/keycloak-admin-password"; + + realms.dunder_mifflin = { + display_name = "Dunder Mifflin Paper Company"; + login_theme = "dunder_mifflin"; + }; + + # declare picture (and the four standard attrs) and flip unmanaged + # to ENABLED so keycloak v24+'s declarative profile stores + # users.jhalpert.attributes.picture instead of silently dropping it. + realm_user_profiles.dunder_mifflin = + let + stdPerms = { + view = [ + "admin" + "user" + ]; + edit = [ + "admin" + "user" + ]; + }; + stdAttr = name: { + inherit name; + permissions = stdPerms; + }; + in + { + realm = "dunder_mifflin"; + unmanaged_attribute_policy = "ENABLED"; + attribute = [ + (stdAttr "username") + (stdAttr "email") + (stdAttr "firstName") + (stdAttr "lastName") + { + name = "picture"; + display_name = "Avatar URL"; + permissions = stdPerms; + } + ]; + }; + + users.jhalpert = { + realm = "dunder_mifflin"; + username = "jhalpert"; + email = "jim@dundermifflin.com"; + first_name = "Jim"; + last_name = "Halpert"; + enabled = true; + email_verified = true; + # default `profile` client scope maps this into the OIDC `picture` + # claim; forgejo pulls it on SSO when UPDATE_AVATAR=true. + attributes.picture = "http://localhost:8888/jhalpert.png"; + initial_password = { + valueFile = "/etc/secrets/jhalpert-password"; + temporary = false; + }; + }; + + openid_clients.dunder_mifflin_infinity = { + realm = "dunder_mifflin"; + client_id = "dunder-mifflin-infinity"; + name = "Dunder Mifflin Infinity"; + access_type = "CONFIDENTIAL"; + client_secretFile = "/etc/secrets/dunder-mifflin-app-client-secret"; + valid_redirect_uris = [ "http://localhost:3000/user/oauth2/DunderMifflinInfinity/callback" ]; + web_origins = [ "http://localhost:3000" ]; + standard_flow_enabled = true; + }; + }; + }; + + services.forgejo = { + enable = true; + settings.server = { + HTTP_PORT = 3000; + DOMAIN = "localhost"; + ROOT_URL = "http://localhost:3000/"; + }; + settings.security.MIN_PASSWORD_LENGTH = 6; + # forgejo's HTTP client blocks RFC1918 + loopback by default (anti-SSRF); + # avatar pulls go through that same client, so the local nginx is + # unreachable without this flip. + settings.migrations.ALLOW_LOCALNETWORKS = true; + settings.oauth2_client = { + ENABLE_AUTO_REGISTRATION = true; + USERNAME = "preferred_username"; + ACCOUNT_LINKING = "auto"; + UPDATE_AVATAR = true; + }; + + runtime = { + enable = true; + + organizations.dunder_mifflin = { + visibility = "public"; + description = "Dunder Mifflin Paper Company, Inc."; + }; + + repositories.scranton_branch = { + owner = "dunder_mifflin"; + description = "The best branch in the company"; + private = false; + }; + + # internal repo: only collaborators can see it. forgejo's SSO + # auto-registration creates a user with login = preferred_username + # (`jhalpert`); pre-creating jhalpert here lets us name him as a + # collaborator. ACCOUNT_LINKING=auto matches by email on first SSO, + # so the pre-created + SSO accounts are the same forgejo user. + repositories.intranet = { + owner = "dunder_mifflin"; + description = "Internal Dunder Mifflin intranet -- not for the warehouse"; + private = true; + }; + + users.jhalpert = { + email = "jim@dundermifflin.com"; + full_name = "Jim Halpert"; + passwordFile = "/etc/secrets/jhalpert-forgejo-password"; + must_change_password = false; + }; + + users.dschrute = { + email = "dschrute@dundermifflin.com"; + passwordFile = "/etc/secrets/dschrute-password"; + must_change_password = false; + }; + + collaborators.jhalpert_intranet = { + repository = "intranet"; + user = "jhalpert"; + permission = "write"; + }; + }; + }; + + # SSO glue: the svalabs/forgejo terraform provider doesn't model auth + # sources, so this oneshot calls `forgejo admin auth add-oauth` after + # both reconcilers are done. The source name doubles as the URL slug + # in the OAuth2 callback path (must match keycloak's valid_redirect_uris). + systemd.services.forgejo-oauth-setup = + let + fcfg = config.services.forgejo; + appIni = "${fcfg.customDir}/conf/app.ini"; + in + { + description = "Register Keycloak as a forgejo OAuth2 login source"; + after = [ + "forgejo.service" + "declarative-keycloak.service" + ]; + requires = [ + "forgejo.service" + "declarative-keycloak.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + fcfg.package + pkgs.gawk + pkgs.curl + ]; + environment = { + GITEA_WORK_DIR = fcfg.stateDir; + GITEA_CUSTOM = fcfg.customDir; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = fcfg.user; + Group = fcfg.group; + LoadCredential = [ "client-secret:/etc/secrets/dunder-mifflin-app-client-secret" ]; + }; + script = '' + set -euo pipefail + sso_name=DunderMifflinInfinity + if forgejo --config '${appIni}' admin auth list | awk 'NR>1 {print $2}' | grep -qx "$sso_name"; then + exit 0 + fi + for _ in $(seq 1 60); do + if curl -fsS -o /dev/null \ + http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration; then + break + fi + sleep 2 + done + secret="$(cat "$CREDENTIALS_DIRECTORY/client-secret")" + forgejo --config '${appIni}' admin auth add-oauth \ + --name "$sso_name" \ + --provider openidConnect \ + --key dunder-mifflin-infinity \ + --secret "$secret" \ + --scopes "openid email profile" \ + --auto-discover-url http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration + ''; + }; + + system.stateVersion = "26.05"; +} diff --git a/examples/keycloak-forgejo/themes/dunder_mifflin/login/messages/messages_en.properties b/examples/keycloak-forgejo/themes/dunder_mifflin/login/messages/messages_en.properties new file mode 100644 index 0000000..58381d8 --- /dev/null +++ b/examples/keycloak-forgejo/themes/dunder_mifflin/login/messages/messages_en.properties @@ -0,0 +1,4 @@ +loginAccountTitle=Dunder Mifflin Infinity 2.0 +loginAccountTitleHtml=Dunder Mifflin Infinity 2.0 +loginTitle=The People Person's Paper People +doLogIn=Sign in diff --git a/examples/keycloak-forgejo/themes/dunder_mifflin/login/resources/css/styles.css b/examples/keycloak-forgejo/themes/dunder_mifflin/login/resources/css/styles.css new file mode 100644 index 0000000..b8cc6bd --- /dev/null +++ b/examples/keycloak-forgejo/themes/dunder_mifflin/login/resources/css/styles.css @@ -0,0 +1,49 @@ +/* Dunder Mifflin Paper Company -- corporate beige with Scranton blue. */ +body, +.login-pf-page, +.pf-v5-c-login, +.pf-v5-c-login__main, +.pf-v5-c-login__container { + background: #f4ede1 !important; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; +} + +.kc-logo-text { + background-image: none !important; + text-align: center; +} +.kc-logo-text::before { + content: "DUNDER MIFFLIN"; + display: block; + font-size: 38px; + font-weight: 900; + letter-spacing: 6px; + color: #003c8f; +} +.kc-logo-text::after { + content: "Paper Company, Inc."; + display: block; + font-size: 12px; + font-weight: 400; + letter-spacing: 3px; + color: #6c6c6c; + margin-top: 4px; + text-transform: uppercase; +} + +.pf-v5-c-login__main { + background: #ffffff !important; + border: 1px solid #003c8f; + border-top: 4px solid #003c8f; + border-radius: 2px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.pf-v5-c-button.pf-m-primary { + background-color: #003c8f !important; + border-color: #003c8f !important; +} + +.pf-v5-c-login__footer-item a { + color: #003c8f !important; +} diff --git a/examples/keycloak-forgejo/themes/dunder_mifflin/login/theme.properties b/examples/keycloak-forgejo/themes/dunder_mifflin/login/theme.properties new file mode 100644 index 0000000..0839660 --- /dev/null +++ b/examples/keycloak-forgejo/themes/dunder_mifflin/login/theme.properties @@ -0,0 +1,4 @@ +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css From 90b05098b61d103099e21f20abd4d44a3f4db31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Thu, 25 Jun 2026 11:09:19 +0200 Subject: [PATCH 49/50] feat(flake): expose examples as packages + eval checks Each entry in the `examples` attrset is materialised three ways: `nixosConfigurations.example-` (the full system), `packages..` (the qemu-runnable VM, so `nix run .#`), and `checks..example-` (eval-only, so option-name drift fails CI without paying for a full VM test). --- README.md | 16 +++++++++++++++- flake.nix | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 45e1eeb..e49de98 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,18 @@ live in the per-pairing README: service-account bootstrap flow and the operator-supplied client override. +### Runnable examples + +Each entry under [`examples/`](examples/) is a complete NixOS +configuration with its own walkthrough. `nix run .#` builds +and boots the example as a QEMU VM (host ports forwarded so you can +hit the services from a browser). + +- [`keycloak-forgejo`](examples/keycloak-forgejo/README.md) — both + pairings together, a custom Keycloak login theme, SSO from Forgejo + into Keycloak, a private internal repo, and per-user avatars served + over a side nginx. `nix run .#keycloak-forgejo`. + ### Secrets Secrets should never enter the world-readable Nix store. The admin token and @@ -103,7 +115,7 @@ path — prefer it over the literal for any real secret. ## Repository layout ``` -flake.nix # outputs: nixosModules, checks, formatter +flake.nix # outputs: nixosModules, packages (examples), checks, formatter treefmt.nix # treefmt + nixfmt config modules/ default.nix # aggregates per-pairing modules into nixosModules.default @@ -120,6 +132,8 @@ services/ # one directory per service<->provider pairing lib.nix # ~95 typed resourceTypes + the value-tree renderer checks.nix # 1 VM + 4 nspawn-container tests, one per resource family README.md # usage docs +examples/ # runnable demos; one configuration.nix + README per example + keycloak-forgejo/ # both pairings together with theme, SSO, avatar ``` ## Development diff --git a/flake.nix b/flake.nix index 5357a49..4b3ba5c 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,21 @@ } ); treefmtEval = forAllSystems ({ pkgs, ... }: treefmt-nix.lib.evalModule pkgs ./treefmt.nix); + # runnable examples (see ./examples//README.md). each `name` + # surfaces as `nix run .#` (the qemu vm) and as an eval-only + # check, so option-name drift fails CI without paying for a full vm test. + examples = { + keycloak-forgejo = ./examples/keycloak-forgejo/configuration.nix; + }; + exampleSystem = + system: cfg: + nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.default + cfg + ]; + }; in { # NixOS module entrypoint: enables a Nixpkgs service and reconciles its @@ -36,11 +51,24 @@ nixosModules.forgejo = ./services/forgejo/module.nix; nixosModules.keycloak = ./services/keycloak/module.nix; + nixosConfigurations = nixpkgs.lib.mapAttrs' ( + name: cfg: nixpkgs.lib.nameValuePair "example-${name}" (exampleSystem "x86_64-linux" cfg) + ) examples; + + packages = forAllSystems ( + { system, ... }: + nixpkgs.lib.mapAttrs (name: cfg: (exampleSystem system cfg).config.system.build.vm) examples + ); + checks = forAllSystems ( { pkgs, system }: # Per-service checks (one attrset per pairing under ./services/). (import ./services/forgejo/checks.nix { inherit pkgs self; }) // (import ./services/keycloak/checks.nix { inherit pkgs self; }) + // (nixpkgs.lib.mapAttrs' ( + name: cfg: + nixpkgs.lib.nameValuePair "example-${name}" (exampleSystem system cfg).config.system.build.toplevel + ) examples) // { formatting = treefmtEval.${system}.config.build.check self; } From 4d998349a84b473ecc5f1b9b54bdde0f27e51ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Thu, 25 Jun 2026 12:35:53 +0200 Subject: [PATCH 50/50] test(services/keycloak): OIDC password-grant end-to-end check Boots a declared realm + user + public client (direct access grants only) and verifies the typed runtime options reach a working OIDC token endpoint: - password grant on the declared client returns access + id tokens - id_token + userinfo carry the declared claims (preferred_username, email, given/family/name, email_verified) - login_with_email_allowed lets the email substitute for the username - a wrong password is rejected (must fail) Container test (~45s), no new fixture dependencies. --- services/keycloak/checks.nix | 120 +++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix index c8bcad3..b5630c0 100644 --- a/services/keycloak/checks.nix +++ b/services/keycloak/checks.nix @@ -664,4 +664,124 @@ in assert flow.get("description") == "Passkey login flow" ''; }; + + # OIDC password-grant end-to-end: declared user authenticates against a + # declared client, and the returned id_token + userinfo carry the + # standard claims the runtime config promised. Validates the typed + # options -> .tf.json -> Keycloak API -> OIDC token pipeline end-to-end, + # not just the admin-API surface the per-family tests cover. + keycloak-e2e = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-e2e"; + + containers.keycloak = mkHost { + runtime = { + realms.acme = { + display_name = "ACME"; + login_with_email_allowed = true; + }; + + users.alice = { + realm = "acme"; + username = "alice"; + email = "alice@acme.test"; + first_name = "Alice"; + last_name = "Tester"; + enabled = true; + email_verified = true; + initial_password = { + valueFile = "/etc/secrets/alice-pw"; + temporary = false; + }; + }; + + # PUBLIC client, only direct access grants enabled (the password + # grant doesn't use redirects, so no valid_redirect_uris and + # standard/implicit flow off -- the provider rejects redirect + # URIs without a flow that uses them). + openid_clients.test_app = { + realm = "acme"; + client_id = "test-app"; + name = "Test App"; + access_type = "PUBLIC"; + standard_flow_enabled = false; + direct_access_grants_enabled = true; + }; + }; + extraEtc = { + "secrets/alice-pw".text = "hackme"; + }; + }; + + testScript = '' + ${pyHelpers} + import base64 + + def jwt_claims(tok): + # JWT = header.payload.signature; payload is urlsafe-base64 JSON + # (no padding). pad to a multiple of 4 before decoding. + payload = tok.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") + + with subtest("password grant returns access + id token"): + resp = json.loads(keycloak.succeed( + "curl --fail -s -X POST " + "http://localhost:8080/realms/acme/protocol/openid-connect/token " + "-d grant_type=password " + "-d client_id=test-app " + "-d username=alice " + "-d password=hackme " + "--data-urlencode 'scope=openid email profile'" + )) + assert "access_token" in resp, f"no access_token in response: {resp}" + assert "id_token" in resp, f"no id_token in response: {resp}" + access_token = resp["access_token"] + id_token = resp["id_token"] + + with subtest("id_token claims match declared user attributes"): + c = jwt_claims(id_token) + assert c.get("preferred_username") == "alice", c + assert c.get("email") == "alice@acme.test", c + assert c.get("email_verified") is True, c + assert c.get("given_name") == "Alice", c + assert c.get("family_name") == "Tester", c + assert c.get("name") == "Alice Tester", c + + with subtest("userinfo endpoint matches the id_token claims"): + ui = json.loads(keycloak.succeed( + f"curl --fail -s -H 'Authorization: Bearer {access_token}' " + "http://localhost:8080/realms/acme/protocol/openid-connect/userinfo" + )) + assert ui.get("preferred_username") == "alice", ui + assert ui.get("email") == "alice@acme.test", ui + assert ui.get("given_name") == "Alice", ui + assert ui.get("family_name") == "Tester", ui + + with subtest("login-with-email accepts the email as the username field"): + resp2 = json.loads(keycloak.succeed( + "curl --fail -s -X POST " + "http://localhost:8080/realms/acme/protocol/openid-connect/token " + "-d grant_type=password " + "-d client_id=test-app " + "-d username=alice@acme.test " + "-d password=hackme " + "--data-urlencode 'scope=openid'" + )) + assert "access_token" in resp2, f"email login rejected: {resp2}" + + with subtest("wrong password is rejected with 401"): + # `curl --fail` exits non-zero on >= 400, so use machine.fail. + keycloak.fail( + "curl --fail -s -X POST " + "http://localhost:8080/realms/acme/protocol/openid-connect/token " + "-d grant_type=password " + "-d client_id=test-app " + "-d username=alice " + "-d password=wrong" + ) + ''; + }; }