Skip to content

Add Keycloak pairing + a runnable demo#1

Open
kmein wants to merge 50 commits into
mainfrom
keycloak
Open

Add Keycloak pairing + a runnable demo#1
kmein wants to merge 50 commits into
mainfrom
keycloak

Conversation

@kmein

@kmein kmein commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator
  • Keycloak pairing under services/keycloak/ with a self-bootstrapping service-account client. Operators can supply their own client and admin realm instead.
  • Shared renderer (modules/lib/) now handles nested blocks, nested-secret indirection, list-of-references, and mutually-exclusive ref groups. Forgejo's lib.nix shrinks as a result.
  • examples/keycloak-forgejo boots both pairings, a themed login page, SSO from Forgejo into Keycloak, a private repo, and per-user avatars. nix run .#keycloak-forgejo.
  • CI: GitHub Actions running nix flake check on every push.
  • Renamed top-level from terraform-providers to declarative-runtime (because we neither use terraform nor write providers)

Known issues

  • Forgejo's OAuth2 login source isn't in the Terraform provider, so the example wires it through a systemd one-shot service. Documented in the example README.

kmein added 29 commits June 24, 2026 18:05
Additive: forgejo passes nothing and keeps the original behaviour. For
services using `DynamicUser=true` with no persistent state dir (e.g.
Keycloak), the reconciler now sets `DynamicUser=true` and
`StateDirectory=`, so systemd reuses the same hashed UID as the main
unit and owns the state dir.
Mirrors the forgejo pairing, with two upstream-driven differences:

  - Auth is OAuth2 client-credentials, so the config emits a
    (client_id, client_secret) pair as two sensitive tf variables fed
    via systemd `LoadCredential=`.
  - Upstream keycloak uses `DynamicUser=true` with no state dir; the
    reconciler uses the new dynamicUser mode of mkReconcileService and
    writes tfstate to /var/lib/keycloak/declarative-terraform.

A companion oneshot mints a service-account oauth2 client in master
with the master-realm `admin` role and saves its client_id/client_secret
0600. Operators can override via clientIdFile/clientSecretFile.

First-wave resource surface is `keycloak_realm` only. The renderer
already carries the full ref/secret machinery so later waves just
extend `resourceTypes`.

The VM test boots the whole keycloak -> bootstrap -> reconciler chain,
asserts the realm via the admin API, checks that no secrets reach the
generated .tf.json, exercises idempotency, and switches to a
specialisation that adds a second realm.
Two related tweaks to the dynamicUser path:

  - Drop the explicit `stateDirectory` parameter; derive it from
    `stateDir` so the absolute path the script cd's into and the
    relative path the unit declares as `StateDirectory=` can't drift.
  - Assert at eval time that `stateDir` lives under `/var/lib` when
    `dynamicUser` is set, so misconfig surfaces at `nix flake check`
    rather than as an opaque unit-load failure.

services/keycloak now only sets stateDir; no behaviour change.
First-pass cleanup across services/keycloak: shorten option
descriptions, drop file-header preambles. A later commit reapplies
the pass to the resources added afterwards.
Adds ~50 settable scalar attrs of keycloak_realm: login config,
ssl_required, themes, token lifespans, password_policy, authentication
flow bindings, free-form attributes map, default client-scope lists.
The nested-block schemas (smtp_server, internationalization,
security_defenses, otp_policy, web_authn_*) wait for renderer support
for `TypeList+MaxItems:1` block wrapping.

VM test sets and asserts a representative cross-section
(registration_allowed, login_theme, ssl_required, access_token_lifespan,
password_policy, attributes).
  - roles: realm- or client-scoped keycloak roles with composite_roles,
    description, attributes.
  - default_roles: per-realm bind of roles auto-granted to new users.

Adds a shared realmRef so follow-up resources (groups, users, clients,
mappers) can reuse it.

VM test declares a realm-level role and a default-roles binding, then
asserts via the admin API that the role exists with the declared
description and is present in the realm's composite default-roles
role.
…roles

  - groups: keyed by name; optional parent_id resolves to another
    managed group for nested hierarchies; description + attributes.
  - default_groups: realm-level set of groups new users auto-join
    (opaque UUIDs / `${keycloak_group.X.id}` interpolations until
    managed list-refs land).
  - group_memberships: users-to-group binding by username.
  - group_roles: roles-to-group binding (opaque role-id list) with an
    `exhaustive` flag for full-replace vs additive.

VM test creates a nested group hierarchy with a role assignment and
asserts via the admin API that the subgroup lives under its parent
(via /groups/<id>/children, since KC 26 paginates subGroups) and that
the engineer role is in the parent's realm role mappings.
  - users: keyed by username (lowercase, per provider). Settable:
    email, email_verified, first_name, last_name, enabled, attributes,
    required_actions. initial_password and federated_identity wait for
    nested-block renderer support.
  - user_roles, user_groups: per-user bindings with the exhaustive flag.

VM test creates a user with attributes, role assignment, and group
membership; asserts all three via the admin API.
Per-realm OAuth2 / SAML consent scopes with consent_screen_text,
gui_order, extra_config (openid scopes also carry
include_in_token_scope).

VM test creates an openid_client_scope and asserts via
/admin/realms/<r>/client-scopes that it exists with the declared
description and consent text.
…indings

  - openid_clients: ~45 typed attributes covering the core OIDC client
    surface (flows, redirect URIs, web origins, token timeouts, consent,
    device authorization). client_secret takes the standard <attr>File
    indirection. Nested authorization + authentication_flow_binding_
    overrides plus the write-only client_secret_wo variant land later.
  - openid_client_default_scopes / openid_client_optional_scopes:
    per-client bindings to managed openid_client_scopes by name.
  - openid_client_service_account_roles: grant per-client role to a
    service-account user.
  - openid_client_service_account_realm_roles: grant realm role to a
    service-account user.

VM test creates an openid_client with a literal client_secret and a
default-scope binding; asserts via the admin API that the client and
its bound scopes exist.
  - saml_clients: full SAML client surface (signing/encryption flags +
    algorithms, redirect URIs, logout bindings, IdP-initiated SSO,
    certs). signing_private_key gets <attr>File even though the
    provider doesn't mark it Sensitive (private-key material).
  - saml_client_default_scopes: per-client default-scope binding.

No VM-test fixture for this commit; a SAML scenario can be added later
if needed.
Adds every keycloak_openid_*_protocol_mapper:

  - user_attribute, user_property, group_membership, full_name
  - sub, hardcoded_claim, audience, audience_resolve, hardcoded_role
  - user_realm_role, user_client_role, user_session_note, script

All share realm + (client | client_scope) refs; common
add_to_{id_token,access_token,userinfo} attrs are factored into a
shared helper.

VM test attaches a user_attribute mapper to the acme-profile client
scope and asserts via the admin API that the mapper exists with the
declared user.attribute / claim.name config.
  - saml_user_attribute / user_property / script: SAML counterparts of
    the openid mappers, with friendly_name + saml_attribute_name(_format).
  - generic_protocol_mapper / generic_client_protocol_mapper: escape
    hatches taking protocol + protocol_mapper id + free-form config.
  - generic_role_mapper / generic_client_role_mapper: role-scope
    mappers (multi-target ref accepts openid or saml siblings).

Shared anyClient* / samlClient* ref helpers mirror the openidClient*
helpers from the previous commit.

No VM-test fixture (no SAML clients/scopes declared); eval check
covers schema correctness.
  - oidc_identity_providers: generic OIDC IdP with the full URL
    surface (authorization / token / userinfo / jwks / logout) and
    a Sensitive client_secret with <attr>File support.
  - saml_identity_providers: SAML IdP with single_sign_on_service_url,
    entity_id, signing_certificate, principal_type, authn_context_*,
    post-binding flags.
  - oidc_google / facebook / github / kubernetes: thin OIDC IdP
    specialisations with a Required+Sensitive client_secret (and
    GitHub Enterprise base/api URL overrides).

Shared helpers near the top of lib.nix:
  - realmAliasRef: IdPs reference the realm by alias, not id.
  - commonIdpAttrs: the 17 attrs every IdP carries.

VM test declares an acme_google IdP whose client_secret is supplied
via /etc/acme-google-secret, then asserts via the admin API that
providerId / alias are right and the literal secret never reaches
main.tf.json.
  - hardcoded_attribute / group / role: set a fixed attribute, group
    or role on every federated user.
  - attribute_importer: import a SAML attribute or OIDC claim onto the
    user.
  - attribute_to_role: grant a role when an attribute / claim matches.
  - user_template_importer: derive the username from a Mustache-style
    template over IdP claims.
  - custom: escape hatch for mapper implementations without a typed
    resource (provider-id + free-form config).

Shared helpers:
  - idpAliasRequiredRef: multi-target alias ref across all six IdP
    collections, falling back to a literal alias.
  - commonIdpMapperAttrs: name + extra_config carried by every mapper.

VM test attaches an attribute_importer mapper to the google IdP and
asserts via the admin API that the mapper exists with the declared
config.
  - authentication_flows: top-level per-realm flows.
  - authentication_subflows: nested under a parent flow / subflow
    (parent_flow_alias multi-targets both collections + falls back to
    a literal alias for built-ins like "browser").
  - authentication_executions: leaf authenticator steps inside a flow.
  - authentication_execution_configs: keyed config map attached to a
    managed execution by id.
  - authentication_bindings: realm-level overrides for the seven
    standard flow bindings.

VM test declares one authentication_flow and asserts via the admin API
that it appears in /admin/realms/<r>/authentication/flows with the
declared description.
  - openid_client_authorization_resource / _scope / _permission:
    the three core authz primitives hanging off a resource server.
  - openid_client_authorization_aggregate_policy: composite of other
    policies under a decision strategy.
  - openid_client_authorization_client_policy: grant by client.
  - openid_client_authorization_client_scope_policy: grant by client
    scope (nested list of `{ id; required; }` blocks).
  - openid_client_authorization_group_policy: grant by group (nested
    list of `{ id; path; extend_children; }` blocks).
  - openid_client_authorization_js_policy: javascript-implemented
    policy (requires the scripts feature).
  - openid_client_authorization_role_policy: grant by role (nested
    list of `{ id; required; }` blocks).
  - openid_client_authorization_time_policy: time-window policy.
  - openid_client_authorization_user_policy: grant by user.

All policies share resource_server_id (managed ref into
openid_clients.resource_server_id, which the provider computes once
`authorization` is enabled on the client) and the standard
{decision_strategy, logic, description} attrs.

Adds an oListSub helper for the nested groups / role / scope
list-of-object blocks the policies use.

Eval-only verification; a runtime fixture needs the deferred renderer
support for `TypeList+MaxItems:1` blocks (to enable authorization on
the parent client).
  - ldap_user_federations: ~30 typed attrs covering the full federation
    surface (vendor + connection + edit_mode + sync + auth). bind_credential
    is Sensitive and goes through <attr>File. Declares kerberos and
    cache nested sub-blocks via oSub.
  - ldap_user_attribute_mapper / group_mapper / role_mapper: the three
    core LDAP <-> keycloak attribute / group / role mappers.
  - ldap_hardcoded_role / attribute / group mappers: per-user
    hardcoded grants.
  - ldap_msad_user_account_control / msad_lds_user_account_control:
    MSAD integration.
  - ldap_full_name_mapper: split / join a single LDAP fullName.
  - ldap_custom_mapper: escape hatch for unmapped LDAP SPIs.

Adds ldapFederationIdRef (reused by every mapper) and an oSub helper
for nested sub-blocks.

Eval-only verification (a runtime fixture needs a reachable LDAP
server).
…bute_mapper

  - custom_user_federation: JPA / SPI-backed user federation with
    cache_policy, sync periods, and a provider config map.
  - hardcoded_attribute_mapper: bare (non-LDAP-prefixed) hardcoded
    attribute mapper -- distinct from ldap_hardcoded_attribute_mapper
    and hardcoded_attribute_identity_provider_mapper (same shape,
    different SPI hookup).

Eval-only verification.
  - realm_keystore_aes_generated / ecdsa_generated / hmac_generated /
    rsa_generated: keystores keycloak generates itself, parameterised
    by curve / algorithm / key_size.
  - realm_keystore_java_keystore: backed by a JKS file on disk;
    keystore_password and key_password go through <attr>File.
  - realm_keystore_rsa: externally-provided PEM private_key +
    certificate; both go through <attr>File.

All keystores share realm + (name / active / enabled / priority).

VM test declares one realm_keystore_rsa_generated and asserts via
/admin/realms/<r>/keys that an ACTIVE RS256 key shows up.
renderItem now reads a per-resource `blockAttrs` list and wraps each
named attribute's value in `[ obj ]` after cleanNulls, so single-
instance terraform blocks emit as JSON arrays of one object (`"x":
[{...}]`) instead of plain objects. Resources without `blockAttrs`
are unaffected.

realms now declares smtp_server (with auth and token_auth sub-blocks;
nested-Sensitive fields are LITERAL for now -- nested <attr>File
support lands later) and internationalization (supported_locales +
default_locale), both listed in blockAttrs.

VM test fixture sets internationalization (en + de, default en) and
smtp_server (flat fields) on realms.acme, and asserts via the admin
API that smtpServer.host / smtpServer.from / supportedLocales /
defaultLocale all reach keycloak.

The remaining previously-skipped nested blocks (openid_clients.
authorization, authentication_flow_binding_overrides on openid/saml
clients, users.initial_password / federated_identity, realm's
security_defenses / otp_policy / web_authn_*) wire up in a later
commit.
  - required_actions: per-realm required actions (keyed by alias, e.g.
    "CONFIGURE_TOTP", "VERIFY_EMAIL").
  - realm_events: realm-wide event-logging config.
  - realm_localizations: per-locale message bundle.
  - realm_default_client_scopes / realm_optional_client_scopes:
    realm-wide client-scope bindings.
  - organizations: keycloak organizations with a nested `domain` list
    of `{ name; verified; }` blocks.
  - identity_provider_token_exchange_scope_permissions: per-IdP
    token-exchange policy granting a set of clients access.

VM test toggles CONFIGURE_TOTP off on the acme realm and adds a custom
EN localization ("loginAccountTitle = ACME"), asserting both via the
admin API.

Deferred: realm_user_profile, realm_client_policy_profile(_policy),
group_permissions, users_permissions -- they use the
scopePermissionsSchema() pattern, beyond the simple `MaxItems:1` wrap.
  - realm_client_policy_profiles: client-policy profile with an
    `executor` list of `{ name; configuration; }` blocks.
  - realm_client_policy_profile_policies: policy with a `condition`
    list and a profiles list.
  - group_permissions: per-group fine-grained authz with scope blocks
    (view / manage / view_members / manage_members / manage_membership).
  - users_permissions: realm-wide fine-grained authz on the users
    collection (view / manage / map_roles / manage_group_membership /
    impersonate / user_impersonated).

Each scope_* attr is a `TypeList+MaxItems:1` nested block listed in
blockAttrs so the renderer wraps it as `[{ decision_strategy; policies;
description; }]`.

Deferred: realm_user_profile (nested MaxItems:1 block inside a list
element; wrapBlocks needs to recurse into list elements first) plus
the unwired nested blocks on openid_clients / saml_clients / users.

Eval-only verification.
Mirrors group_permissions / users_permissions: fine-grained authz on
an openid_client, with 7 scope blocks (view, manage, configure,
map_roles, map_roles_client_scope, map_roles_composite, token_exchange).
All 7 listed in blockAttrs so the renderer emits them as
`[{ decision_strategy; policies; description; }]`.
Renderer:
  - wrapBlocks now takes a path and recurses through both attrsets and
    list elements. blockAttrs entries become dotted paths (e.g.
    `security_defenses.headers`, `attribute.permissions`), so
    nested-in-nested MaxItems:1 blocks and MaxItems:1 blocks inside
    list elements both get wrapped. Backwards-compatible with the flat
    entries already in use.

Resources gaining nested-block options + blockAttrs:
  - realm: security_defenses (with headers + brute_force_detection),
    otp_policy, web_authn_policy, web_authn_passwordless_policy.
  - openid_clients: authorization, authentication_flow_binding_overrides.
  - saml_clients: authentication_flow_binding_overrides.
  - users: initial_password (LITERAL value until nested-<attr>File
    lands), federated_identity (renders as JSON array directly).

VM test sets security_defenses (headers + brute_force_detection) and
otp_policy on realms.acme, then asserts via the admin API that the
header fields, brute-force settings, and otp_policy fields all reach
keycloak.
Per-realm user-profile schema with typed `attribute` and `group`
lists. Each attribute carries the deep-nested `permissions`
MaxItems:1 block (view + edit roles) and a `validator` list of
`{ name; config; }` blocks.

`blockAttrs = [ "attribute.permissions" ]` exercises the recursive
wrapBlocks (matching inside the list element).

VM test declares a user-profile with five attributes (the four
built-ins + a custom `team`), asserts via /admin/realms/<r>/users/profile
that the custom attribute has the declared view/edit permissions and
that the username attribute has the length validator. Keycloak refuses
to drop the built-ins on PUT, so any real fixture must include them.
Renderer:
  - Replace itemSecrets (top-level only) with substituteSecrets, a
    recursive walk over the cleaned value tree. For every `<attr>File
    = "/path"` at any depth, swap in `<attr> = "${var.<id>}"`, collect
    an (id, file) entry, and use a dotted-path id so nested secrets
    get unique names (e.g.
    `secret_realm_acme_smtp_server_auth_password`). Throws if both
    the literal and the *File sibling are set on the same object.
  - renderItem now returns { label; value; secrets } so secrets flow up
    alongside the JSON values; downstream allSecrets / credentials /
    variable block all key off the same path-aware ids.
  - Top-level <attr>File flows through the same walk and keeps the same
    var ids (a top-level path is just <attr>), so existing fixtures
    aren't affected.

Option declarations:
  - realm: smtp_server.auth.{password,passwordFile} and
    smtp_server.token_auth.{client_secret,client_secretFile} drop from
    required-literal to optional-with-File-alternative.
  - users: initial_password.{value,valueFile} get the same treatment.

VM test adds smtp_server.auth.passwordFile and asserts the literal
("verysecretpassword") is absent from main.tf.json while the
substituted var reference (`secret_realm_acme_smtp_server_auth_password`)
is present.
Refs spec gains an optional `list = true` flag. When set:
  - resourceOptions declares the option as `listOf str`.
  - renderItem maps resolveRef over each element, so each entry is
    either resolved to `${type.label.field}` for managed keys or
    passed through as a literal.

Migrated 13 list-of-refs cases (formerly opaque oListStr attrs where
users had to write `${keycloak_role.X.id}` interpolations by hand):

  - roles.composite_roles -> roles[].id
  - default_roles.default_roles -> roles[].name
  - default_groups.group_ids -> groups[].id
  - group_memberships.members -> users[].username
  - group_roles.role_ids -> roles[].id
  - user_roles.role_ids -> roles[].id
  - user_groups.group_ids -> groups[].id
  - openid_client_default_scopes.default_scopes -> openid_client_scopes[].name
  - openid_client_optional_scopes.optional_scopes -> openid_client_scopes[].name
  - saml_client_default_scopes.default_scopes -> saml_client_scopes[].name
  - realm_default_client_scopes.default_scopes -> openid_client_scopes[].name | saml_client_scopes[].name
  - realm_optional_client_scopes.optional_scopes -> same multi-target
  - realm_client_policy_profile_policies.profiles -> realm_client_policy_profiles[].name

All migrated refs are `managedOnly = false`, so the renderer falls
back to the literal when no managed sibling matches -- built-in role
names like "offline_access" and built-in scope names like "profile"
just work.

VM fixtures drop their inline `${keycloak_role.X.id}` /
`${keycloak_group.X.id}` interpolations in favour of managed keys.
The generated .tf.json is byte-identical; the change is purely
ergonomic.
kmein added 18 commits June 24, 2026 18:05
…face

  - Resources section groups every typed collection by family (realms /
    roles+groups+users / clients+scopes / mappers / IdPs / auth /
    authorization / federation / keystores / realm-level config) with
    the `keycloak_*` type and reference inputs.
  - Adds a worked example exercising managed-key list refs
    (default_roles, group_roles.role_ids), parent refs (realms,
    openid_clients, identity providers, IdP mappers), and nested-secret
    indirection at multiple depths (smtp_server.auth.passwordFile,
    users.<u>.initial_password.valueFile, openid_clients.<c>.
    client_secretFile).
  - Security note expanded to enumerate every secret attribute that
    has <attr>File support (top-level and nested).
  - keycloak (VM, with specialisation): core boot -> bootstrap ->
    reconcile chain + a config-change reroll. Minimal one-realm
    fixture. The only test that needs full QEMU (specialisations only
    work in nodes.<n>).
  - keycloak-rbac (container): roles, groups (with nesting), users,
    role + group bindings via managed-key list refs.
  - keycloak-clients (container): openid_client_scopes, openid_clients,
    default-scope binding, a protocol mapper.
  - keycloak-realm-extras (container): extended realm attrs, smtp_server
    with nested-secret indirection, security_defenses (nested-in-
    nested), otp_policy, realm_keystore_rsa_generated, required_action,
    realm_localization, realm_user_profile (nested-in-list).
  - keycloak-idp (container): google IdP + secret indirection,
    attribute_importer IdP mapper, authentication_flow.

Containers boot via systemd-nspawn, so the 4 non-specialisation tests
start faster and run in parallel (start_all). Each test is
self-contained; the shared mkHost helper carries the common keycloak
service config and pyHelpers (admin_token / admin_get / get_realm).

Treefmt reflowed the README Resources table to even column widths
(no content change); folded into the same commit.
Trim the inline comments throughout the keycloak pairing and
modules/lib/default.nix. Plain words over jargon ("wait for" instead
of "gate on", "reuse a client" instead of "tolerate ... from a partial
earlier bootstrap", "shared" instead of "provider-agnostic"), drop
the multi-paragraph file-header preamble in modules/lib, and shorten
every option description that ran to two or more sentences.

Slips in one small correctness fix: ldap_user_federations.blockAttrs
now lists `kerberos` and `cache` so the nested sub-blocks emit as
`[{ ... }]`. No fixture exercises this today, so the tests are
byte-identical.
Uses Determinate Systems' nix-installer-action plus magic-nix-cache-action
to install Nix and cache builds across runs. Triggers on push and
pull_request. The single `nix flake check -L` covers the forgejo VM
test, the five keycloak tests (one VM, four nspawn containers), and
treefmt formatting.

Standard GitHub Actions / Forgejo-Actions-compatible syntax, so the
workflow ports unchanged if hosting moves.
A descriptive name that calls out the load-bearing concept (declarative
*runtime* state, distinct from build-time config). Updates:

  - flake.nix description.
  - top-level README title + tagline.
  - flake-input examples in services/forgejo/README.md and
    services/keycloak/README.md (input variable `declarative-runtime`,
    URL `github:<org>/declarative-runtime`).

The directory rename and GitHub repo rename are external steps; no
in-tree code references the old name.
The 4 keycloak container tests need systemd-nspawn's UID-range
allocation (gated behind the auto-allocate-uids experimental
feature) plus the cgroups feature + use-cgroups setting for
nspawn's container management. Wire both into the installer
action's extra-conf.
  - Status block lists both pairings with their resource-count scope
    and headline features (keycloak's bootstrap + nested <attr>File).
  - Top-of-file example shows a forgejo and a keycloak runtime
    side-by-side so the per-pairing usage is visible from the entry
    README.
  - Usage section links the keycloak README and mentions the bootstrap
    vs operator-supplied paths.
  - Repository-layout tree adds the keycloak/ directory.
Both `services/<svc>/lib.nix` files had ~270 lines of copy-pasted
renderer machinery (option helpers, resourceOptions generator,
cleanNulls, the tf-config builder with resolveRef + substituteSecrets
+ renderItem + the credential map). Move every shared piece into
`modules/lib/default.nix`:

  - option helpers (oStr/oBool/oInt/oListStr/oAttrsStr/oSub/oListSub/
    rStr/rBool/rMapStr) -- the union of what either pairing used.
  - cleanNulls.
  - resourceOptions, now a function of `resourceTypes`.
  - mkTfConfig: takes resourceTypes + a per-provider record
    (providerName, providerSource, providerVersion, providerBlock,
    runtimePrefix, tokenVar, extraSensitiveVars) and returns
    cfg -> { config; credentials; }.

The keycloak renderer (recursive substituteSecrets walk +
list-of-managed-refs + blockAttrs wrapping) becomes the canonical one
-- a strict superset of forgejo's flat flavour, and forgejo uses none
of the extensions so behaviour is identical.

Net -245 lines; no behaviour change. forgejo + the 5 keycloak tests
cached green (rendered output byte-identical).

services/forgejo/lib.nix and services/keycloak/lib.nix now keep only
their provider import, executor, tokenVar (+ clientIdVar for keycloak),
provider-specific shared refs (realmRef etc.), the resourceTypes
record, and the genlib.mkTfConfig call. forgejo's dormant
requiredScopes stays in forgejo's lib.
  - services/keycloak/checks.nix: openid_clients.acme_app.client_secret
    "topsecret" -> client_secretFile = /etc/acme-app-client-secret.
    Asserts the literal stays out of the generated .tf.json.
  - services/forgejo/checks.nix: users.alice.password "hackme" in the
    widenScope specialisation -> passwordFile = /etc/forgejo-alice-password.
    (bob already used passwordFile; alice was the one literal left.)

The fixtures now exclusively exercise the secret-file indirection, so
the tests stay honest examples for operators.
The keycloak/keycloak v5.7.0 schema registers these policy resources
without the `_authorization_` infix; the spec had it wrong:

  - aggregate_policy, client_policy, group_policy, js_policy,
    role_policy, time_policy, user_policy

Only `keycloak_openid_client_authorization_client_scope_policy` keeps
the infix (and was already correct). Renames the 7 collections, types,
and tf-label prefixes to match the schema; README's resources table
updated to mirror. No fixture exercises these collections, so no test
churn -- but a user reaching for one would have hit `Invalid resource
type` at apply.
…rings

  - When nameAttr is also in requiredAttrs (e.g. users.<k> with
    nameAttr=username), the check now sees the post-nameInject value
    rather than the raw item, so the documented key->name default works
    end-to-end. Previously the check threw for any user-id pair that
    relied on the default.
  - Add empty string to the not-set list. Without it, requiredAttrs =
    ["client_id"] paired with `client_id = ""` slipped through and
    hit the provider as an opaque 400 at apply.
…ndexes list elements in path

Two related renderer hygiene fixes:

  - The conflict throw interpolated `spec.prefix` (tf-label prefix,
    e.g. `realm`/`user`) instead of the option-path `c` (the
    collection name, e.g. `realms`/`users`). The sibling reqSecret
    and reqAttr throws already used `c`. Thread `c` into
    substituteSecrets and use it.
  - Walking into list elements re-used the parent `pathParts`, so
    two list elements with the same `<attr>File` key would collide
    on the credential id. Latent today (no current oListSub carries
    a *File sibling) but a future webhook-style list would trigger
    the uniqueness throw with the wrong remediation hint. Walk with
    `lib.imap0` and include the index in the path.

No fixture output changes (top-level secret ids unchanged); all keycloak
tests cached green.
…t} nullable

Both fields are Required at the provider, but were declared as bare
`listOf str` -- which the nixos module system defaults to `[]`. A
user who set permissions = { view = ["admin"]; } (forgot edit) would
get the .tf.json emitted with edit = [], silently stripping every
editor role for the attribute.

Switch to oListStr (nullable, default null). cleanNulls drops the
key, terraform sees the field as missing, and apply errors with the
provider's required-field message instead of letting a silent role
wipe through.
@kmein kmein changed the title Keycloak Add Keycloak pairing + a runnable demo Jun 25, 2026
@kmein kmein requested a review from aforemny June 25, 2026 11:58
@kmein kmein marked this pull request as ready for review June 25, 2026 11:58
kmein added 3 commits June 25, 2026 14:15
Boots both pairings together with a custom keycloak login theme,
SSO from forgejo into keycloak, a private internal repo, and per-user
avatars. Inert until wired into the flake (next commit).
Each entry in the `examples` attrset is materialised three ways:
`nixosConfigurations.example-<name>` (the full system), `packages.<system>.<name>`
(the qemu-runnable VM, so `nix run .#<name>`), and `checks.<system>.example-<name>`
(eval-only, so option-name drift fails CI without paying for a full VM test).
Boots a declared realm + user + public client (direct access grants
only) and verifies the typed runtime options reach a working OIDC
token endpoint:

  - password grant on the declared client returns access + id tokens
  - id_token + userinfo carry the declared claims (preferred_username,
    email, given/family/name, email_verified)
  - login_with_email_allowed lets the email substitute for the username
  - a wrong password is rejected (must fail)

Container test (~45s), no new fixture dependencies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant