From 3fde6255dc1d87ab13ad4d0cbad02aeb5912d23e Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:51:16 +0200 Subject: [PATCH 1/9] Add design spec for ArgoCD Keycloak SSO integration (#227) --- .../2026-04-08-argocd-keycloak-sso-design.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-argocd-keycloak-sso-design.md diff --git a/docs/superpowers/specs/2026-04-08-argocd-keycloak-sso-design.md b/docs/superpowers/specs/2026-04-08-argocd-keycloak-sso-design.md new file mode 100644 index 0000000..a202363 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-argocd-keycloak-sso-design.md @@ -0,0 +1,295 @@ +# ArgoCD Keycloak SSO Design + +**Issue:** https://github.com/nebari-dev/nebari-infrastructure-core/issues/227 +**Date:** 2026-04-08 + +## Problem + +ArgoCD is deployed with only the built-in admin account. Nebari users who have Keycloak accounts cannot access ArgoCD without being given the admin password. There is no group-based access control. + +## Design + +Integrate ArgoCD with Keycloak OIDC as part of the initial deploy flow. Two Keycloak groups control access: + +- `argocd-admins` - mapped to ArgoCD `role:admin` (full access) +- `argocd-viewers` - mapped to ArgoCD `role:readonly` (read-only) + +The realm admin user created during setup is added to `argocd-admins`. + +### Architecture + +The integration touches three layers, all executed during `nic deploy`: + +``` +Go (foundational.go) Keycloak (realm-setup-job) ArgoCD (Helm values) +───────────────────── ────────────────────────── ──────────────────── +Generate client secret --> Create OIDC client with --> OIDC config referencing +Store in argocd-secret pre-generated secret $oidc.keycloak.clientSecret + Create groups RBAC policy.csv mapping + Add admin to argocd-admins groups to roles +``` + +### Layer 1: Go Code Changes + +#### 1a. New secret in `foundational.go` + +Add a new secret `argocd-oidc-secret` to the `argocd` namespace containing the pre-generated OIDC client secret. This secret is created **before** ArgoCD is installed, so ArgoCD's Helm values can reference it. + +However, ArgoCD's `$variable` secret reference syntax only works with the `argocd-secret` Secret (or secrets labeled `app.kubernetes.io/part-of: argocd`). The simplest approach is to inject the client secret directly into ArgoCD's Helm values via `configs.secret.extra`, which adds it to the `argocd-secret` Secret. + +**Changes to `FoundationalConfig`:** + +```go +type FoundationalConfig struct { + Keycloak KeycloakConfig + ArgoCD ArgoCDSSOConfig // NEW + LandingPage LandingPageConfig + MetalLB MetalLBConfig +} + +type ArgoCDSSOConfig struct { + ClientSecret string // Pre-generated OIDC client secret for ArgoCD +} +``` + +**Changes to `deploy.go`:** + +Generate the client secret alongside other passwords: + +```go +foundationalCfg := argocd.FoundationalConfig{ + // ... existing fields ... + ArgoCD: argocd.ArgoCDSSOConfig{ + ClientSecret: generateSecurePassword(rand.Reader), + }, +} +``` + +Also store it as a Kubernetes secret in the `keycloak` namespace so the realm-setup job can read it: + +```go +// In createKeycloakSecrets() or a new createArgoCDSecrets(): +createSecret(ctx, client, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "argocd-oidc-client-secret", + Namespace: KeycloakDefaultNamespace, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "client-secret": foundationalCfg.ArgoCD.ClientSecret, + }, +}) +``` + +#### 1b. ArgoCD Helm values in `config.go` + +The `DefaultConfig()` function currently only sets `server.insecure: true`. It needs to be extended to accept OIDC parameters. Since the Keycloak issuer URL and client secret are runtime values (they depend on the domain and generated password), `DefaultConfig()` should accept these as parameters, or a new function should build the complete config. + +**New function - `ConfigWithOIDC`:** + +```go +func ConfigWithOIDC(domain, keycloakBasePath, clientSecret string) Config { + cfg := DefaultConfig() + + issuerURL := fmt.Sprintf("https://keycloak.%s%s/realms/nebari", domain, keycloakBasePath) + argocdURL := fmt.Sprintf("https://argocd.%s", domain) + + oidcConfig := fmt.Sprintf(`name: Keycloak +issuer: %s +clientID: argocd +clientSecret: $oidc.keycloak.clientSecret +requestedScopes: + - openid + - profile + - email + - groups`, issuerURL) + + rbacPolicy := `g, argocd-admins, role:admin +g, argocd-viewers, role:readonly` + + configs := cfg.Values["configs"].(map[string]any) + configs["cm"] = map[string]any{ + "url": argocdURL, + "oidc.config": oidcConfig, + } + configs["rbac"] = map[string]any{ + "policy.default": "", + "scopes": "[groups]", + "policy.csv": rbacPolicy, + } + configs["secret"] = map[string]any{ + "extra": map[string]any{ + "oidc.keycloak.clientSecret": clientSecret, + }, + } + + cfg.Values["configs"] = configs + return cfg +} +``` + +**Note:** `policy.default` is set to `""` (empty string) so only users in the two groups get access. Users who authenticate via SSO but aren't in either group will be denied. + +#### 1c. Deploy flow changes + +In `deploy.go`, the ArgoCD install call currently uses `DefaultConfig()` implicitly (via `argocd.Install()`). The install function needs to accept the OIDC-aware config. + +Looking at the current flow: +1. `argocd.Install(ctx, cfg, provider)` - installs ArgoCD via Helm +2. `argocd.InstallFoundationalServices(ctx, cfg, provider, foundationalCfg)` - creates secrets, applies root app-of-apps + +The OIDC client secret needs to be: +- Passed to ArgoCD's Helm values (step 1) +- Stored as a K8s secret for the realm-setup job (step 2) + +So the client secret must be generated **before** step 1. The deploy flow becomes: + +```go +// Generate all passwords upfront +argoCDClientSecret := generateSecurePassword(rand.Reader) + +// Install ArgoCD with OIDC config +argoCDConfig := argocd.ConfigWithOIDC(cfg.Domain, infraSettings.KeycloakBasePath, argoCDClientSecret) +argocd.InstallHelm(ctx, kubeconfigBytes, argoCDConfig) + +// Install foundational services (creates secrets including the OIDC client secret for realm-setup) +foundationalCfg := argocd.FoundationalConfig{ + ArgoCD: argocd.ArgoCDSSOConfig{ + ClientSecret: argoCDClientSecret, + }, + // ... rest unchanged +} +argocd.InstallFoundationalServices(ctx, cfg, provider, foundationalCfg) +``` + +### Layer 2: Keycloak Realm Setup Job + +Extend `realm-setup-job.yaml` to create the OIDC client and groups after realm creation. + +**New environment variable:** + +```yaml +- name: ARGOCD_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: argocd-oidc-client-secret + key: client-secret +``` + +**New script sections (appended to existing script):** + +```bash +echo "Creating ArgoCD OIDC client..." +$KCADM create clients -r nebari \ + -s clientId=argocd \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s secret="$ARGOCD_CLIENT_SECRET" \ + -s 'redirectUris=["https://argocd.'"$DOMAIN"'/auth/callback"]' \ + -s directAccessGrantsEnabled=false \ + -s standardFlowEnabled=true || echo "Client may already exist" + +# Add groups scope to argocd client as a default scope +ARGOCD_CLIENT_ID=$($KCADM get clients -r nebari --fields id,clientId | \ + grep -B1 '"clientId" *: *"argocd"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + +if [ -n "$ARGOCD_CLIENT_ID" ] && [ -n "$GROUPS_SCOPE_ID" ]; then + echo "Adding groups scope to argocd client..." + $KCADM update clients/$ARGOCD_CLIENT_ID/default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true +fi + +echo "Creating ArgoCD groups..." +$KCADM create groups -r nebari -s name=argocd-admins || echo "Group may already exist" +$KCADM create groups -r nebari -s name=argocd-viewers || echo "Group may already exist" + +echo "Adding admin user to argocd-admins group..." +ADMIN_USER_ID=$($KCADM get users -r nebari -q username=admin --fields id | \ + sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') +ADMINS_GROUP_ID=$($KCADM get groups -r nebari --fields id,name | \ + grep -B1 '"name" *: *"argocd-admins"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + +if [ -n "$ADMIN_USER_ID" ] && [ -n "$ADMINS_GROUP_ID" ]; then + $KCADM update users/$ADMIN_USER_ID/groups/$ADMINS_GROUP_ID -r nebari -s realm=nebari -s userId=$ADMIN_USER_ID -s groupId=$ADMINS_GROUP_ID -n || true +fi +``` + +**New environment variable for domain** (needed for redirect URI): + +```yaml +- name: DOMAIN + value: {{ .Domain }} +``` + +### Layer 3: ArgoCD RBAC Configuration + +Handled entirely through Helm values (see Layer 1b above). The key settings: + +| Helm Value | Value | Purpose | +|------------|-------|---------| +| `configs.rbac.policy.default` | `""` | No access for users not in a group | +| `configs.rbac.scopes` | `[groups]` | Use the `groups` claim from OIDC token | +| `configs.rbac.policy.csv` | See below | Map groups to roles | + +**policy.csv:** +``` +g, argocd-admins, role:admin +g, argocd-viewers, role:readonly +``` + +### Ordering and Dependencies + +``` +Time --> + +1. Generate passwords 2. Install ArgoCD 3. Create secrets 4. ArgoCD syncs apps + (deploy.go) (Helm with OIDC values) (foundational.go) (wave 4: Keycloak) + + argoCDClientSecret --> configs.secret.extra argocd-oidc-client- 5. Realm setup job + has the secret secret in keycloak ns creates OIDC client + with same secret +``` + +ArgoCD is installed at step 2 with OIDC config pointing to a Keycloak that doesn't exist yet. This is fine because: +- ArgoCD's OIDC discovery is lazy (fetches `.well-known/openid-configuration` only on login attempt) +- The built-in admin account still works for initial access +- Once Keycloak comes up (wave 4) and the realm-setup job completes (PostSync), SSO starts working + +### Files to Modify + +| File | Change | +|------|--------| +| `pkg/argocd/config.go` | Add `ConfigWithOIDC()` function | +| `pkg/argocd/foundational.go` | Add `ArgoCDSSOConfig` struct, create `argocd-oidc-client-secret` in keycloak namespace | +| `pkg/argocd/install.go` | Accept Config parameter instead of using DefaultConfig() internally | +| `pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml` | Add OIDC client, groups, and group membership | +| `pkg/argocd/writer.go` | Add `Domain` to template data if not already available (it is) | +| `cmd/nic/deploy.go` | Generate client secret, pass OIDC config to ArgoCD install, update foundational config | + +### Files to Add + +| File | Purpose | +|------|---------| +| `pkg/argocd/config_test.go` | Test `ConfigWithOIDC()` generates correct Helm values | +| `pkg/argocd/foundational_test.go` (extend) | Test new secret creation | + +### Testing Strategy + +**Unit tests:** +- `ConfigWithOIDC()` produces correct Helm values structure (OIDC config, RBAC policy, secret) +- `createKeycloakSecrets()` creates the new `argocd-oidc-client-secret` +- `FoundationalConfig` correctly carries the `ArgoCD.ClientSecret` field + +**Manual validation:** +- Deploy a local cluster, verify ArgoCD shows "Log in via Keycloak" button +- Log in as realm admin, verify admin access +- Create a user in `argocd-viewers`, verify read-only access +- Create a user not in either group, verify access denied + +### Security Considerations + +- The OIDC client secret is generated with `generateSecurePassword()` (32 bytes, base64 encoded) - same strength as other secrets +- The client secret exists in two places: `argocd-secret` (argocd namespace) and `argocd-oidc-client-secret` (keycloak namespace) - both are Opaque secrets +- `publicClient=false` ensures the client secret is required for token exchange +- `directAccessGrantsEnabled=false` prevents password-based token grants +- The built-in ArgoCD admin account remains available as a break-glass mechanism From 395bfa868d1d79a7b1b68e475e00e423c17681cb Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:04:12 +0200 Subject: [PATCH 2/9] Add implementation plan for ArgoCD Keycloak SSO (#227) --- .../plans/2026-04-08-argocd-keycloak-sso.md | 740 ++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-argocd-keycloak-sso.md diff --git a/docs/superpowers/plans/2026-04-08-argocd-keycloak-sso.md b/docs/superpowers/plans/2026-04-08-argocd-keycloak-sso.md new file mode 100644 index 0000000..105aeee --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-argocd-keycloak-sso.md @@ -0,0 +1,740 @@ +# ArgoCD Keycloak SSO Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Configure ArgoCD with Keycloak OIDC SSO so users in `argocd-admins` get full admin access and users in `argocd-viewers` get read-only access. + +**Architecture:** Extend the existing deploy flow to (1) generate an OIDC client secret upfront, (2) pass it into ArgoCD's Helm values for OIDC config, (3) store it as a K8s secret for the Keycloak realm-setup job, and (4) extend the realm-setup job to create the OIDC client, groups, and group membership. + +**Tech Stack:** Go, Helm (ArgoCD chart v9.4.1), Keycloak 24.0 kcadm.sh, Kubernetes fake client for tests + +**Spec:** `docs/superpowers/specs/2026-04-08-argocd-keycloak-sso-design.md` +**Issue:** https://github.com/nebari-dev/nebari-infrastructure-core/issues/227 + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|---------------| +| `pkg/argocd/config.go` | Modify | Add `ConfigWithOIDC()` function | +| `pkg/argocd/config_test.go` | Modify | Add tests for `ConfigWithOIDC()` | +| `pkg/argocd/foundational.go` | Modify | Add `ArgoCDSSOConfig` struct, `argocd-oidc-client-secret` creation | +| `pkg/argocd/foundational_test.go` | Modify | Add tests for new secret and struct | +| `pkg/argocd/install.go` | Modify | Accept `Config` parameter in `Install()` instead of hardcoding `DefaultConfig()` | +| `pkg/argocd/install_test.go` | Verify | Ensure existing tests still pass (no new tests needed - signature change only) | +| `pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml` | Modify | Add OIDC client creation, groups, and group membership | +| `cmd/nic/deploy.go` | Modify | Generate client secret, pass OIDC config to `Install()`, wire into `FoundationalConfig` | + +--- + +### Task 1: Add `ConfigWithOIDC()` to config.go (TDD) + +**Files:** +- Modify: `pkg/argocd/config_test.go` +- Modify: `pkg/argocd/config.go` + +- [ ] **Step 1: Write failing tests for `ConfigWithOIDC`** + +Add these table-driven tests to `pkg/argocd/config_test.go`: + +```go +func TestConfigWithOIDC(t *testing.T) { + tests := []struct { + name string + domain string + keycloakBasePath string + clientSecret string + wantIssuer string + wantURL string + }{ + { + name: "standard domain with no base path", + domain: "nebari.example.com", + keycloakBasePath: "", + clientSecret: "test-secret-123", + wantIssuer: "https://keycloak.nebari.example.com/realms/nebari", + wantURL: "https://argocd.nebari.example.com", + }, + { + name: "domain with keycloak base path", + domain: "nebari.example.com", + keycloakBasePath: "/auth", + clientSecret: "test-secret-456", + wantIssuer: "https://keycloak.nebari.example.com/auth/realms/nebari", + wantURL: "https://argocd.nebari.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ConfigWithOIDC(tt.domain, tt.keycloakBasePath, tt.clientSecret) + + // Should preserve defaults + if cfg.Version == "" { + t.Error("Version should not be empty") + } + if cfg.Namespace != "argocd" { + t.Errorf("Namespace = %q, want %q", cfg.Namespace, "argocd") + } + + // Should still have server.insecure + configs := cfg.Values["configs"].(map[string]any) + params := configs["params"].(map[string]any) + if insecure, ok := params["server.insecure"].(bool); !ok || !insecure { + t.Error("server.insecure should be true") + } + + // Check OIDC config in configs.cm + cm := configs["cm"].(map[string]any) + if cm["url"] != tt.wantURL { + t.Errorf("cm.url = %q, want %q", cm["url"], tt.wantURL) + } + oidcConfig, ok := cm["oidc.config"].(string) + if !ok { + t.Fatal("cm[oidc.config] should be a string") + } + if !strings.Contains(oidcConfig, "name: Keycloak") { + t.Error("oidc.config should contain 'name: Keycloak'") + } + if !strings.Contains(oidcConfig, "issuer: "+tt.wantIssuer) { + t.Errorf("oidc.config should contain issuer %q, got:\n%s", tt.wantIssuer, oidcConfig) + } + if !strings.Contains(oidcConfig, "clientID: argocd") { + t.Error("oidc.config should contain 'clientID: argocd'") + } + if !strings.Contains(oidcConfig, "$oidc.keycloak.clientSecret") { + t.Error("oidc.config should reference $oidc.keycloak.clientSecret") + } + if !strings.Contains(oidcConfig, "groups") { + t.Error("oidc.config should request groups scope") + } + + // Check RBAC config + rbac := configs["rbac"].(map[string]any) + if rbac["policy.default"] != "" { + t.Errorf("rbac.policy.default = %q, want empty string", rbac["policy.default"]) + } + if rbac["scopes"] != "[groups]" { + t.Errorf("rbac.scopes = %q, want %q", rbac["scopes"], "[groups]") + } + policyCSV, ok := rbac["policy.csv"].(string) + if !ok { + t.Fatal("rbac.policy.csv should be a string") + } + if !strings.Contains(policyCSV, "g, argocd-admins, role:admin") { + t.Error("policy.csv should map argocd-admins to role:admin") + } + if !strings.Contains(policyCSV, "g, argocd-viewers, role:readonly") { + t.Error("policy.csv should map argocd-viewers to role:readonly") + } + + // Check secret injection + secret := configs["secret"].(map[string]any) + extra := secret["extra"].(map[string]any) + if extra["oidc.keycloak.clientSecret"] != tt.clientSecret { + t.Errorf("secret.extra[oidc.keycloak.clientSecret] = %q, want %q", + extra["oidc.keycloak.clientSecret"], tt.clientSecret) + } + }) + } +} +``` + +You'll need to add `"strings"` to the imports at the top of the test file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestConfigWithOIDC -v` +Expected: compilation error - `ConfigWithOIDC` undefined + +- [ ] **Step 3: Implement `ConfigWithOIDC` in `config.go`** + +Add this function after `DefaultConfig()` in `pkg/argocd/config.go`. You'll need to add `"fmt"` to the imports. + +```go +// ConfigWithOIDC returns an Argo CD configuration with Keycloak OIDC SSO enabled. +// It builds on DefaultConfig and adds OIDC provider config, RBAC policies mapping +// Keycloak groups to ArgoCD roles, and the client secret. +// +// The OIDC config references the client secret via $oidc.keycloak.clientSecret, +// which ArgoCD resolves from the argocd-secret Kubernetes Secret. The secret value +// is injected via configs.secret.extra in the Helm values. +func ConfigWithOIDC(domain, keycloakBasePath, clientSecret string) Config { + cfg := DefaultConfig() + + issuerURL := fmt.Sprintf("https://keycloak.%s%s/realms/nebari", domain, keycloakBasePath) + argocdURL := fmt.Sprintf("https://argocd.%s", domain) + + oidcConfig := fmt.Sprintf(`name: Keycloak +issuer: %s +clientID: argocd +clientSecret: $oidc.keycloak.clientSecret +requestedScopes: + - openid + - profile + - email + - groups`, issuerURL) + + rbacPolicy := `g, argocd-admins, role:admin +g, argocd-viewers, role:readonly` + + configs := cfg.Values["configs"].(map[string]any) + configs["cm"] = map[string]any{ + "url": argocdURL, + "oidc.config": oidcConfig, + } + configs["rbac"] = map[string]any{ + "policy.default": "", + "scopes": "[groups]", + "policy.csv": rbacPolicy, + } + configs["secret"] = map[string]any{ + "extra": map[string]any{ + "oidc.keycloak.clientSecret": clientSecret, + }, + } + + cfg.Values["configs"] = configs + return cfg +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestConfigWithOIDC -v` +Expected: PASS + +- [ ] **Step 5: Run all config tests to ensure no regressions** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestConfig -v` +Expected: PASS (both `TestDefaultConfig` and `TestConfigWithOIDC`) + +- [ ] **Step 6: Commit** + +```bash +git add pkg/argocd/config.go pkg/argocd/config_test.go +git commit -m "feat: add ConfigWithOIDC for ArgoCD Keycloak OIDC SSO (#227)" +``` + +--- + +### Task 2: Add `ArgoCDSSOConfig` and OIDC client secret to foundational.go (TDD) + +**Files:** +- Modify: `pkg/argocd/foundational_test.go` +- Modify: `pkg/argocd/foundational.go` + +- [ ] **Step 1: Write failing tests for the new struct and secret** + +Add these tests to `pkg/argocd/foundational_test.go`: + +```go +func TestArgoCDSSOConfigDefaults(t *testing.T) { + cfg := ArgoCDSSOConfig{} + if cfg.ClientSecret != "" { + t.Error("ArgoCDSSOConfig.ClientSecret should default to empty") + } +} + +func TestCreateKeycloakSecrets_CreatesArgoCDOIDCSecret(t *testing.T) { + ctx := context.Background() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keycloak", + }, + } + client := fake.NewSimpleClientset(ns) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests + + keycloakCfg := KeycloakConfig{ + Enabled: true, + AdminUsername: "admin", + AdminPassword: "admin-pass", + DBPassword: "db-pass", + PostgresAdminPassword: "pg-admin-pass", + PostgresUserPassword: "pg-user-pass", + RealmAdminUsername: "admin", + RealmAdminPassword: "realm-admin-pass", + } + argocdSSO := ArgoCDSSOConfig{ + ClientSecret: "argocd-oidc-secret-value", + } + + err := createKeycloakSecrets(ctx, client, keycloakCfg, argocdSSO) + if err != nil { + t.Fatalf("createKeycloakSecrets() error = %v", err) + } + + // Verify argocd-oidc-client-secret was created + secret, err := client.CoreV1().Secrets("keycloak").Get(ctx, "argocd-oidc-client-secret", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get argocd-oidc-client-secret: %v", err) + } + if got := getSecretValue(secret, "client-secret"); got != "argocd-oidc-secret-value" { + t.Errorf("client-secret = %q, want %q", got, "argocd-oidc-secret-value") + } + // Verify labels + if secret.Labels["app.kubernetes.io/part-of"] != "nebari-foundational" { + t.Error("missing or incorrect part-of label") + } +} + +func TestCreateKeycloakSecrets_SkipsArgoCDSecretWhenEmpty(t *testing.T) { + ctx := context.Background() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keycloak", + }, + } + client := fake.NewSimpleClientset(ns) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests + + keycloakCfg := KeycloakConfig{ + Enabled: true, + AdminUsername: "admin", + AdminPassword: "admin-pass", + DBPassword: "db-pass", + } + argocdSSO := ArgoCDSSOConfig{ + ClientSecret: "", // Empty - should not create secret + } + + err := createKeycloakSecrets(ctx, client, keycloakCfg, argocdSSO) + if err != nil { + t.Fatalf("createKeycloakSecrets() error = %v", err) + } + + // Verify argocd-oidc-client-secret was NOT created + _, err = client.CoreV1().Secrets("keycloak").Get(ctx, "argocd-oidc-client-secret", metav1.GetOptions{}) + if err == nil { + t.Error("argocd-oidc-client-secret should not be created when ClientSecret is empty") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestArgoCDSSO -v` +Expected: compilation error - `ArgoCDSSOConfig` undefined + +- [ ] **Step 3: Add `ArgoCDSSOConfig` struct and update `createKeycloakSecrets` signature** + +In `pkg/argocd/foundational.go`: + +Add the new struct after `MetalLBConfig`: + +```go +// ArgoCDSSOConfig holds ArgoCD SSO configuration +type ArgoCDSSOConfig struct { + ClientSecret string // Pre-generated OIDC client secret for ArgoCD's Keycloak integration +} +``` + +Add the `ArgoCD` field to `FoundationalConfig` (after the `Keycloak` field): + +```go +type FoundationalConfig struct { + // Keycloak configuration + Keycloak KeycloakConfig + + // ArgoCD SSO configuration + ArgoCD ArgoCDSSOConfig + + // LandingPage configuration + LandingPage LandingPageConfig + + // MetalLB configuration (local deployments only) + MetalLB MetalLBConfig +} +``` + +Update `createKeycloakSecrets` to accept the new parameter. Change the signature on line 223 from: + +```go +func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, keycloakCfg KeycloakConfig) error { +``` + +to: + +```go +func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, keycloakCfg KeycloakConfig, argocdSSO ArgoCDSSOConfig) error { +``` + +Add the ArgoCD OIDC client secret creation at the end of `createKeycloakSecrets`, before the final `return nil` (after the realm admin secret block, around line 289): + +```go + // 5. Create ArgoCD OIDC client secret (used by realm-setup job to configure the Keycloak client) + if argocdSSO.ClientSecret != "" { + if err := createSecret(ctx, client, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "argocd-oidc-client-secret", + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/part-of": "nebari-foundational", + "app.kubernetes.io/managed-by": "nebari-infrastructure-core", + }, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "client-secret": argocdSSO.ClientSecret, + }, + }); err != nil { + return err + } + } +``` + +Update the call site in `InstallFoundationalServices` (line 125) from: + +```go + if err := createKeycloakSecrets(ctx, k8sClient, foundationalCfg.Keycloak); err != nil { +``` + +to: + +```go + if err := createKeycloakSecrets(ctx, k8sClient, foundationalCfg.Keycloak, foundationalCfg.ArgoCD); err != nil { +``` + +- [ ] **Step 4: Fix existing tests to match new signature** + +The existing `TestCreateKeycloakSecrets` tests in `foundational_test.go` call `createKeycloakSecrets` with the old 3-argument signature. Update each call to pass an empty `ArgoCDSSOConfig{}` as the fourth argument. + +Find all calls matching `createKeycloakSecrets(ctx, client, cfg)` and change to `createKeycloakSecrets(ctx, client, cfg, ArgoCDSSOConfig{})`. + +There are 4 call sites in the existing tests (in the subtests: "creates all secrets", "creates realm admin secret when password provided", "skips realm admin secret when password empty", "does not overwrite existing secrets"). + +- [ ] **Step 5: Run all foundational tests** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestCreateKeycloakSecrets -v` +Expected: PASS (both old and new tests) + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestArgoCDSSO -v` +Expected: PASS + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestFoundationalConfig -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add pkg/argocd/foundational.go pkg/argocd/foundational_test.go +git commit -m "feat: add ArgoCDSSOConfig and OIDC client secret creation (#227)" +``` + +--- + +### Task 3: Update `Install()` to accept a `Config` parameter + +**Files:** +- Modify: `pkg/argocd/install.go:22,78` +- Modify: `cmd/nic/deploy.go:169` + +- [ ] **Step 1: Change `Install()` signature to accept Config** + +In `pkg/argocd/install.go`, change the `Install` function signature on line 22 from: + +```go +func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provider) error { +``` + +to: + +```go +func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provider, argoCDCfg Config) error { +``` + +On line 78, replace: + +```go + // Get Argo CD configuration + argoCDCfg := DefaultConfig() +``` + +with nothing (delete both lines). The variable `argoCDCfg` is now the parameter. + +- [ ] **Step 2: Update the call site in `deploy.go`** + +In `cmd/nic/deploy.go`, line 169, change: + +```go + if err := argocd.Install(ctx, cfg, provider); err != nil { +``` + +to: + +```go + if err := argocd.Install(ctx, cfg, provider, argocd.DefaultConfig()); err != nil { +``` + +This is a temporary passthrough - Task 5 will change it to use `ConfigWithOIDC`. + +- [ ] **Step 3: Run all tests to verify no regressions** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./... -v -count=1 2>&1 | tail -30` +Expected: PASS (all packages) + +- [ ] **Step 4: Commit** + +```bash +git add pkg/argocd/install.go cmd/nic/deploy.go +git commit -m "refactor: accept Config parameter in argocd.Install() (#227)" +``` + +--- + +### Task 4: Extend realm-setup job with OIDC client, groups, and membership + +**Files:** +- Modify: `pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml` + +- [ ] **Step 1: Add new environment variables to the job container** + +In `pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml`, add these environment variables after the existing `KEYCLOAK_URL` env var (after line 32): + +```yaml + - name: ARGOCD_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: argocd-oidc-client-secret + key: client-secret + - name: DOMAIN + value: {{ .Domain }} +``` + +- [ ] **Step 2: Add OIDC client creation, group creation, and membership to the script** + +Append the following to the bash script in the job, before the final `echo "Realm setup complete!"` line (before line 110): + +```bash + echo "Creating ArgoCD OIDC client..." + $KCADM create clients -r nebari \ + -s clientId=argocd \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s "secret=$ARGOCD_CLIENT_SECRET" \ + -s "redirectUris=[\"https://argocd.$DOMAIN/auth/callback\"]" \ + -s directAccessGrantsEnabled=false \ + -s standardFlowEnabled=true || echo "Client may already exist" + + # Add groups scope to argocd client as a default scope + ARGOCD_CLIENT_ID=$($KCADM get clients -r nebari --fields id,clientId | \ + grep -B1 '"clientId" *: *"argocd"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + + if [ -n "$ARGOCD_CLIENT_ID" ] && [ -n "$GROUPS_SCOPE_ID" ]; then + echo "Adding groups scope to argocd client..." + $KCADM update clients/$ARGOCD_CLIENT_ID/default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true + fi + + echo "Creating ArgoCD access groups..." + $KCADM create groups -r nebari -s name=argocd-admins || echo "Group may already exist" + $KCADM create groups -r nebari -s name=argocd-viewers || echo "Group may already exist" + + echo "Adding admin user to argocd-admins group..." + ADMIN_USER_ID=$($KCADM get users -r nebari -q username=admin --fields id | \ + sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + ADMINS_GROUP_ID=$($KCADM get groups -r nebari --fields id,name | \ + grep -B1 '"name" *: *"argocd-admins"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + + if [ -n "$ADMIN_USER_ID" ] && [ -n "$ADMINS_GROUP_ID" ]; then + $KCADM update users/$ADMIN_USER_ID/groups/$ADMINS_GROUP_ID -r nebari \ + -s realm=nebari -s userId=$ADMIN_USER_ID -s groupId=$ADMINS_GROUP_ID -n || true + fi +``` + +- [ ] **Step 3: Verify template renders correctly** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestWriteAllToGit -v` +Expected: PASS (template parsing should succeed) + +If there's no such test, run the writer tests: + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -run TestWriter -v` + +If that also doesn't exist, run all argocd tests: + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -v` +Expected: PASS (no template parse errors) + +- [ ] **Step 4: Commit** + +```bash +git add pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml +git commit -m "feat: extend realm-setup job with ArgoCD OIDC client and groups (#227)" +``` + +--- + +### Task 5: Wire everything together in deploy.go + +**Files:** +- Modify: `cmd/nic/deploy.go:166-209` + +- [ ] **Step 1: Update the deploy flow to generate and pass the OIDC client secret** + +In `cmd/nic/deploy.go`, replace the ArgoCD install + foundational services block (lines 166-209) with: + +```go + // Install Argo CD (skip in dry-run mode) + if !deployDryRun { + slog.Info("Installing Argo CD on cluster") + + // Generate OIDC client secret upfront - needed by both ArgoCD Helm values + // and the Keycloak realm-setup job + argoCDClientSecret := generateSecurePassword(rand.Reader) + + // Build ArgoCD config with Keycloak OIDC SSO + argoCDConfig := argocd.ConfigWithOIDC(cfg.Domain, infraSettings.KeycloakBasePath, argoCDClientSecret) + + if err := argocd.Install(ctx, cfg, provider, argoCDConfig); err != nil { + // Log error but don't fail deployment + slog.Warn("Failed to install Argo CD", "error", err) + slog.Warn("You can install Argo CD manually with: helm install argocd argo/argo-cd --namespace argocd --create-namespace") + } else { + slog.Info("Argo CD installed successfully") + argoCDInstalled = true + + // Install foundational services via Argo CD + slog.Info("Installing foundational services") + foundationalCfg := argocd.FoundationalConfig{ + Keycloak: argocd.KeycloakConfig{ + Enabled: true, + AdminUsername: "admin", + AdminPassword: generateSecurePassword(rand.Reader), + DBPassword: generateSecurePassword(rand.Reader), + PostgresAdminPassword: generateSecurePassword(rand.Reader), + PostgresUserPassword: generateSecurePassword(rand.Reader), + RealmAdminUsername: "admin", + RealmAdminPassword: generateSecurePassword(rand.Reader), + Hostname: "", // Will be auto-generated from domain + }, + ArgoCD: argocd.ArgoCDSSOConfig{ + ClientSecret: argoCDClientSecret, + }, + // Enable MetalLB only for providers that need it + MetalLB: argocd.MetalLBConfig{ + Enabled: infraSettings.NeedsMetalLB, + AddressPool: infraSettings.MetalLBAddressPool, + }, + } + + if err := argocd.InstallFoundationalServices(ctx, cfg, provider, foundationalCfg); err != nil { + // Log warning but don't fail deployment + slog.Warn("Failed to install foundational services", "error", err) + slog.Warn("You can install foundational services manually with: kubectl apply -f pkg/foundational/") + } else { + slog.Info("Foundational services installed successfully") + keycloakInstalled = true + } + } + } else { + slog.Info("Would install Argo CD and foundational services (dry-run mode)") + } +``` + +Note: The existing code does not explicitly set `LandingPage.RedisPassword` - it uses the zero value (empty string). Keep this behavior as-is to avoid scope creep. + +- [ ] **Step 2: Build to verify compilation** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go build ./...` +Expected: success (no errors) + +- [ ] **Step 3: Run all tests** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./... -v -count=1 2>&1 | tail -30` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add cmd/nic/deploy.go +git commit -m "feat: wire ArgoCD OIDC SSO into deploy flow (#227)" +``` + +--- + +### Task 6: Update post-deploy instructions + +**Files:** +- Modify: `cmd/nic/deploy.go` (the `printArgoCDInstructions` function, lines 437-467) + +- [ ] **Step 1: Update ArgoCD instructions to mention SSO login** + +Replace the `printArgoCDInstructions` function in `cmd/nic/deploy.go` with: + +```go +// printArgoCDInstructions prints instructions for accessing Argo CD +func printArgoCDInstructions(cfg *config.NebariConfig) { + fmt.Println() + fmt.Println("===============================================================================") + fmt.Println(" ARGO CD INSTALLED") + fmt.Println("===============================================================================") + fmt.Println() + fmt.Println(" Argo CD has been successfully installed on your cluster.") + fmt.Println() + fmt.Println(" To access Argo CD:") + fmt.Println() + if cfg.Domain != "" { + fmt.Printf(" UI: https://argocd.%s (after DNS configuration)\n", cfg.Domain) + fmt.Println() + fmt.Println(" Or use port-forwarding:") + fmt.Println() + } + fmt.Println(" kubectl port-forward svc/argocd-server -n argocd 8080:443") + fmt.Println(" Then visit: https://localhost:8080") + fmt.Println() + fmt.Println(" SSO Login:") + fmt.Println(" Click 'Log in via Keycloak' to authenticate with your Nebari account.") + fmt.Println(" Users in the 'argocd-admins' group get full admin access.") + fmt.Println(" Users in the 'argocd-viewers' group get read-only access.") + fmt.Println() + fmt.Println(" Admin fallback (break-glass):") + fmt.Println() + fmt.Println(" kubectl -n argocd get secret argocd-initial-admin-secret \\") + fmt.Println(" -o jsonpath=\"{.data.password}\" | base64 -d") + fmt.Println() + fmt.Println(" Username: admin") + fmt.Println(" Password: ") + fmt.Println() + fmt.Println("===============================================================================") + fmt.Println() +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go build ./...` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add cmd/nic/deploy.go +git commit -m "docs: update ArgoCD post-deploy instructions with SSO info (#227)" +``` + +--- + +### Task 7: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./... -v -cover 2>&1 | tail -40` +Expected: PASS on all packages + +- [ ] **Step 2: Run linter** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && golangci-lint run` +Expected: no errors + +- [ ] **Step 3: Run go vet** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go vet ./...` +Expected: no errors + +- [ ] **Step 4: Verify template rendering** + +Run: `cd /home/chuck/devel/nebari-infrastructure-core && go test ./pkg/argocd/ -v -run Test` +Expected: all PASS, confirming templates parse correctly with new variables From 011221c49bb0a02321ba6c1a67b18706b26f57d3 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:19:14 +0200 Subject: [PATCH 3/9] feat: add ConfigWithOIDC for ArgoCD Keycloak OIDC SSO (#227) --- pkg/argocd/config.go | 51 ++++++++++++++++++- pkg/argocd/config_test.go | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/pkg/argocd/config.go b/pkg/argocd/config.go index 556a119..92b36a1 100644 --- a/pkg/argocd/config.go +++ b/pkg/argocd/config.go @@ -1,6 +1,9 @@ package argocd -import "time" +import ( + "fmt" + "time" +) const ( defaultChartVersion = "9.4.1" @@ -29,6 +32,52 @@ type Config struct { Values map[string]any } +// ConfigWithOIDC returns an Argo CD configuration with Keycloak OIDC SSO enabled. +// It builds on DefaultConfig and adds OIDC provider config, RBAC policies mapping +// Keycloak groups to ArgoCD roles, and the client secret. +// +// The OIDC config references the client secret via $oidc.keycloak.clientSecret, +// which ArgoCD resolves from the argocd-secret Kubernetes Secret. The secret value +// is injected via configs.secret.extra in the Helm values. +func ConfigWithOIDC(domain, keycloakBasePath, clientSecret string) Config { + cfg := DefaultConfig() + + issuerURL := fmt.Sprintf("https://keycloak.%s%s/realms/nebari", domain, keycloakBasePath) + argocdURL := fmt.Sprintf("https://argocd.%s", domain) + + oidcConfig := fmt.Sprintf(`name: Keycloak +issuer: %s +clientID: argocd +clientSecret: $oidc.keycloak.clientSecret +requestedScopes: + - openid + - profile + - email + - groups`, issuerURL) + + rbacPolicy := `g, argocd-admins, role:admin +g, argocd-viewers, role:readonly` + + configs := cfg.Values["configs"].(map[string]any) + configs["cm"] = map[string]any{ + "url": argocdURL, + "oidc.config": oidcConfig, + } + configs["rbac"] = map[string]any{ + "policy.default": "", + "scopes": "[groups]", + "policy.csv": rbacPolicy, + } + configs["secret"] = map[string]any{ + "extra": map[string]any{ + "oidc.keycloak.clientSecret": clientSecret, + }, + } + + cfg.Values["configs"] = configs + return cfg +} + // DefaultConfig returns the default Argo CD configuration func DefaultConfig() Config { return Config{ diff --git a/pkg/argocd/config_test.go b/pkg/argocd/config_test.go index 224f3eb..79f30ee 100644 --- a/pkg/argocd/config_test.go +++ b/pkg/argocd/config_test.go @@ -1,6 +1,7 @@ package argocd import ( + "strings" "testing" "time" ) @@ -48,6 +49,107 @@ func TestDefaultConfig(t *testing.T) { } } +func TestConfigWithOIDC(t *testing.T) { + tests := []struct { + name string + domain string + keycloakBasePath string + clientSecret string + wantIssuer string + wantURL string + }{ + { + name: "standard domain with no base path", + domain: "nebari.example.com", + keycloakBasePath: "", + clientSecret: "test-secret-123", + wantIssuer: "https://keycloak.nebari.example.com/realms/nebari", + wantURL: "https://argocd.nebari.example.com", + }, + { + name: "domain with keycloak base path", + domain: "nebari.example.com", + keycloakBasePath: "/auth", + clientSecret: "test-secret-456", + wantIssuer: "https://keycloak.nebari.example.com/auth/realms/nebari", + wantURL: "https://argocd.nebari.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ConfigWithOIDC(tt.domain, tt.keycloakBasePath, tt.clientSecret) + + // Should preserve defaults + if cfg.Version == "" { + t.Error("Version should not be empty") + } + if cfg.Namespace != "argocd" { + t.Errorf("Namespace = %q, want %q", cfg.Namespace, "argocd") + } + + // Should still have server.insecure + configs := cfg.Values["configs"].(map[string]any) + params := configs["params"].(map[string]any) + if insecure, ok := params["server.insecure"].(bool); !ok || !insecure { + t.Error("server.insecure should be true") + } + + // Check OIDC config in configs.cm + cm := configs["cm"].(map[string]any) + if cm["url"] != tt.wantURL { + t.Errorf("cm.url = %q, want %q", cm["url"], tt.wantURL) + } + oidcConfig, ok := cm["oidc.config"].(string) + if !ok { + t.Fatal("cm[oidc.config] should be a string") + } + if !strings.Contains(oidcConfig, "name: Keycloak") { + t.Error("oidc.config should contain 'name: Keycloak'") + } + if !strings.Contains(oidcConfig, "issuer: "+tt.wantIssuer) { + t.Errorf("oidc.config should contain issuer %q, got:\n%s", tt.wantIssuer, oidcConfig) + } + if !strings.Contains(oidcConfig, "clientID: argocd") { + t.Error("oidc.config should contain 'clientID: argocd'") + } + if !strings.Contains(oidcConfig, "$oidc.keycloak.clientSecret") { + t.Error("oidc.config should reference $oidc.keycloak.clientSecret") + } + if !strings.Contains(oidcConfig, "groups") { + t.Error("oidc.config should request groups scope") + } + + // Check RBAC config + rbac := configs["rbac"].(map[string]any) + if rbac["policy.default"] != "" { + t.Errorf("rbac.policy.default = %q, want empty string", rbac["policy.default"]) + } + if rbac["scopes"] != "[groups]" { + t.Errorf("rbac.scopes = %q, want %q", rbac["scopes"], "[groups]") + } + policyCSV, ok := rbac["policy.csv"].(string) + if !ok { + t.Fatal("rbac.policy.csv should be a string") + } + if !strings.Contains(policyCSV, "g, argocd-admins, role:admin") { + t.Error("policy.csv should map argocd-admins to role:admin") + } + if !strings.Contains(policyCSV, "g, argocd-viewers, role:readonly") { + t.Error("policy.csv should map argocd-viewers to role:readonly") + } + + // Check secret injection + secret := configs["secret"].(map[string]any) + extra := secret["extra"].(map[string]any) + if extra["oidc.keycloak.clientSecret"] != tt.clientSecret { + t.Errorf("secret.extra[oidc.keycloak.clientSecret] = %q, want %q", + extra["oidc.keycloak.clientSecret"], tt.clientSecret) + } + }) + } +} + func TestConfigFields(t *testing.T) { // Test that Config struct can be created with custom values cfg := Config{ From 2d5b95fc7cb5522d2d13b6c8d4a728e3f75e08af Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:30:51 +0200 Subject: [PATCH 4/9] feat: add ArgoCDSSOConfig and OIDC client secret creation (#227) --- pkg/argocd/foundational.go | 32 +++++++++++- pkg/argocd/foundational_test.go | 90 +++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/pkg/argocd/foundational.go b/pkg/argocd/foundational.go index d3e15e7..ba10d8f 100644 --- a/pkg/argocd/foundational.go +++ b/pkg/argocd/foundational.go @@ -35,6 +35,9 @@ type FoundationalConfig struct { // Keycloak configuration Keycloak KeycloakConfig + // ArgoCD SSO configuration + ArgoCD ArgoCDSSOConfig + // LandingPage configuration LandingPage LandingPageConfig @@ -66,6 +69,11 @@ type MetalLBConfig struct { AddressPool string // e.g., "192.168.1.100-192.168.1.110" } +// ArgoCDSSOConfig holds ArgoCD SSO configuration +type ArgoCDSSOConfig struct { + ClientSecret string // Pre-generated OIDC client secret for ArgoCD's Keycloak integration +} + // InstallFoundationalServices installs foundational services via GitOps. // This function handles the bootstrap phase: // 1. Creates the ArgoCD Project for foundational services @@ -122,7 +130,7 @@ func InstallFoundationalServices(ctx context.Context, cfg *config.NebariConfig, } // Create secrets for Keycloak and PostgreSQL - if err := createKeycloakSecrets(ctx, k8sClient, foundationalCfg.Keycloak); err != nil { + if err := createKeycloakSecrets(ctx, k8sClient, foundationalCfg.Keycloak, foundationalCfg.ArgoCD); err != nil { span.RecordError(err) return fmt.Errorf("failed to create Keycloak secrets: %w", err) } @@ -220,7 +228,7 @@ func createSecret(ctx context.Context, client kubernetes.Interface, secret *core } // createKeycloakSecrets creates the required secrets for Keycloak and PostgreSQL -func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, keycloakCfg KeycloakConfig) error { +func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, keycloakCfg KeycloakConfig, argocdSSO ArgoCDSSOConfig) error { namespace := KeycloakDefaultNamespace // 1. Create admin credentials secret @@ -288,6 +296,26 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key } } + // 5. Create ArgoCD OIDC client secret (used by realm-setup job to configure the Keycloak client) + if argocdSSO.ClientSecret != "" { + if err := createSecret(ctx, client, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "argocd-oidc-client-secret", + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/part-of": "nebari-foundational", + "app.kubernetes.io/managed-by": "nebari-infrastructure-core", + }, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "client-secret": argocdSSO.ClientSecret, + }, + }); err != nil { + return err + } + } + return nil } diff --git a/pkg/argocd/foundational_test.go b/pkg/argocd/foundational_test.go index fc9c25a..ca1b2e2 100644 --- a/pkg/argocd/foundational_test.go +++ b/pkg/argocd/foundational_test.go @@ -79,7 +79,7 @@ func TestCreateKeycloakSecrets(t *testing.T) { PostgresUserPassword: "db-pass-456-user", } - err := createKeycloakSecrets(ctx, client, cfg) + err := createKeycloakSecrets(ctx, client, cfg, ArgoCDSSOConfig{}) if err != nil { t.Fatalf("createKeycloakSecrets() error = %v", err) } @@ -131,7 +131,7 @@ func TestCreateKeycloakSecrets(t *testing.T) { RealmAdminPassword: "realm-admin-pass", } - err := createKeycloakSecrets(ctx, client, cfg) + err := createKeycloakSecrets(ctx, client, cfg, ArgoCDSSOConfig{}) if err != nil { t.Fatalf("createKeycloakSecrets() error = %v", err) } @@ -168,7 +168,7 @@ func TestCreateKeycloakSecrets(t *testing.T) { RealmAdminPassword: "", // Empty - should not create secret } - err := createKeycloakSecrets(ctx, client, cfg) + err := createKeycloakSecrets(ctx, client, cfg, ArgoCDSSOConfig{}) if err != nil { t.Fatalf("createKeycloakSecrets() error = %v", err) } @@ -204,7 +204,7 @@ func TestCreateKeycloakSecrets(t *testing.T) { DBPassword: "db-pass", } - err := createKeycloakSecrets(ctx, client, cfg) + err := createKeycloakSecrets(ctx, client, cfg, ArgoCDSSOConfig{}) if err != nil { t.Fatalf("createKeycloakSecrets() error = %v", err) } @@ -280,6 +280,88 @@ func TestFoundationalConfig(t *testing.T) { }) } +func TestArgoCDSSOConfigDefaults(t *testing.T) { + cfg := ArgoCDSSOConfig{} + if cfg.ClientSecret != "" { + t.Error("ArgoCDSSOConfig.ClientSecret should default to empty") + } +} + +func TestCreateKeycloakSecrets_CreatesArgoCDOIDCSecret(t *testing.T) { + ctx := context.Background() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keycloak", + }, + } + client := fake.NewSimpleClientset(ns) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests + + keycloakCfg := KeycloakConfig{ + Enabled: true, + AdminUsername: "admin", + AdminPassword: "admin-pass", + DBPassword: "db-pass", + PostgresAdminPassword: "pg-admin-pass", + PostgresUserPassword: "pg-user-pass", + RealmAdminUsername: "admin", + RealmAdminPassword: "realm-admin-pass", + } + argocdSSO := ArgoCDSSOConfig{ + ClientSecret: "argocd-oidc-secret-value", + } + + err := createKeycloakSecrets(ctx, client, keycloakCfg, argocdSSO) + if err != nil { + t.Fatalf("createKeycloakSecrets() error = %v", err) + } + + // Verify argocd-oidc-client-secret was created + secret, err := client.CoreV1().Secrets("keycloak").Get(ctx, "argocd-oidc-client-secret", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get argocd-oidc-client-secret: %v", err) + } + if got := getSecretValue(secret, "client-secret"); got != "argocd-oidc-secret-value" { + t.Errorf("client-secret = %q, want %q", got, "argocd-oidc-secret-value") + } + // Verify labels + if secret.Labels["app.kubernetes.io/part-of"] != "nebari-foundational" { + t.Error("missing or incorrect part-of label") + } +} + +func TestCreateKeycloakSecrets_SkipsArgoCDSecretWhenEmpty(t *testing.T) { + ctx := context.Background() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keycloak", + }, + } + client := fake.NewSimpleClientset(ns) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests + + keycloakCfg := KeycloakConfig{ + Enabled: true, + AdminUsername: "admin", + AdminPassword: "admin-pass", + DBPassword: "db-pass", + } + argocdSSO := ArgoCDSSOConfig{ + ClientSecret: "", // Empty - should not create secret + } + + err := createKeycloakSecrets(ctx, client, keycloakCfg, argocdSSO) + if err != nil { + t.Fatalf("createKeycloakSecrets() error = %v", err) + } + + // Verify argocd-oidc-client-secret was NOT created + _, err = client.CoreV1().Secrets("keycloak").Get(ctx, "argocd-oidc-client-secret", metav1.GetOptions{}) + if err == nil { + t.Error("argocd-oidc-client-secret should not be created when ClientSecret is empty") + } +} + func TestNewK8sClient(t *testing.T) { t.Run("fails with invalid kubeconfig", func(t *testing.T) { _, err := newK8sClient([]byte("invalid kubeconfig")) From c344782b30fa75f96c9547bfd183d59416c545c8 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:45:03 +0200 Subject: [PATCH 5/9] refactor: accept Config parameter in argocd.Install() (#227) --- cmd/nic/deploy.go | 2 +- pkg/argocd/install.go | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cmd/nic/deploy.go b/cmd/nic/deploy.go index 085eb10..ef2fd08 100644 --- a/cmd/nic/deploy.go +++ b/cmd/nic/deploy.go @@ -224,7 +224,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { // Install Argo CD (skip in dry-run mode) if !deployDryRun { slog.Info("Installing Argo CD on cluster") - if err := argocd.Install(ctx, cfg, provider); err != nil { + if err := argocd.Install(ctx, cfg, provider, argocd.DefaultConfig()); err != nil { // Log error but don't fail deployment slog.Warn("Failed to install Argo CD", "error", err) slog.Warn("You can install Argo CD manually with: helm install argocd argo/argo-cd --namespace argocd --create-namespace") diff --git a/pkg/argocd/install.go b/pkg/argocd/install.go index 6bf1df3..d1625c7 100644 --- a/pkg/argocd/install.go +++ b/pkg/argocd/install.go @@ -25,7 +25,7 @@ const ( // Install installs Argo CD on a Kubernetes cluster // This is the main entry point called from cmd/nic/deploy.go // If cfg.GitRepository is a local file:// path, the directory is mounted into the repo-server pod. -func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provider) error { +func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provider, argoCDCfg Config) error { tracer := otel.Tracer("nebari-infrastructure-core") ctx, span := tracer.Start(ctx, "argocd.Install") defer span.End() @@ -80,9 +80,6 @@ func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provid return fmt.Errorf("cluster not ready: %w", err) } - // Get Argo CD configuration - argoCDCfg := DefaultConfig() - // If using a local file:// git repo, mount it into the repo-server pod if cfg.GitRepository != nil && cfg.GitRepository.IsLocalPath() { localPath, err := cfg.GitRepository.GetLocalPath() @@ -96,6 +93,7 @@ func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provid WithAction("configuring")) } + // Create namespace if err := createNamespace(ctx, k8sClient, argoCDCfg.Namespace); err != nil { span.RecordError(err) From f0d92ecfbf13f394f8b5d4f28290821e6ff9e4eb Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:58:00 +0200 Subject: [PATCH 6/9] feat: extend realm-setup job with ArgoCD OIDC client and groups (#227) --- .../manifests/keycloak/realm-setup-job.yaml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml b/pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml index 4248b2a..d303ba2 100644 --- a/pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml +++ b/pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml @@ -30,6 +30,13 @@ spec: key: password - name: KEYCLOAK_URL value: {{ .KeycloakServiceURL }} + - name: ARGOCD_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: argocd-oidc-client-secret + key: client-secret + - name: DOMAIN + value: {{ .Domain }} command: - /bin/bash - -c @@ -107,4 +114,39 @@ spec: $KCADM update realms/nebari/default-default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true fi + echo "Creating ArgoCD OIDC client..." + $KCADM create clients -r nebari \ + -s clientId=argocd \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s "secret=$ARGOCD_CLIENT_SECRET" \ + -s "redirectUris=[\"https://argocd.$DOMAIN/auth/callback\"]" \ + -s directAccessGrantsEnabled=false \ + -s standardFlowEnabled=true || echo "Client may already exist" + + # Add groups scope to argocd client as a default scope + ARGOCD_CLIENT_ID=$($KCADM get clients -r nebari --fields id,clientId | \ + grep -B1 '"clientId" *: *"argocd"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + + if [ -n "$ARGOCD_CLIENT_ID" ] && [ -n "$GROUPS_SCOPE_ID" ]; then + echo "Adding groups scope to argocd client..." + $KCADM update clients/$ARGOCD_CLIENT_ID/default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true + fi + + echo "Creating ArgoCD access groups..." + $KCADM create groups -r nebari -s name=argocd-admins || echo "Group may already exist" + $KCADM create groups -r nebari -s name=argocd-viewers || echo "Group may already exist" + + echo "Adding admin user to argocd-admins group..." + ADMIN_USER_ID=$($KCADM get users -r nebari -q username=admin --fields id | \ + sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + ADMINS_GROUP_ID=$($KCADM get groups -r nebari --fields id,name | \ + grep -B1 '"name" *: *"argocd-admins"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p') + + if [ -n "$ADMIN_USER_ID" ] && [ -n "$ADMINS_GROUP_ID" ]; then + $KCADM update users/$ADMIN_USER_ID/groups/$ADMINS_GROUP_ID -r nebari \ + -s realm=nebari -s userId=$ADMIN_USER_ID -s groupId=$ADMINS_GROUP_ID -n || true + fi + echo "Realm setup complete!" From 6ffca88b56b59a03ab8fe612a6fadb4f8b92af6d Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:04:55 +0200 Subject: [PATCH 7/9] feat: wire ArgoCD OIDC SSO into deploy flow (#227) --- cmd/nic/deploy.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/nic/deploy.go b/cmd/nic/deploy.go index ef2fd08..3ef4a5b 100644 --- a/cmd/nic/deploy.go +++ b/cmd/nic/deploy.go @@ -224,7 +224,15 @@ func runDeploy(cmd *cobra.Command, args []string) error { // Install Argo CD (skip in dry-run mode) if !deployDryRun { slog.Info("Installing Argo CD on cluster") - if err := argocd.Install(ctx, cfg, provider, argocd.DefaultConfig()); err != nil { + + // Generate OIDC client secret upfront - needed by both ArgoCD Helm values + // and the Keycloak realm-setup job + argoCDClientSecret := generateSecurePassword(rand.Reader) + + // Build ArgoCD config with Keycloak OIDC SSO + argoCDConfig := argocd.ConfigWithOIDC(cfg.Domain, infraSettings.KeycloakBasePath, argoCDClientSecret) + + if err := argocd.Install(ctx, cfg, provider, argoCDConfig); err != nil { // Log error but don't fail deployment slog.Warn("Failed to install Argo CD", "error", err) slog.Warn("You can install Argo CD manually with: helm install argocd argo/argo-cd --namespace argocd --create-namespace") @@ -246,6 +254,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { RealmAdminPassword: generateSecurePassword(rand.Reader), Hostname: "", // Will be auto-generated from domain }, + ArgoCD: argocd.ArgoCDSSOConfig{ + ClientSecret: argoCDClientSecret, + }, LandingPage: argocd.LandingPageConfig{ RedisPassword: generateSecurePassword(rand.Reader), }, From 7c46e421ba9987d38a14114e50ef5a97154e2b23 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:06:57 +0200 Subject: [PATCH 8/9] docs: update ArgoCD post-deploy instructions with SSO info (#227) --- cmd/nic/deploy.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/nic/deploy.go b/cmd/nic/deploy.go index 3ef4a5b..fe47286 100644 --- a/cmd/nic/deploy.go +++ b/cmd/nic/deploy.go @@ -582,9 +582,9 @@ func printDNSGuidance(cfg *config.NebariConfig, lb *endpoint.LoadBalancerEndpoin // printArgoCDInstructions prints instructions for accessing Argo CD func printArgoCDInstructions(cfg *config.NebariConfig) { fmt.Println() - fmt.Println("═══════════════════════════════════════════════════════════════════════════════") + fmt.Println("===============================================================================") fmt.Println(" ARGO CD INSTALLED") - fmt.Println("═══════════════════════════════════════════════════════════════════════════════") + fmt.Println("===============================================================================") fmt.Println() fmt.Println(" Argo CD has been successfully installed on your cluster.") fmt.Println() @@ -599,16 +599,20 @@ func printArgoCDInstructions(cfg *config.NebariConfig) { fmt.Println(" kubectl port-forward svc/argocd-server -n argocd 8080:443") fmt.Println(" Then visit: https://localhost:8080") fmt.Println() - fmt.Println(" Get the admin password:") + fmt.Println(" SSO Login:") + fmt.Println(" Click 'Log in via Keycloak' to authenticate with your Nebari account.") + fmt.Println(" Users in the 'argocd-admins' group get full admin access.") + fmt.Println(" Users in the 'argocd-viewers' group get read-only access.") + fmt.Println() + fmt.Println(" Admin fallback (break-glass):") fmt.Println() fmt.Println(" kubectl -n argocd get secret argocd-initial-admin-secret \\") fmt.Println(" -o jsonpath=\"{.data.password}\" | base64 -d") fmt.Println() - fmt.Println(" Login credentials:") fmt.Println(" Username: admin") fmt.Println(" Password: ") fmt.Println() - fmt.Println("═══════════════════════════════════════════════════════════════════════════════") + fmt.Println("===============================================================================") fmt.Println() } From 7411a0d5798455e3258b4c6169d8630e3d072e47 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Thu, 7 May 2026 10:12:41 +0200 Subject: [PATCH 9/9] fix: resolve lint issues in argocd package - Extract "nebari-foundational" string into NebariFoundationalPartOf constant (goconst) - Remove extra blank line in install.go (gofmt) --- pkg/argocd/foundational.go | 9 ++++++--- pkg/argocd/install.go | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/argocd/foundational.go b/pkg/argocd/foundational.go index ba10d8f..e4da495 100644 --- a/pkg/argocd/foundational.go +++ b/pkg/argocd/foundational.go @@ -28,6 +28,9 @@ const ( // NebariLandingRedisSecretName is the name of the Kubernetes secret containing Redis password for nebari-landing. NebariLandingRedisSecretName = "nebari-landing-redis" //nolint:gosec // This is a secret name reference, not a credential + + // NebariFoundationalPartOf is the value of the app.kubernetes.io/part-of label for foundational resources. + NebariFoundationalPartOf = "nebari-foundational" ) // FoundationalConfig holds configuration for foundational services @@ -282,7 +285,7 @@ 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": "nebari-foundational", + "app.kubernetes.io/part-of": NebariFoundationalPartOf, "app.kubernetes.io/managed-by": "nebari-infrastructure-core", }, }, @@ -303,7 +306,7 @@ 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": "nebari-foundational", + "app.kubernetes.io/part-of": NebariFoundationalPartOf, "app.kubernetes.io/managed-by": "nebari-infrastructure-core", }, }, @@ -330,7 +333,7 @@ func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface, Name: NebariLandingRedisSecretName, Namespace: namespace, Labels: map[string]string{ - "app.kubernetes.io/part-of": "nebari-foundational", + "app.kubernetes.io/part-of": NebariFoundationalPartOf, "app.kubernetes.io/managed-by": "nebari-infrastructure-core", }, }, diff --git a/pkg/argocd/install.go b/pkg/argocd/install.go index d1625c7..7aad1f3 100644 --- a/pkg/argocd/install.go +++ b/pkg/argocd/install.go @@ -93,7 +93,6 @@ func Install(ctx context.Context, cfg *config.NebariConfig, prov provider.Provid WithAction("configuring")) } - // Create namespace if err := createNamespace(ctx, k8sClient, argoCDCfg.Namespace); err != nil { span.RecordError(err)