Skip to content

Commit 30a05a8

Browse files
committed
refactor(keycloak): embedded defaults + in-cluster Secret per #154 rewrite
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).
1 parent 140830d commit 30a05a8

7 files changed

Lines changed: 270 additions & 182 deletions

File tree

pkg/argocd/foundational.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ func InstallFoundationalServices(ctx context.Context, cfg *config.NebariConfig,
140140
return fmt.Errorf("failed to create Keycloak secrets: %w", err)
141141
}
142142

143+
// Render the embedded keycloak-config-cli defaults and write them
144+
// to the in-cluster Secret the realm-setup Job will mount. The
145+
// gitops repo never holds realm content under this design.
146+
domain := cfg.Domain
147+
if domain == "" {
148+
domain = "nebari.local"
149+
}
150+
importContent, err := RenderKeycloakDefaults(TemplateData{Domain: domain})
151+
if err != nil {
152+
span.RecordError(err)
153+
return fmt.Errorf("failed to render keycloak-config-cli defaults: %w", err)
154+
}
155+
if err := createKeycloakImportSecret(ctx, k8sClient, importContent); err != nil {
156+
span.RecordError(err)
157+
return fmt.Errorf("failed to create %s secret: %w", KeycloakImportSecretName, err)
158+
}
159+
143160
// Create namespace for Nebari system services
144161
if err := createNamespace(ctx, k8sClient, NebariSystemNamespace); err != nil {
145162
span.RecordError(err)
@@ -324,6 +341,33 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
324341
return nil
325342
}
326343

344+
// createKeycloakImportSecret writes the keycloak-config-cli input to an
345+
// in-cluster Secret named `keycloak-config-import` in the `keycloak`
346+
// namespace. The realm-setup Job mounts this Secret read-only and applies
347+
// the file against Keycloak.
348+
//
349+
// The Secret carrier (rather than a gitops-repo ConfigMap) keeps realm
350+
// structure and any inline credentials out of git: only the Job manifest
351+
// lives in the gitops repo. Phase 1a writes only the embedded defaults;
352+
// Phase 1b will overwrite or replace this content with the user's
353+
// keycloak.yaml.
354+
func createKeycloakImportSecret(ctx context.Context, client kubernetes.Interface, importContent []byte) error {
355+
return createSecret(ctx, client, &corev1.Secret{
356+
ObjectMeta: metav1.ObjectMeta{
357+
Name: KeycloakImportSecretName,
358+
Namespace: KeycloakDefaultNamespace,
359+
Labels: map[string]string{
360+
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
361+
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
362+
},
363+
},
364+
Type: corev1.SecretTypeOpaque,
365+
Data: map[string][]byte{
366+
KeycloakImportSecretKey: importContent,
367+
},
368+
})
369+
}
370+
327371
// createLandingPageSecrets creates the required secrets for the nebari-landing service
328372
func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface, landingCfg LandingPageConfig) error {
329373
namespace := NebariSystemNamespace

pkg/argocd/keycloak_defaults.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package argocd
2+
3+
import (
4+
"bytes"
5+
_ "embed"
6+
"fmt"
7+
"text/template"
8+
)
9+
10+
// keycloakDefaultsRaw is the default keycloak-config-cli input for the nebari
11+
// realm. It is embedded as a Go template and rendered with TemplateData at
12+
// deploy time before being written to the in-cluster Secret.
13+
//
14+
//go:embed keycloak_defaults.yaml
15+
var keycloakDefaultsRaw []byte
16+
17+
const (
18+
// KeycloakImportSecretName is the in-cluster Secret carrying the kcc
19+
// input that the realm-setup Job applies. The content lives only inside
20+
// the cluster's Secret store; the gitops repo never holds realm
21+
// structure or inline credentials.
22+
KeycloakImportSecretName = "keycloak-config-import" //nolint:gosec // secret name reference
23+
24+
// KeycloakImportSecretKey is the key inside KeycloakImportSecretName
25+
// under which the rendered kcc input lives.
26+
KeycloakImportSecretKey = "realm.yaml"
27+
)
28+
29+
// RenderKeycloakDefaults renders the embedded default kcc input with the
30+
// supplied TemplateData. Only the Domain field is substituted today (used
31+
// to build the argocd client's redirect URI); future fields can be wired
32+
// in as the schema grows.
33+
func RenderKeycloakDefaults(data TemplateData) ([]byte, error) {
34+
tmpl, err := template.New("keycloak_defaults.yaml").Parse(string(keycloakDefaultsRaw))
35+
if err != nil {
36+
return nil, fmt.Errorf("parse keycloak defaults template: %w", err)
37+
}
38+
var buf bytes.Buffer
39+
if err := tmpl.Execute(&buf, data); err != nil {
40+
return nil, fmt.Errorf("render keycloak defaults: %w", err)
41+
}
42+
return buf.Bytes(), nil
43+
}

pkg/argocd/keycloak_defaults.yaml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Default keycloak-config-cli input for the nebari realm.
2+
#
3+
# Phase 1a of the declarative Keycloak configuration approach (see
4+
# docs/superpowers/specs/2026-03-12-declarative-keycloak-config-design.md).
5+
# This file is embedded into the NIC binary via go:embed (see
6+
# keycloak_defaults.go), rendered with TemplateData at deploy time, and
7+
# written verbatim to the in-cluster Secret `keycloak-config-import`. The
8+
# kcc Job mounts that Secret read-only and applies the file against
9+
# Keycloak.
10+
#
11+
# The gitops repo never sees this content. Realm structure and any
12+
# inline credentials (none currently — we use $(env:VAR) placeholders)
13+
# stay inside the cluster's Secret store.
14+
#
15+
# `$(env:VAR)` placeholders are substituted by kcc at apply time from env
16+
# vars set on the Job (see templates/manifests/keycloak/realm-setup-job.yaml).
17+
# `{{ .Domain }}` is rendered by Go's text/template at NIC deploy time.
18+
realm: nebari
19+
enabled: true
20+
displayName: Nebari
21+
sslRequired: external
22+
registrationAllowed: false
23+
loginWithEmailAllowed: true
24+
resetPasswordAllowed: true
25+
bruteForceProtected: true
26+
27+
# kcc applies list fields as a full replace, not a merge. We must
28+
# include the built-in default scopes (Keycloak auto-creates these on
29+
# realm creation) here so they survive the import — otherwise tokens
30+
# would be missing email/profile/roles/etc. claims.
31+
defaultDefaultClientScopes:
32+
- basic
33+
- profile
34+
- email
35+
- roles
36+
- web-origins
37+
- acr
38+
- groups
39+
40+
roles:
41+
realm:
42+
- name: admin
43+
description: Administrator role
44+
- name: user
45+
description: Regular user role
46+
47+
groups:
48+
- name: argocd-admins
49+
- name: argocd-viewers
50+
51+
users:
52+
- username: admin
53+
enabled: true
54+
emailVerified: true
55+
firstName: Admin
56+
lastName: User
57+
email: admin@nebari.local
58+
realmRoles:
59+
- admin
60+
- user
61+
credentials:
62+
- type: password
63+
value: $(env:REALM_ADMIN_PASSWORD)
64+
groups:
65+
- /argocd-admins
66+
67+
clientScopes:
68+
- name: groups
69+
protocol: openid-connect
70+
attributes:
71+
include.in.token.scope: "true"
72+
display.on.consent.screen: "true"
73+
protocolMappers:
74+
- name: group-membership
75+
protocol: openid-connect
76+
protocolMapper: oidc-group-membership-mapper
77+
config:
78+
full.path: "false"
79+
introspection.token.claim: "true"
80+
userinfo.token.claim: "true"
81+
multivalued: "true"
82+
id.token.claim: "true"
83+
access.token.claim: "true"
84+
claim.name: groups
85+
86+
clients:
87+
- clientId: argocd
88+
enabled: true
89+
protocol: openid-connect
90+
publicClient: false
91+
secret: $(env:ARGOCD_CLIENT_SECRET)
92+
redirectUris:
93+
- https://argocd.{{ .Domain }}/auth/callback
94+
webOrigins:
95+
- https://argocd.{{ .Domain }}
96+
directAccessGrantsEnabled: false
97+
standardFlowEnabled: true
98+
# The realm's defaultDefaultClientScopes auto-apply only at client
99+
# CREATION time, not on subsequent updates. So we must explicitly
100+
# list the full set of default scopes here — otherwise kcc's
101+
# replace semantics on this field would leave older clients with
102+
# whatever they had at creation. Same content as the realm
103+
# default-defaults above so kcc converges to the expected state.
104+
defaultClientScopes:
105+
- basic
106+
- profile
107+
- email
108+
- roles
109+
- web-origins
110+
- acr
111+
- groups
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package argocd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestRenderKeycloakDefaults asserts the load-bearing fields of the
9+
// rendered kcc input that NIC writes to the keycloak-config-import
10+
// Secret. The rendered content is what makes the realm-setup Job
11+
// produce a valid Keycloak state; regressions here are silent (the YAML
12+
// renders fine but Keycloak gets misconfigured), so we check structure.
13+
func TestRenderKeycloakDefaults(t *testing.T) {
14+
rendered, err := RenderKeycloakDefaults(TemplateData{Domain: "test.example.com"})
15+
if err != nil {
16+
t.Fatalf("RenderKeycloakDefaults error: %v", err)
17+
}
18+
output := string(rendered)
19+
20+
// kcc realm payload — load-bearing fields the realm-setup-job depends on
21+
for _, want := range []string{
22+
"realm: nebari",
23+
"defaultDefaultClientScopes:",
24+
// kcc applies list fields as full-replace, so the built-in default
25+
// scopes must be present here or tokens lose email/profile/roles.
26+
"- basic",
27+
"- profile",
28+
"- email",
29+
"- roles",
30+
"- web-origins",
31+
"- acr",
32+
"- groups",
33+
"- name: argocd-admins",
34+
"- name: argocd-viewers",
35+
"username: admin",
36+
"$(env:REALM_ADMIN_PASSWORD)",
37+
"- /argocd-admins",
38+
"protocolMapper: oidc-group-membership-mapper",
39+
"claim.name: groups",
40+
"clientId: argocd",
41+
"$(env:ARGOCD_CLIENT_SECRET)",
42+
"https://argocd.test.example.com/auth/callback",
43+
} {
44+
if !strings.Contains(output, want) {
45+
t.Errorf("expected %q in rendered realm config, got:\n%s", want, output)
46+
}
47+
}
48+
49+
// The argocd client must explicitly list the full set of default
50+
// scopes. Keycloak's realm default-defaults auto-apply only at client
51+
// creation, not on subsequent updates — so we need this redundancy to
52+
// keep kcc's replace semantics from stripping scopes on existing
53+
// clients during a re-run.
54+
if !strings.Contains(output, " defaultClientScopes:\n - basic") {
55+
t.Errorf("argocd client must explicitly list defaultClientScopes starting with built-ins, got:\n%s", output)
56+
}
57+
58+
// The rendered content must not contain any unresolved Go-template
59+
// markers. If a future edit introduces a new {{ . }} reference and
60+
// forgets to add the field, this catches it instead of letting it
61+
// land in Keycloak as a literal.
62+
if strings.Contains(output, "{{") || strings.Contains(output, "}}") {
63+
t.Errorf("rendered output contains unresolved template markers, got:\n%s", output)
64+
}
65+
}

pkg/argocd/templates/manifests/keycloak/realm-config-cm.yaml

Lines changed: 0 additions & 108 deletions
This file was deleted.

0 commit comments

Comments
 (0)