Skip to content

feat(keycloak): replace Keycloak realm setup with keycloak-config-cli #289

Draft
viniciusdc wants to merge 8 commits into
mainfrom
feat/keycloak-config-cli-bootstrap
Draft

feat(keycloak): replace Keycloak realm setup with keycloak-config-cli #289
viniciusdc wants to merge 8 commits into
mainfrom
feat/keycloak-config-cli-bootstrap

Conversation

@viniciusdc

@viniciusdc viniciusdc commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1a of the declarative Keycloak configuration approach proposed in #154. Replaces the imperative shell script in realm-setup-job.yaml (chains of kcadm calls with || true for idempotency) with a declarative pipeline using keycloak-config-cli (kcc).

Structurally aligned with the design rewrite Chuck pushed to #154 on 2026-05-12: kcc input lives in an in-cluster Secret created by NIC at bootstrap from embedded defaults. The gitops repo never holds realm content — only the Job manifest.

Changes

  • pkg/argocd/keycloak_defaults.yaml (new) — kcc realm input in native format. Parity with the previous shell setup: nebari realm settings, admin/user realm roles, admin user with both roles + argocd-admins membership, groups client scope + oidc-group-membership-mapper registered as defaultDefaultClientScopes (load-bearing for operator-managed app authorization via NebariApplication.allowedGroups), argocd OIDC client wired by ArgoCD SSO via Keycloak OIDC #234, argocd-admins/argocd-viewers groups.
  • pkg/argocd/keycloak_defaults.go (new) — //go:embed of the realm YAML + RenderKeycloakDefaults(TemplateData) helper. {{ .Domain }} is substituted at deploy time (used in the argocd client's redirect URI).
  • pkg/argocd/foundational.go — new createKeycloakImportSecret writes the rendered content to an in-cluster Secret named keycloak-config-import in the keycloak namespace. InstallFoundationalServices renders the defaults with cfg.Domain and calls the creator at bootstrap, immediately after the existing Keycloak secret creators.
  • pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml (rewritten) — kcc Job pulling quay.io/adorsys/keycloak-config-cli:6.5.0-26.5.4. Volume mounts the new keycloak-config-import Secret (was the deleted gitops ConfigMap). Existing env-var passthrough for nebari-realm-admin-credentials and argocd-oidc-client-secret Secrets unchanged.
  • pkg/argocd/templates/apps/keycloak.yaml — Keycloak chart image pinned to 26.5.4 to match the kcc build target.
  • pkg/argocd/templates/manifests/keycloak/realm-config-cm.yaml (deleted) — the gitops ConfigMap is gone; realm content no longer leaks into git.

Scope (per #154 review)

Phase 1a only:

  • No keycloak.yaml user-supplied file yet
  • No NIC-side $(env:*) scanning yet
  • No NIC-side merge engine (kcc owns reconciliation via remote-state)
  • No backup/restore

User-supplied schema (Phase 1b) and the user-secrets pipeline (Phase 1c) follow in separate PRs.

Why an in-cluster Secret, not a gitops-repo ConfigMap

Original implementation used a ConfigMap rendered into the gitops repo with $(env:VAR) placeholders. The #154 design rewrite (commit 669458a on design/declarative-keycloak-config) explicitly rejected that pattern:

The gitops repo is treated as untrusted. Any pattern that requires "the file in the repo only contains placeholders, never literals" creates a footgun: one operator mistake leaks a secret into git history forever.

Refactored to the Secret model accordingly. Side benefit for Phase 1b: when a user supplies their own keycloak.yaml, the same Secret carrier accepts it without restructuring.

Operator coexistence

Three independent layers so operator-managed OAuth2 clients (created at runtime by NebariApplication CRDs) are never touched by kcc:

  1. clients: in the kcc input only contains the argocd client. Operator-managed clients are not declared.
  2. IMPORT_REMOTESTATE_ENABLED=true (default in kcc v6.x) — kcc tracks resources it created in a Keycloak realm attribute. Operator-created clients are invisible to kcc's delete logic.
  3. IMPORT_MANAGED_CLIENT=no-delete — belt-and-braces. Even if the input ever changes shape, kcc still won't delete unlisted clients.

My earlier #154 review said "omit clients: entirely" — that was too strict. NIC owns the argocd client at bootstrap and has to declare it. The three-layer protection above is the practical shape.

Migration from the shell-based setup

On existing deployments where the previous realm-setup-job shell script ran successfully, kcc detects the existing realm/users/groups/scope/clients by their natural keys and updates them in place, adding them to remote state. First run is effectively a no-op for already-correct values. The pre-generated argocd OIDC client secret is the same value across both K8s Secrets and Keycloak's stored value (verified during testing), so the OIDC trust survives.

Testing

Verified end-to-end on a fresh kind cluster (kind-nebari-local):

  • keycloak-config-import Secret created at bootstrap from the embedded defaults
  • Gitops repo manifests/keycloak/ directory contains only realm-setup-job.yaml (no realm content)
  • Job's volume references secretName: keycloak-config-import (not the deleted ConfigMap)
  • kcc Job runs in ~4s after Keycloak is reachable, auto-cleaned by HookSucceeded
  • Realm state: nebari realm with all 7 defaultDefaultClientScopes (basic, profile, email, roles, web-origins, acr, groups); argocd client default scopes same set; argocd-admins and argocd-viewers groups created; admin user member of argocd-admins
  • Pre-generated OIDC secret byte-identical across all 3 sites (keycloak-namespace Secret, argocd-secret, Keycloak client config)

pkg/argocd/keycloak_defaults_test.go::TestRenderKeycloakDefaults (new) asserts each load-bearing field of the rendered kcc input + that no unresolved Go-template markers leak into the output. Catches regressions at unit-test time before they hit the cluster.

Findings the testing surfaced (and is now baked into source)

Five non-obvious-from-the-design things came up during real deploys:

  • kcc image tags are tighter than the CI matrix advertises. kcc v6.5.0's CI lists Keycloak 26.5.5 but the actual built image only exists for 6.5.0-26.5.4. Both Keycloak chart image and kcc image pinned to 26.5.4.
  • HookSucceeded-only delete policy traps stuck Jobs. A kcc Job that fails (bad image, network blip, parsing error) never succeeds, so HookSucceeded never fires its cleanup, and ArgoCD's hook-finalizer keeps the Job around indefinitely. Subsequent reconciles can't update the Job's pod template either (Jobs are immutable post-create). Added BeforeHookCreation to the policy so every fresh sync first deletes any existing Job.
  • kcc applies list fields as full-replace, not merge. The previous shell script added groups to defaultDefaultClientScopes via a PUT on the specific scope path (additive). kcc's declarative defaultDefaultClientScopes: [groups] replaces the entire list. All built-in default scopes have to be listed alongside groups, both at realm level and on the argocd client.
  • kcc runs string substitution over the whole file before parsing as YAML. That includes comments. A comment block that literally contained the $(env:VAR) placeholder shape tripped the substitutor with Cannot resolve variable 'env:VAR'. Comments now describe the syntax without writing the literal pattern, plus a warning so the next editor doesn't reintroduce it.
  • ArgoCD's hook-finalizer can leave the application in a stuck operation state if a Job is force-deleted (via stripped finalizer). Surfaces only when iterating on the same cluster (force-deleting a stuck Job to push a corrected manifest). Not a deploy-flow bug — normal first deploys don't hit this. Unstick pattern is kubectl patch app <name> --type=merge -p '{"operation":null,"status":{"operationState":null}}' followed by a fresh sync.

Refs

@viniciusdc

Copy link
Copy Markdown
Contributor Author

Two small in-manifest follow-ups surfaced while talking through user/group lifecycle, both worth doing but holding off this PR to keep the diff focused on the shell-to-kcc swap. Filing here so they don't get lost.

1. userLabel: initial on the admin user's password

Currently:

credentials:
  - type: password
    value: $(env:REALM_ADMIN_PASSWORD)

kcc can't read existing password hashes from Keycloak, so it re-applies the password on every reconcile. If an operator manually rotates the admin password via the Keycloak UI, the next nic deploy reverts it.

Standard kcc mitigation is userLabel: initial, which tells kcc to set the password only at user creation, not on subsequent updates:

credentials:
  - type: password
    value: $(env:REALM_ADMIN_PASSWORD)
    userLabel: initial

After this change, the env-var value is the bootstrap password; any later rotation done in Keycloak survives nic deploy. Doc reference: docs/FEATURES.md "preserveExistingCredentials" pattern in adorsys/keycloak-config-cli.

2. IMPORT_MANAGED_GROUP=no-delete on the kcc Job

Same belt-and-braces shape as the existing IMPORT_MANAGED_CLIENT=no-delete. With IMPORT_REMOTESTATE_ENABLED=true (already set), kcc only deletes groups it itself created — so manually-created groups are already protected. But layering no-delete adds defense against future regressions in remote-state handling, with zero cost. Mirrors the rationale that's already in the IMPORT_MANAGED_CLIENT comment.

- name: IMPORT_MANAGED_GROUP
  value: "no-delete"

Out of scope for this comment but worth noting

The admin user's groups: [/argocd-admins] field in the realm config is also full-replace on kcc re-runs. If an operator manually adds the admin user to additional Keycloak groups via the UI, those memberships get reverted. That's by-design for a managed bootstrap user, but worth documenting in the eventual user-facing docs (#288 follow-up).

User accounts NOT in our users: list (IdP-synced, manually-created, self-registered) are safe — kcc has no IMPORT_MANAGED_USER policy and explicitly leaves the broader user set alone.

@viniciusdc

Copy link
Copy Markdown
Contributor Author

One more note on kcc's capabilities relevant to future scope: identity providers are first-class in kcc's input schema. Setting up SSO (Google, GitHub, GitLab, generic OIDC/SAML, etc.) is the same shape as everything else — a top-level field on the realm with secret values via $(env:VAR):

realms:
  - realm: nebari
    # ...
    identityProviders:
      - alias: github
        providerId: github
        enabled: true
        trustEmail: true
        config:
          clientId: "$(env:GITHUB_CLIENT_ID)"
          clientSecret: "$(env:GITHUB_CLIENT_SECRET)"
          defaultScope: "user:email"

    identityProviderMappers:
      - identityProviderAlias: github
        name: email-mapper
        identityProviderMapper: github-user-attribute-mapper
        config:
          jsonField: email
          userAttribute: email

Not in scope for this PR (Phase 1a is defaults-only), but the shape is what users get in Phase 1b/1c when they put an idps: (or similar) block in their config.yaml keycloak: section. That maps directly through to the realm's identityProviders array in the rendered kcc YAML.

Two reasons this is worth surfacing now:

  1. It validates the broader design — kcc is one tool covering realm config + IdPs + mappers + groups + scopes + clients. We don't need a separate code path for "SSO setup" vs "realm setup". The April 9 discussion about declarative IdPs as an enabler for Google-groups → Keycloak-groups → admin-role mapping lands naturally on top of this PR.

  2. By default kcc applies IMPORT_MANAGED_IDENTITYPROVIDER=full, meaning IdPs not in the input get deleted on re-run. Combined with IMPORT_REMOTESTATE_ENABLED=true, this only affects IdPs kcc itself created — so manually-added IdPs in the Keycloak admin UI are safe today. But once users start declaring IdPs through config.yaml, removing one from config.yaml and re-running nic deploy will delete that IdP from Keycloak. That's by-design for a declarative tool, but worth being explicit about in the user-facing docs (Follow-up to #234: user-facing docs for ArgoCD SSO #288 follow-up).

@viniciusdc viniciusdc changed the title feat(keycloak): replace shell-based realm setup with keycloak-config-cli (Phase 1a) feat(keycloak): replace shell-based realm setup with keycloak-config-cli May 12, 2026
@viniciusdc viniciusdc changed the title feat(keycloak): replace shell-based realm setup with keycloak-config-cli feat(keycloak): replace Keycloak realm setup with keycloak-config-cli Jun 15, 2026
Phase 1a of the declarative Keycloak configuration design proposed in
#154. Replaces the imperative shell script in realm-setup-job.yaml with
a declarative YAML applied by keycloak-config-cli (kcc) at PostSync.

Changes:

- Add `realm-config-cm.yaml`: ConfigMap carrying the kcc-format YAML for
  the nebari realm. Parity with the previous shell setup, including the
  `groups` client scope + group-membership mapper (load-bearing for
  operator-managed app authorization), the `argocd` OIDC client (PR
  #234), the `argocd-admins`/`argocd-viewers` groups, and the admin
  user's `argocd-admins` membership.
- Rewrite `realm-setup-job.yaml`: pulls
  `quay.io/adorsys/keycloak-config-cli:6.5.0-26.5.5`, mounts the
  ConfigMap, and applies it against Keycloak. Secrets are mounted from
  the existing `nebari-realm-admin-credentials` and
  `argocd-oidc-client-secret` Secrets and substituted into the realm
  YAML by kcc at apply time via `$(env:VAR)` placeholders.

Operator coexistence:

- `IMPORT_REMOTESTATE_ENABLED=true` (kcc default since v6.x): kcc tracks
  resources it created in a Keycloak realm attribute. Re-runs only
  reconcile what kcc itself owns; operator-managed OAuth2 clients
  created at runtime via NebariApplication CRDs are invisible to kcc's
  delete logic.
- `IMPORT_MANAGED_CLIENT=no-delete`: belt-and-braces. Even if a future
  input change accidentally removes the argocd client from `clients:`,
  kcc will not delete unlisted clients.

The strict "omit `clients:` entirely" pattern from my #154 review was
too restrictive: NIC owns the `argocd` client at bootstrap, so it has
to be in the input. The two-layer protection above is the practical
shape of the coexistence story.

Phase 1a scope (per #154 review): only replaces the existing realm
setup. No `keycloak:` section in `config.yaml`, no NIC-side env-var
scanning, no merge engine. Defaults are static. Future phases add the
user-facing schema (1b) and the user-secrets pipeline (1c).

Migration: on existing deployments, kcc detects existing resources by
their natural keys (realm name, role name, username, scope name) and
updates them in place, adding them to remote state. First run on an
already-configured cluster is a no-op for existing managed values; the
pre-generated argocd OIDC client secret (same value on both sides) is
re-applied verbatim.

Refs #154.
- Bump kcc availability check timeout from 120s to 300s to match the
  5-minute readiness window of the previous shell script. Keycloak's
  first-start Quarkus augmentation can push past 2 minutes.
- Pin the Keycloak image version (26.5.5) in keycloak.yaml app values
  so the kcc image and Keycloak image versions are co-located. Comment
  in both files calls out the bump-in-lock-step constraint.
- Add TestKeycloakRealmConfigCM to assert the load-bearing fields of
  the rendered realm-config ConfigMap. The kcc input is what makes the
  realm-setup-job produce a valid Keycloak state, and regressions would
  otherwise be silent (YAML renders fine but Keycloak gets
  misconfigured).
Two fixes surfaced during E2E testing on a fresh kind cluster:

- The earlier `6.5.0-26.5.5` kcc image tag does not exist on quay.io.
  The actual built tags for kcc v6.5.0 stop at Keycloak `26.5.4`
  (confirmed via the adorsys/keycloak-config-cli release assets and
  quay.io tag listing). Bump both the Keycloak chart image and the kcc
  image to `26.5.4` so the pair matches a real released combination.

- Add `BeforeHookCreation` to the realm-setup Job's
  `argocd.argoproj.io/hook-delete-policy`. Without it, a Job that fails
  for any reason (bad image, network blip, kcc parsing error) stays in
  the cluster forever: Kubernetes Jobs are immutable post-create, and
  ArgoCD's PostSync hook reconciler won't recreate one that hasn't
  succeeded. The `HookSucceeded`-only policy on main has the same
  fragility. With `BeforeHookCreation` added, every fresh sync first
  deletes any existing Job, then creates a new one from the current
  manifest — making upgrades safe even from a previously-failed run.

  Note this annotation alone does NOT unblock a cluster where ArgoCD's
  hook-finalizer is already stuck on a terminating Job from a prior
  run; that requires manual finalizer removal. But it prevents the
  class of issue going forward.
The previous version of realm-config-cm.yaml declared
`defaultDefaultClientScopes: [groups]` at the realm level and
`defaultClientScopes: [groups]` on the argocd client. kcc applies list
fields as full replaces, not merges, so this stripped the built-in
default scopes (basic, profile, email, roles, web-origins, acr) from
the realm — tokens would be missing email/profile/roles claims that
ArgoCD's UI and RBAC depend on.

E2E test confirmed the regression on a fresh kind cluster: after the
kcc Job ran successfully, the realm's default-default scopes contained
only [groups] and the argocd client's default scopes were the same.
The previous shell script avoided this by issuing PUTs against the
specific scope path, which adds without touching the rest of the list.

Fix:

- Realm-level: list all built-in default scopes plus `groups` in
  `defaultDefaultClientScopes` so kcc's replace semantics produces the
  same end state as a merge would.
- argocd client: drop the explicit `defaultClientScopes` entry
  entirely. Keycloak auto-applies the realm's default-defaults to new
  clients, which now correctly includes everything we need. Explicitly
  setting the field would replace that auto-applied list.

Test (writer_test.go) updated to assert each built-in scope is present
in the rendered ConfigMap and that the argocd client does not set
`defaultClientScopes`. Caught at unit-test time going forward.
Follow-on to the realm-level scope fix in the previous commit.

E2E re-test confirmed half of the fix landed: realm
defaultDefaultClientScopes now correctly contains all 7 scopes after
kcc applied the updated YAML. But the argocd client's
defaultClientScopes was still just [groups] — Keycloak's realm
default-defaults are auto-applied to new clients *at creation time
only*, not retroactively on subsequent updates. So an existing argocd
client never picked up the new realm defaults.

Fix: explicitly list the full scope set on the argocd client too. kcc
applies it as replace, but since the list is the complete intended
state, the end result is correct. Redundant with the realm-level
defaults for fresh-creation cases, load-bearing for re-runs.

Test asserts the argocd client's defaultClientScopes line and its
first entry (basic) are both present in the rendered ConfigMap.
…write

Aligns Phase 1a with the agreed design in PR #154's rewrite (commit
669458a on design/declarative-keycloak-config). Previous structure put
the kcc realm input in a ConfigMap rendered into the gitops repo. The
rewrite's "Why no ConfigMap in the gitops repo" section explicitly
rejects that pattern:

- The gitops repo is treated as untrusted. Placeholder-only contracts
  ("file in repo only contains $(env:...), never literals") create a
  footgun where one operator mistake leaks a secret into git history.
- Realm content beyond raw secrets is still sensitive (IdP names,
  redirect URI patterns, credential hashes from kc.sh export).

New structure:

- pkg/argocd/keycloak_defaults.yaml (new) — kcc realm input in native
  format, embedded into the NIC binary via //go:embed. Same content
  as the previous gitops ConfigMap data field, no ConfigMap wrapping.
- pkg/argocd/keycloak_defaults.go (new) — RenderKeycloakDefaults()
  helper renders the template with TemplateData (Domain substitutes
  into the argocd redirect URI).
- pkg/argocd/foundational.go — InstallFoundationalServices now renders
  the embedded defaults with cfg.Domain and writes the result to a
  new in-cluster Secret keycloak-config-import (keycloak namespace).
  createKeycloakImportSecret added as a sibling of the existing
  Secret creators; uses createSecret's no-op-if-exists semantics so
  re-deploys preserve any value already present in the cluster.
- templates/manifests/keycloak/realm-setup-job.yaml — volume changed
  from a ConfigMap reference (keycloak-realm-config, deleted) to a
  Secret reference (keycloak-config-import). Everything else stays:
  same kcc image, same env-var sourcing, same hook annotations.
- templates/manifests/keycloak/realm-config-cm.yaml (deleted) — the
  gitops repo no longer carries realm content. Only the Job manifest
  remains there.

Phase 1a scope is unchanged. Phase 1b will introduce a user-supplied
keycloak.yaml file path that overrides these embedded defaults; the
Secret-vs-ConfigMap structural decision now makes that path direct.

Test:

- keycloak_defaults_test.go (new) asserts the load-bearing fields of
  the rendered kcc input — realm settings, scope lists, groups,
  argocd client, the oidc-group-membership-mapper, the full
  defaultDefaultClientScopes list, and that no unresolved Go-template
  markers leak into the output.
- writer_test.go TestKeycloakRealmConfigCM removed (it tested the
  deleted ConfigMap template).
kcc resolves $(env:VAR) placeholders via Apache Commons StringSubstitutor
over the whole file BEFORE parsing as YAML, so even comments are
scanned for unresolved variables. The previous comment block in
keycloak_defaults.yaml literally described the placeholder pattern as
"$(env:VAR)", which kcc treated as a real reference and failed with
"Cannot resolve variable 'env:VAR' (enableSubstitutionInVariables=true)"
before applying anything.

Rewords the comment to describe the syntax without writing the literal
shape, and adds an explicit warning so future editors don't reintroduce
the trap.

Caught during E2E on the refactored branch: the new
keycloak-config-import Secret carried the original comment verbatim,
so the kcc Job failed-fast on every retry until the comment was
sanitized. Post-fix E2E (kind-nebari-local, fresh deploy) ran the kcc
Job to completion in seconds and produced the expected realm state
(all 7 default scopes on both realm and argocd client, both
argocd-admins/argocd-viewers groups, admin user member of
argocd-admins).
- Extract labelPartOf/labelManagedBy/labelManagedByValue constants so the
  import-secret labels stop tripping goconst (4th occurrence of the label
  keys). Fixes the lint failure blocking CI.
- Instrument RenderKeycloakDefaults with an OTel span and thread ctx through
  from InstallFoundationalServices, per the pkg/ instrumentation convention.
- Render with missingkey=error so a stray template field fails loudly instead
  of emitting "<no value>" into the realm config; add a guard assertion.
- Expand TestRenderKeycloakDefaults to cover the security-relevant scalars
  (sslRequired, registrationAllowed, bruteForceProtected), the realm roles,
  the multivalued groups-mapper flag, the argocd client flags, and webOrigins.
- Drop the dead doc path from keycloak_defaults.yaml; point at #154.
@viniciusdc viniciusdc force-pushed the feat/keycloak-config-cli-bootstrap branch from 91931f7 to d57e8b7 Compare June 19, 2026 05:16
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.

2 participants