Skip to content
64 changes: 58 additions & 6 deletions pkg/argocd/foundational.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const (

// NebariFoundationalPartOf is the value of the app.kubernetes.io/part-of label for foundational resources.
NebariFoundationalPartOf = "nebari-foundational"

// labelPartOf and labelManagedBy are the standard Kubernetes recommended
// label keys applied to foundational resources NIC creates.
labelPartOf = "app.kubernetes.io/part-of"
labelManagedBy = "app.kubernetes.io/managed-by"

// labelManagedByValue marks resources as managed by NIC.
labelManagedByValue = "nebari-infrastructure-core"
)

// FoundationalConfig holds configuration for foundational services
Expand Down Expand Up @@ -140,6 +148,23 @@ func InstallFoundationalServices(ctx context.Context, cfg *config.NebariConfig,
return fmt.Errorf("failed to create Keycloak secrets: %w", err)
}

// Render the embedded keycloak-config-cli defaults and write them
// to the in-cluster Secret the realm-setup Job will mount. The
// gitops repo never holds realm content under this design.
domain := cfg.Domain
if domain == "" {
domain = "nebari.local"
}
importContent, err := RenderKeycloakDefaults(ctx, TemplateData{Domain: domain})
if err != nil {
span.RecordError(err)
return fmt.Errorf("failed to render keycloak-config-cli defaults: %w", err)
}
if err := createKeycloakImportSecret(ctx, k8sClient, importContent); err != nil {
span.RecordError(err)
return fmt.Errorf("failed to create %s secret: %w", KeycloakImportSecretName, err)
}

// Create namespace for Nebari system services
if err := createNamespace(ctx, k8sClient, NebariSystemNamespace); err != nil {
span.RecordError(err)
Expand Down Expand Up @@ -287,8 +312,8 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
Name: "nebari-realm-admin-credentials",
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
labelPartOf: NebariFoundationalPartOf,
labelManagedBy: labelManagedByValue,
},
},
Type: corev1.SecretTypeOpaque,
Expand All @@ -308,8 +333,8 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
Name: "argocd-oidc-client-secret",
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
labelPartOf: NebariFoundationalPartOf,
labelManagedBy: labelManagedByValue,
},
},
Type: corev1.SecretTypeOpaque,
Expand All @@ -324,6 +349,33 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
return nil
}

// createKeycloakImportSecret writes the keycloak-config-cli input to an
// in-cluster Secret named `keycloak-config-import` in the `keycloak`
// namespace. The realm-setup Job mounts this Secret read-only and applies
// the file against Keycloak.
//
// The Secret carrier (rather than a gitops-repo ConfigMap) keeps realm
// structure and any inline credentials out of git: only the Job manifest
// lives in the gitops repo. Phase 1a writes only the embedded defaults;
// Phase 1b will overwrite or replace this content with the user's
// keycloak.yaml.
func createKeycloakImportSecret(ctx context.Context, client kubernetes.Interface, importContent []byte) error {
return createSecret(ctx, client, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: KeycloakImportSecretName,
Namespace: KeycloakDefaultNamespace,
Labels: map[string]string{
labelPartOf: NebariFoundationalPartOf,
labelManagedBy: labelManagedByValue,
},
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
KeycloakImportSecretKey: importContent,
},
})
}

// createLandingPageSecrets creates the required secrets for the nebari-landing service
func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface, landingCfg LandingPageConfig) error {
namespace := NebariSystemNamespace
Expand All @@ -335,8 +387,8 @@ func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface,
Name: NebariLandingRedisSecretName,
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
labelPartOf: NebariFoundationalPartOf,
labelManagedBy: labelManagedByValue,
},
},
Type: corev1.SecretTypeOpaque,
Expand Down
56 changes: 56 additions & 0 deletions pkg/argocd/keycloak_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package argocd

import (
"bytes"
"context"
_ "embed"
"fmt"
"text/template"

"go.opentelemetry.io/otel"
)

// keycloakDefaultsRaw is the default keycloak-config-cli input for the nebari
// realm. It is embedded as a Go template and rendered with TemplateData at
// deploy time before being written to the in-cluster Secret.
//
//go:embed keycloak_defaults.yaml
var keycloakDefaultsRaw []byte

const (
// KeycloakImportSecretName is the in-cluster Secret carrying the kcc
// input that the realm-setup Job applies. The content lives only inside
// the cluster's Secret store; the gitops repo never holds realm
// structure or inline credentials.
KeycloakImportSecretName = "keycloak-config-import" //nolint:gosec // secret name reference

// KeycloakImportSecretKey is the key inside KeycloakImportSecretName
// under which the rendered kcc input lives.
KeycloakImportSecretKey = "realm.yaml"
)

// RenderKeycloakDefaults renders the embedded default kcc input with the
// supplied TemplateData. Only the Domain field is substituted today (used
// to build the argocd client's redirect URI); future fields can be wired
// in as the schema grows.
//
// missingkey=error makes a stray template field (e.g. a future {{ .Typo }})
// fail loudly here rather than silently emitting "<no value>" into the realm
// config, where it would surface only as a misconfigured Keycloak at runtime.
func RenderKeycloakDefaults(ctx context.Context, data TemplateData) ([]byte, error) {
tracer := otel.Tracer("nebari-infrastructure-core")
_, span := tracer.Start(ctx, "argocd.RenderKeycloakDefaults")
defer span.End()

tmpl, err := template.New("keycloak_defaults.yaml").Option("missingkey=error").Parse(string(keycloakDefaultsRaw))
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("parse keycloak defaults template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
span.RecordError(err)
return nil, fmt.Errorf("render keycloak defaults: %w", err)
}
return buf.Bytes(), nil
}
115 changes: 115 additions & 0 deletions pkg/argocd/keycloak_defaults.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Default keycloak-config-cli input for the nebari realm.
#
# Phase 1a of the declarative Keycloak configuration approach proposed in
# nebari-infrastructure-core#154. This file is embedded into the NIC binary
# via go:embed (see
# keycloak_defaults.go), rendered with TemplateData at deploy time, and
# written verbatim to the in-cluster Secret `keycloak-config-import`. The
# kcc Job mounts that Secret read-only and applies the file against
# Keycloak.
#
# The gitops repo never sees this content. Realm structure and any
# inline credentials (none currently; secret-bearing fields use kcc's
# variable-substitution syntax) stay inside the cluster's Secret store.
#
# kcc resolves placeholders via Apache Commons StringSubstitutor BEFORE
# parsing the file as YAML. That means even comments are scanned for
# unresolved variables, so do NOT write the literal placeholder shape
# inside any comment in this file or kcc will fail with
# "Cannot resolve variable" before applying anything. The actual
# placeholders live in field values below.
# {{ .Domain }} is rendered by Go's text/template at NIC deploy time.
realm: nebari
enabled: true
displayName: Nebari
sslRequired: external
registrationAllowed: false
loginWithEmailAllowed: true
resetPasswordAllowed: true
bruteForceProtected: true

# kcc applies list fields as a full replace, not a merge. We must
# include the built-in default scopes (Keycloak auto-creates these on
# realm creation) here so they survive the import — otherwise tokens
# would be missing email/profile/roles/etc. claims.
defaultDefaultClientScopes:
- basic
- profile
- email
- roles
- web-origins
- acr
- groups

roles:
realm:
- name: admin
description: Administrator role
- name: user
description: Regular user role

groups:
- name: argocd-admins
- name: argocd-viewers

users:
- username: admin
enabled: true
emailVerified: true
firstName: Admin
lastName: User
email: admin@nebari.local
realmRoles:
- admin
- user
credentials:
- type: password
value: $(env:REALM_ADMIN_PASSWORD)
groups:
- /argocd-admins

clientScopes:
- name: groups
protocol: openid-connect
attributes:
include.in.token.scope: "true"
display.on.consent.screen: "true"
protocolMappers:
- name: group-membership
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
full.path: "false"
introspection.token.claim: "true"
userinfo.token.claim: "true"
multivalued: "true"
id.token.claim: "true"
access.token.claim: "true"
claim.name: groups

clients:
- clientId: argocd
enabled: true
protocol: openid-connect
publicClient: false
secret: $(env:ARGOCD_CLIENT_SECRET)
redirectUris:
- https://argocd.{{ .Domain }}/auth/callback
webOrigins:
- https://argocd.{{ .Domain }}
directAccessGrantsEnabled: false
standardFlowEnabled: true
# The realm's defaultDefaultClientScopes auto-apply only at client
# CREATION time, not on subsequent updates. So we must explicitly
# list the full set of default scopes here — otherwise kcc's
# replace semantics on this field would leave older clients with
# whatever they had at creation. Same content as the realm
# default-defaults above so kcc converges to the expected state.
defaultClientScopes:
- basic
- profile
- email
- roles
- web-origins
- acr
- groups
87 changes: 87 additions & 0 deletions pkg/argocd/keycloak_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package argocd

import (
"context"
"strings"
"testing"
)

// TestRenderKeycloakDefaults asserts the load-bearing fields of the
// rendered kcc input that NIC writes to the keycloak-config-import
// Secret. The rendered content is what makes the realm-setup Job
// produce a valid Keycloak state; regressions here are silent (the YAML
// renders fine but Keycloak gets misconfigured), so we check structure.
func TestRenderKeycloakDefaults(t *testing.T) {
rendered, err := RenderKeycloakDefaults(context.Background(), TemplateData{Domain: "test.example.com"})
if err != nil {
t.Fatalf("RenderKeycloakDefaults error: %v", err)
}
output := string(rendered)

// kcc realm payload — load-bearing fields the realm-setup-job depends on
for _, want := range []string{
"realm: nebari",
"defaultDefaultClientScopes:",
// kcc applies list fields as full-replace, so the built-in default
// scopes must be present here or tokens lose email/profile/roles.
"- basic",
"- profile",
"- email",
"- roles",
"- web-origins",
"- acr",
"- groups",
"- name: argocd-admins",
"- name: argocd-viewers",
"username: admin",
"$(env:REALM_ADMIN_PASSWORD)",
"- /argocd-admins",
"protocolMapper: oidc-group-membership-mapper",
"claim.name: groups",
"clientId: argocd",
"$(env:ARGOCD_CLIENT_SECRET)",
"https://argocd.test.example.com/auth/callback",
// Realm security posture — silent-but-damaging if a regression flips these.
"sslRequired: external",
"registrationAllowed: false",
"bruteForceProtected: true",
// Realm roles assigned to the bootstrap admin user.
"- name: admin",
"- name: user",
// Group-membership mapper emits a multivalued groups claim.
`multivalued: "true"`,
// argocd client security flags + CORS origin (webOrigins is distinct
// from redirectUris and Domain-templated, so assert it renders too).
"publicClient: false",
"standardFlowEnabled: true",
"directAccessGrantsEnabled: false",
"https://argocd.test.example.com\n",
} {
if !strings.Contains(output, want) {
t.Errorf("expected %q in rendered realm config, got:\n%s", want, output)
}
}

// The argocd client must explicitly list the full set of default
// scopes. Keycloak's realm default-defaults auto-apply only at client
// creation, not on subsequent updates — so we need this redundancy to
// keep kcc's replace semantics from stripping scopes on existing
// clients during a re-run.
if !strings.Contains(output, " defaultClientScopes:\n - basic") {
t.Errorf("argocd client must explicitly list defaultClientScopes starting with built-ins, got:\n%s", output)
}

// The rendered content must not contain any unresolved Go-template
// markers. If a future edit introduces a new {{ . }} reference and
// forgets to add the field, this catches it instead of letting it
// land in Keycloak as a literal.
if strings.Contains(output, "{{") || strings.Contains(output, "}}") {
t.Errorf("rendered output contains unresolved template markers, got:\n%s", output)
}

// missingkey=error should make a stray field error out, but guard against
// a rendered "<no value>" leaking into the realm config regardless.
if strings.Contains(output, "<no value>") {
t.Errorf("rendered output contains \"<no value>\" from an unresolved field, got:\n%s", output)
}
}
5 changes: 5 additions & 0 deletions pkg/argocd/templates/apps/keycloak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ spec:
helm:
releaseName: keycloak
values: |
# Pin the Keycloak image. The realm-setup-job uses a
# keycloak-config-cli build that's verified against this exact
# Keycloak version; bumping one means bumping the other.
image:
tag: "26.5.4"
args:
- start
replicas: 1
Expand Down
Loading
Loading