diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ca189e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - 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 + + - name: nix flake check + run: nix flake check -L diff --git a/README.md b/README.md index f6ab1e3..e49de98 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ -# 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. -> **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 @@ -31,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 @@ -53,11 +81,27 @@ 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. + +### 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 @@ -71,18 +115,25 @@ 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, packages (examples), 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 +examples/ # runnable demos; one configuration.nix + README per example + keycloak-forgejo/ # both pairings together with theme, SSO, avatar ``` ## Development 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 diff --git a/flake.nix b/flake.nix index 94ffe8b..4b3ba5c 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"; @@ -28,17 +28,47 @@ } ); 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 # runtime state via OpenTofu after the primary unit starts. nixosModules.default = ./modules; 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; } 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/modules/lib/default.nix b/modules/lib/default.nix index c38e65b..c6034a4 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -1,41 +1,479 @@ -# 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, 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 { - # 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. + + # --------------------------------------------------------------------------- + # 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 = 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. + # 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. # - # 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 + # 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 = + c: 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}.${c}.${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 + # 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; + 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 + # 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 == [ ] || v == "" then + throw "${runtimePrefix}.${c}.${key}: '${attr}' is required and must be non-empty" + 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. + 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 c 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 oneOfChecks ] { + 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") + # 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, @@ -49,25 +487,31 @@ rec { group, stateDir, credentials ? { }, + dynamicUser ? false, }: 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= 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) + "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; requires = afterUnits; wantedBy = [ "multi-user.target" ]; - # Re-apply whenever the generated configuration changes. + # re-apply when the generated config changes. restartTriggers = [ confFile ]; path = [ executor @@ -81,26 +525,33 @@ 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 { + # 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"; }; script = '' 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 @@ -108,7 +559,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/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/forgejo/checks.nix b/services/forgejo/checks.nix index d3987b3..3bdbe45 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; }; }; @@ -133,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/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/README.md b/services/keycloak/README.md new file mode 100644 index 0000000..efa3d1f --- /dev/null +++ b/services/keycloak/README.md @@ -0,0 +1,461 @@ +# 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"; + + declarative-runtime.url = "github:applicative-systems/declarative-runtime"; + declarative-runtime.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { nixpkgs, declarative-runtime, ... }: + { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + declarative-runtime.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. 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 + 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"; + }; + }; +} +``` + +### 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 | +| ---------------------------- | ----------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `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 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_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_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`, +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 + +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. + +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. diff --git a/services/keycloak/checks.nix b/services/keycloak/checks.nix new file mode 100644 index 0000000..b5630c0 --- /dev/null +++ b/services/keycloak/checks.nix @@ -0,0 +1,787 @@ +# 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` in the VM + # test, `keycloak` in the container tests) so bodies match. + 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) + 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; + # `extraEtc` mocks operator-supplied 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 covering boot -> bootstrap -> reconcile, plus a + # specialisation switch that adds a second realm. + keycloak = pkgs.testers.runNixOSTest { + name = "declarative-keycloak"; + + nodes.machine = + 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"; + }; + }; + + testScript = '' + ${pyHelpers} + machine.start() + + # wait for the chain: keycloak -> bootstrap -> reconciler. + machine.wait_for_unit("declarative-keycloak.service") + + 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}" + + 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" + + with subtest("tfstate file exists and is non-empty"): + machine.succeed( + "test -s /var/lib/keycloak/declarative-terraform/terraform.tfstate" + ) + + 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( + "/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}" + ''; + }; + + # roles, groups, users + bindings via managed-key list refs. + keycloak-rbac = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-rbac"; + + containers.keycloak = mkHost { + runtime = { + realms.acme.display_name = "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" + ]; + }; + + 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; + }; + + 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 = '' + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") + + 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 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("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" + 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 = 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}" + ''; + }; + + # openid clients + scopes + protocol mapper + default-scope binding. + keycloak-clients = pkgs.testers.runNixOSTest { + name = "declarative-keycloak-clients"; + + containers.keycloak = mkHost { + extraEtc."acme-app-client-secret".text = "topsecret"; + 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; + }; + + openid_clients.acme_app = { + realm = "acme"; + client_id = "acme-app"; + name = "ACME App"; + access_type = "CONFIDENTIAL"; + client_secretFile = "/etc/acme-app-client-secret"; + 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" + ]; + }; + + # protocol mapper attached to the managed scope by 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; + }; + }; + }; + + testScript = '' + ${pyHelpers} + start_all() + keycloak.wait_for_unit("declarative-keycloak.service") + + 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("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("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") + 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]}" + 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}" + ''; + }; + + # 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"; + + containers.keycloak = mkHost { + extraEtc."acme-smtp-password".text = "verysecretpassword"; + runtime = { + realms.acme = { + display_name = "ACME Corp."; + display_name_html = "ACME Corp."; + # cross-section of the extended realm 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 with a nested-secret indirection (auth.passwordFile). + 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 wrap (headers + brute_force_detection + # inside security_defenses). + 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_keystore_rsa_generateds.acme_extra_rsa = { + realm = "acme"; + name = "acme-extra-rsa"; + algorithm = "RS256"; + key_size = 2048; + priority = 50; + }; + + required_actions.acme_configure_totp = { + realm = "acme"; + alias = "CONFIGURE_TOTP"; + enabled = false; + default_action = false; + }; + + realm_localizations.acme_en = { + realm = "acme"; + locale = "en"; + texts.loginAccountTitle = "ACME"; + }; + + # 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"; + 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 "verysecretpassword" not in tfjson, \ + "smtp_server.auth.password leaked into .tf.json" + assert "secret_realm_acme_smtp_server_auth_password" in tfjson, \ + "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("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("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}" + + 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("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 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 the multi-target idp-alias ref. + 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`); + # 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" + 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" + ''; + }; + + # 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" + ) + ''; + }; +} diff --git a/services/keycloak/lib.nix b/services/keycloak/lib.nix new file mode 100644 index 0000000..0b4ad91 --- /dev/null +++ b/services/keycloak/lib.nix @@ -0,0 +1,3159 @@ +# 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) + oStr + oBool + oInt + oListStr + oAttrsStr + oSub + oListSub + rStr + rBool + ; + + provider = pkgs.terraform-providers.keycloak_keycloak; + providerVersion = provider.version; + + # tf-var names for the service-account oauth2 client the reconciler uses. + tokenVar = "keycloak_client_secret"; + clientIdVar = "keycloak_client_id"; + + executor = pkgs.opentofu.withPlugins (_: [ provider ]); + + # 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 = [ + { + collection = "realms"; + field = "id"; + } + ]; + managedOnly = true; + required = true; + 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."; + }; + # 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?"; + add_to_access_token = oBool "Include in access token?"; + 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"; + 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."; + }; + + # identity providers reference the realm by its alias (name) -- the + # provider's `realm` attribute, not `realm_id`. + 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."; + }; + + # every ldap_*_mapper resolves its parent federation by id. + 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."; + }; + + # IdP mappers reference an IdP by alias; the alias can belong to + # any of the six IdP collections. + 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."; + }; + + # attrs every IdP mapper carries. + commonIdpMapperAttrs = { + name = oStr "Mapper name. Defaults to the attribute key."; + extra_config = oAttrsStr "Free-form extra mapper config entries."; + }; + + # 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."; + 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 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 = [ + { + 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."; + }; + + # 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; 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"; + prefix = "realm"; + nameAttr = "realm"; + scope = null; + refs = { }; + 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 = { + 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."; + + # 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."; + + # 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."; + 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 = 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 = 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."; + + internationalization = oSub { + supported_locales = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = "Locales the realm supports."; + }; + 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."; + }; + }; + + roles = { + type = "keycloak_role"; + prefix = "role"; + nameAttr = "name"; + scope = null; + 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."; + attributes = oAttrsStr "Free-form role attribute map."; + }; + }; + + default_roles = { + type = "keycloak_default_roles"; + prefix = "default_roles"; + nameAttr = null; + scope = null; + 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 = { + 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; + 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 = { + 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."; + }; + 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."; + }; + }; + description = "Keycloak group memberships, keyed by an arbitrary label."; + attrs = { }; + }; + + 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."; + }; + 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."; + }; + }; + description = "Role assignments for a group, keyed by an arbitrary label."; + attrs = { + 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" ]; + blockAttrs = [ "initial_password" ]; + # 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."; + 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\")."; + + initial_password = oSub { + 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."; + + 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; }`."; + }; + }; + + 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."; + }; + 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."; + }; + }; + description = "Role assignments for a user, keyed by an arbitrary label."; + attrs = { + 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."; + }; + 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."; + }; + }; + description = "Group memberships for a user, keyed by an arbitrary label."; + attrs = { + 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."; + }; + }; + + openid_clients = { + type = "keycloak_openid_client"; + prefix = "openid_client"; + nameAttr = "client_id"; + scope = null; + refs.realm = realmRef; + secrets = [ "client_secret" ]; + blockAttrs = [ + "authorization" + "authentication_flow_binding_overrides" + ]; + # 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."; + 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."; + + 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."; + }; + }; + + 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."; + }; + 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)."; + }; + }; + description = "Default OAuth2 scopes auto-attached to a client, keyed by an arbitrary label."; + attrs = { }; + }; + + 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."; + }; + 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."; + }; + }; + description = "Optional OAuth2 scopes available to a client, keyed by an arbitrary label."; + attrs = { }; + }; + + 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 = { + # 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)."; + }; + }; + + 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."; + }; + }; + + saml_clients = { + type = "keycloak_saml_client"; + prefix = "saml_client"; + nameAttr = "client_id"; + scope = null; + refs.realm = realmRef; + # 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."; + 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."; + + 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."; + }; + }; + + 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."; + }; + 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."; + }; + }; + description = "Default SAML scopes auto-attached to a SAML client, keyed by an arbitrary label."; + attrs = { }; + }; + + # 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"; + nameAttr = "name"; + scope = null; + refs = { + realm = realmRef; + client = openidClientOptionalRef; + client_scope = openidClientScopeOptionalRef; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + 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."; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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."; + }; + }; + + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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; + }; + 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 = { + 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; + }; + oneOfRefs = clientOrScopeOneOf; + 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."; + }; + }; + + 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."; + }; + }; + + 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."; + }; + }; + + 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."; + }; + }; + + 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_aggregate_policies = { + type = "keycloak_openid_client_aggregate_policy"; + prefix = "openid_client_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_client_policies = { + type = "keycloak_openid_client_client_policy"; + prefix = "openid_client_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_group_policies = { + type = "keycloak_openid_client_group_policy"; + prefix = "openid_client_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_js_policies = { + type = "keycloak_openid_client_js_policy"; + prefix = "openid_client_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_role_policies = { + type = "keycloak_openid_client_role_policy"; + prefix = "openid_client_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_time_policies = { + type = "keycloak_openid_client_time_policy"; + prefix = "openid_client_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."; + }; + }; + + 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" + ]; + blockAttrs = [ + "kerberos" + "cache" + ]; + 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."; + 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."; + }; + }; + + 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."; + }; + }; + + 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."; + }; + }; + + 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; expose File + # for both even though only private_key is technically secret. + 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"; + 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_user_policies = { + type = "keycloak_openid_client_user_policy"; + prefix = "openid_client_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."; + }; + }; + + 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; + 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 = { + type = "keycloak_realm_optional_client_scopes"; + prefix = "realm_optional_client_scopes"; + nameAttr = null; + scope = null; + 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 = { + 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 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?"; + } "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."; + }; + }; + + realm_user_profiles = { + type = "keycloak_realm_user_profile"; + prefix = "realm_user_profile"; + nameAttr = null; + scope = null; + refs.realm = realmRef; + # 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 = { + 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."; + # 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 = 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\")."; + 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"; + 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; + 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."; + 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."; + }; + }; + + 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. + 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."; + }; + }; + + 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"; + 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."; + }; + }; + }; + + keycloakTfConfig = genlib.mkTfConfig { + inherit resourceTypes providerVersion tokenVar; + providerName = "keycloak"; + providerSource = "keycloak/keycloak"; + runtimePrefix = "services.keycloak.runtime"; + extraSensitiveVars = [ clientIdVar ]; + providerBlock = cfg: { + url = cfg.baseUrl; + realm = cfg.adminRealm; + client_id = "\${var.${clientIdVar}}"; + client_secret = "\${var.${tokenVar}}"; + }; + }; +in +{ + inherit resourceTypes keycloakTfConfig clientIdVar; + resourceOptions = genlib.resourceOptions resourceTypes; + mkReconcileService = args: genlib.mkReconcileService (args // { inherit executor tokenVar; }); +} diff --git a/services/keycloak/module.nix b/services/keycloak/module.nix new file mode 100644 index 0000000..7b64a39 --- /dev/null +++ b/services/keycloak/module.nix @@ -0,0 +1,257 @@ +{ + 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}"; + + # 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 = + 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 = mkEnableOption ( + "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."; + }; + + 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; + example = "/run/secrets/keycloak-admin-password"; + description = '' + 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. + ''; + }; + + clientName = mkOption { + type = types.str; + default = "declarative-keycloak"; + description = '' + 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. + ''; + }; + + clientIdFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/keycloak-tf-client-id"; + description = '' + 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. + ''; + }; + + clientSecretFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/keycloak-tf-client-secret"; + description = '' + Host path to a file with the oauth2 `client_secret` paired with + `clientIdFile`. Read via systemd `LoadCredential=`; never copied + into the store. + ''; + }; + } + // tflib.resourceOptions; + + config = mkIf cfg.enable { + assertions = [ + { + 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."; + } + { + 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 = { + 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/${cfg.adminRealm}"; + tokenFile = effectiveClientSecretFile; + user = "keycloak"; + group = "keycloak"; + # 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; + }; + } + // 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 writes its token cache under $HOME/.keycloak; + # DynamicUser has no real home, so point HOME at our state dir. + 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" + + # 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 + fi + 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 \ + --user admin \ + --password "$(cat "$CREDENTIALS_DIRECTORY/admin-password")" + + # 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')" + 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 `admin` role grants global admin across every + # realm. 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)" + + # 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" + mv "$client_secret_file.tmp" "$client_secret_file" + ''; + }; + }; + }; +}