From 72a65f0e065da60629171cdd7a5e5d77a7232d4c Mon Sep 17 00:00:00 2001 From: Danijel Simeunovic Date: Fri, 17 Apr 2026 13:42:44 +0000 Subject: [PATCH] client cloner (#3) Reviewed-on: https://git.forteapps.net/Forte/launchpad/pulls/3 Reviewed-by: gitea_admin Co-authored-by: Danijel Simeunovic Co-committed-by: Danijel Simeunovic --- .../policies/auth-sidecar-injector.yaml | 8 +- .../policies/keycloak-client-cloner.yaml | 37 ++ docs/DEVELOPER-GUIDE.md | 178 ++++--- docs/REFERENCE.md | 112 ++++- infra/keycloak.yaml | 4 +- infra/values/keycloak-values.yaml | 457 ++++++++++++------ 6 files changed, 551 insertions(+), 245 deletions(-) create mode 100644 cluster-resources/policies/keycloak-client-cloner.yaml diff --git a/cluster-resources/policies/auth-sidecar-injector.yaml b/cluster-resources/policies/auth-sidecar-injector.yaml index 6d47dc8..0babdc2 100644 --- a/cluster-resources/policies/auth-sidecar-injector.yaml +++ b/cluster-resources/policies/auth-sidecar-injector.yaml @@ -243,8 +243,8 @@ spec: - name: AUTH_OIDC_CLIENT_SECRET valueFrom: secretKeyRef: - name: auth-oidc - key: client-secret + name: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oidc-credentials-secret\" || 'auth-oidc' }}" + key: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oidc-credentials-secret-key\" || 'client-secret' }}" resources: limits: cpu: 50m @@ -410,8 +410,8 @@ spec: - name: AUTH_OAUTH_CLIENT_SECRET valueFrom: secretKeyRef: - name: auth-oauth - key: client-secret + name: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oauth-credentials-secret\" || 'auth-oauth' }}" + key: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oauth-credentials-secret-key\" || 'client-secret' }}" - name: AUTH_OAUTH_DELEGATION_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/cluster-resources/policies/keycloak-client-cloner.yaml b/cluster-resources/policies/keycloak-client-cloner.yaml new file mode 100644 index 0000000..d83c43c --- /dev/null +++ b/cluster-resources/policies/keycloak-client-cloner.yaml @@ -0,0 +1,37 @@ +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: keycloak-client-config-cloner +spec: + rules: + - name: clone-client-config-to-keycloak + skipBackgroundRequests: false + match: + any: + - resources: + kinds: + - Secret + selector: + matchLabels: + keycloak.forteapps.net/client-config: "true" + exclude: + any: + - resources: + namespaces: + - keycloak + generate: + apiVersion: v1 + kind: Secret + name: "{{request.object.metadata.name}}" + namespace: keycloak + synchronize: true + data: + metadata: + labels: + keycloak.forteapps.net/client-config: "true" + keycloak.forteapps.net/source-namespace: "{{request.object.metadata.namespace}}" + annotations: + keycloak.forteapps.net/source-name: "{{request.object.metadata.name}}" + keycloak.forteapps.net/source-namespace: "{{request.object.metadata.namespace}}" + data: "{{request.object.data}}" + type: "{{request.object.type}}" diff --git a/docs/DEVELOPER-GUIDE.md b/docs/DEVELOPER-GUIDE.md index bec5109..27b7ddc 100644 --- a/docs/DEVELOPER-GUIDE.md +++ b/docs/DEVELOPER-GUIDE.md @@ -1250,20 +1250,119 @@ kubectl logs -n myapp -c authn ## Adding a New Keycloak Client -When you need an application to authenticate via Keycloak (OIDC), you can add a client definition to the realm config. The secret syncer automatically extracts the Keycloak-generated client secret into a Kubernetes Secret that your application can reference — no manual secret management needed. +There are two ways to add an OIDC client, depending on your use case: -### How It Works +| Method | Best for | Who edits the infra repo? | +|--------|----------|--------------------------| +| **Self-service** (recommended) | New apps that deploy their own resources | App developer — no infra changes needed | +| **Legacy (realm JSON)** | Existing clients already defined in forte-realm.json (e.g., Gitea) | Platform engineer | -1. You define a client in `forte-realm.json` (inside `keycloak-values.yaml`) **without** a `secret` field -2. Keycloak auto-generates a cryptographically strong secret on first creation -3. An ArgoCD **PostSync Job** (`keycloak-secret-syncer`) runs after each Keycloak sync: - - Authenticates to the Keycloak Admin API - - Finds clients with `k8s.secret.sync: "true"` in their attributes - - Extracts the auto-generated secret for each client - - Creates/updates a K8s Secret in the target namespace with `client-id` and `client-secret` keys -4. Your application references the syncer-created Secret +Both methods are served by the **Keycloak Client Registrar** CronJob, which runs every 2 minutes. -### Step 1: Add Client to Realm Config +### Self-Service OIDC Client Registration + +This is the recommended flow for new applications. Your app deploys a labeled config Secret in its own namespace; the platform handles everything else. + +#### How It Works + +1. You deploy a Secret with label `keycloak.forteapps.net/client-config: "true"` containing a `client.json` definition +2. A **Kyverno ClusterPolicy** (`keycloak-client-config-cloner`) clones it to the `keycloak` namespace +3. The **Client Registrar CronJob** picks it up within 2 minutes: + - Registers (or updates) the client in Keycloak + - Fetches the auto-generated client secret + - Creates a credential Secret in your app's namespace + - Annotates the config Secret with sync status + +#### Step 1: Create the Config Secret + +Deploy this Secret in your application's namespace (e.g., as part of your Helm chart or Kustomize overlay): + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-client-myapp + namespace: myapp + labels: + keycloak.forteapps.net/client-config: "true" +stringData: + client.json: | + { + "clientId": "myapp", + "name": "My Application", + "redirectUris": ["https://myapp.forteapps.net/*"], + "webOrigins": ["https://myapp.forteapps.net"], + "defaultClientScopes": ["openid", "email", "profile"], + "protocolMappers": [], + "secret": { + "namespace": "myapp", + "name": "myapp-oidc-credentials", + "keys": { "clientId": "client-id", "clientSecret": "client-secret" } + } + } +``` + +**`client.json` fields**: + +| Field | Required | Description | +|-------|----------|-------------| +| `clientId` | Yes | Keycloak client ID | +| `name` | Yes | Display name in Keycloak | +| `redirectUris` | Yes | Allowed redirect URIs | +| `webOrigins` | Yes | Allowed web origins (CORS) | +| `defaultClientScopes` | No | Scopes (default: `["openid", "email", "profile"]`) | +| `protocolMappers` | No | Custom claim mappers (default: `[]`) | +| `secret.namespace` | No | Namespace for the credential Secret (default: source namespace) | +| `secret.name` | No | Name of the credential Secret (default: `-oidc-credentials`) | +| `secret.keys.clientId` | No | Key name for client ID in credential Secret (default: `client-id`) | +| `secret.keys.clientSecret` | No | Key name for client secret in credential Secret (default: `client-secret`) | + +#### Step 2: Reference the Credential Secret + +In your application's deployment config, reference the credential Secret that the registrar creates: + +```yaml +env: +- name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: myapp-oidc-credentials + key: client-id +- name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: myapp-oidc-credentials + key: client-secret +``` + +#### Step 3: Deploy and Wait + +Commit and push your changes. The credential Secret will appear within 2 minutes: + +```bash +# Watch for the credential Secret to be created +kubectl get secret myapp-oidc-credentials -n myapp -w + +# Check registrar logs +kubectl logs -n keycloak job/$(kubectl get jobs -n keycloak --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}') + +# Check sync status on the config Secret +kubectl get secret keycloak-client-myapp -n keycloak -o jsonpath='{.metadata.annotations}' +``` + +#### Change Detection + +The registrar computes a SHA-256 hash of `client.json` and stores it as an annotation. On subsequent runs, it skips processing if: +- The hash hasn't changed, AND +- The credential Secret already exists in the target namespace + +To force a re-sync, update any field in `client.json` (e.g., add a trailing space to `name`). + +### Legacy Method: Realm JSON + +Existing clients (like Gitea) are defined directly in `forte-realm.json` inside `keycloak-values.yaml`. The registrar syncs their secrets via client attributes. + +#### Step 1: Add Client to Realm Config In `infra/values/keycloak-values.yaml`, add a new entry to the `clients` array in `forte-realm.json`: @@ -1292,30 +1391,16 @@ In `infra/values/keycloak-values.yaml`, add a new entry to the `clients` array i **Important**: - Do **NOT** include a `"secret"` field — Keycloak generates one automatically -- The `attributes` block tells the syncer where to create the K8s Secret -- The target namespace must exist before the syncer runs (ArgoCD creates it via `CreateNamespace=true`) +- The `attributes` block tells the registrar where to create the K8s Secret - Set `client-id-key` / `client-secret-key` to match what the consuming app expects (defaults: `client-id` / `client-secret`) -### Step 2: Reference the Secret in Your Application - -In your application's Helm values, reference the syncer-created secret: +#### Step 2: Reference the Secret in Your Application ```yaml -# In helm-values/myapp/values.yaml (or inline in values file) -# The secret will have keys: client-id, client-secret existingSecret: myapp-oidc-credentials ``` -For Gitea-style oauth config: -```yaml -oauth: -- name: "Forte" - provider: "openidConnect" - existingSecret: myapp-oidc-credentials # Gitea expects "key" and "secret" as fields - autoDiscoverUrl: "https://id.forteapps.net/realms/forte/.well-known/openid-configuration" -``` - -### Step 3: Commit and Push +#### Step 3: Commit and Push ```bash cd ~/dev/k8s/launchpad @@ -1324,27 +1409,9 @@ git commit -m "Add myapp Keycloak client with auto-sync" git push ``` -ArgoCD will: -1. Sync the Keycloak config (keycloakConfigCli creates the client) -2. Run the PostSync syncer Job -3. The syncer creates `myapp-oidc-credentials` in the `myapp` namespace +ArgoCD will sync the Keycloak config, and the registrar CronJob will pick up the new client within 2 minutes. -### Step 4: Verify - -```bash -# Check the syncer job ran successfully -kubectl get jobs -n keycloak -kubectl logs -n keycloak job/keycloak-secret-syncer - -# Verify the secret was created -kubectl get secret myapp-oidc-credentials -n myapp -o yaml - -# Check the secret has the expected keys -kubectl get secret myapp-oidc-credentials -n myapp -o jsonpath='{.data.client-id}' | base64 -d -kubectl get secret myapp-oidc-credentials -n myapp -o jsonpath='{.data.client-secret}' | base64 -d -``` - -### Sync Attribute Reference +#### Legacy Sync Attribute Reference | Attribute | Required | Default | Description | |-----------|----------|---------|-------------| @@ -1354,11 +1421,9 @@ kubectl get secret myapp-oidc-credentials -n myapp -o jsonpath='{.data.client-se | `k8s.secret.client-id-key` | No | `client-id` | Field name for the client ID in the K8s Secret | | `k8s.secret.client-secret-key` | No | `client-secret` | Field name for the client secret in the K8s Secret | -**Note on key names:** Different applications expect different field names. For example, the Gitea Helm chart expects `key` and `secret`, while a generic OIDC consumer might expect `client-id` and `client-secret`. Use the optional key attributes to match what the consuming application expects. - ### Retrieving Secrets for External Deployments -The syncer always writes a **central copy** of every synced secret to the `secrets` namespace, in addition to the target namespace. This allows operators to retrieve client credentials for applications deployed outside this cluster: +The registrar always writes a **central copy** of every synced secret to the `secrets` namespace, in addition to the target namespace. This allows operators to retrieve client credentials for applications deployed outside this cluster: ```bash # View the central copy @@ -1369,16 +1434,13 @@ kubectl get secret myapp-oidc-credentials -n secrets \ -o jsonpath='{.data.client-secret}' | base64 -d ``` -This is useful when an application runs on a separate cluster or external infrastructure and needs the Keycloak-generated OIDC credentials provisioned manually (e.g., via a SealedSecret on the remote side). +### Registrar Behavior Notes -### Syncer Behavior Notes - -- The syncer runs as an ArgoCD **PostSync hook** — it executes after all Keycloak resources are healthy -- `BeforeHookCreation` delete policy ensures old Job is cleaned up before each run +- The registrar runs as a CronJob every 2 minutes (`concurrencyPolicy: Forbid`) - If the target namespace doesn't exist, the target write is skipped with a warning (the central copy still happens) - A central copy is **always** written to the `secrets` namespace for every synced client -- The syncer uses the `keycloak-credentials` secret for admin authentication -- Created secrets have the label `app.kubernetes.io/managed-by: keycloak-secret-syncer` +- The registrar uses the `keycloak-credentials` secret for admin authentication +- Created secrets have the label `app.kubernetes.io/managed-by: keycloak-client-registrar` --- diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 22bdfc4..5d72b57 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -123,6 +123,7 @@ launchpad/ │ ├── replicaset-cleaner.yaml │ ├── default-ns-blocker.yaml │ ├── secret-cloner.yaml +│ ├── keycloak-client-cloner.yaml │ └── auth-sidecar-injector.yaml │ ├── secrets/ # Application secrets (sealed) @@ -869,29 +870,44 @@ dind: - Gitea admin panel (`/admin/runners`) — runners show as Online - Create test workflow in `.gitea/workflows/test.yml` — job executes -### Keycloak Secret Syncer +### Keycloak Client Registrar -**Type**: ArgoCD PostSync Job (deployed via Keycloak Helm chart `extraDeploy`) +**Type**: CronJob (deployed via Keycloak Helm chart `extraDeploy`) **Namespace**: `keycloak` +**Schedule**: `*/2 * * * *` (every 2 minutes) -**Purpose**: Automatically extracts Keycloak-generated client secrets and syncs them into Kubernetes Secrets in target namespaces. Eliminates the need to manually manage OIDC client secrets. +**Purpose**: Handles two responsibilities: +1. **Legacy sync** — extracts secrets from Keycloak clients with `k8s.secret.sync: "true"` attribute (same as former PostSync syncer) +2. **Self-service registration** — processes config Secrets (cloned by Kyverno) to register new OIDC clients and sync their credentials **How It Works**: -1. Runs as an ArgoCD PostSync hook after Keycloak resources are healthy -2. Authenticates to Keycloak Admin API using admin credentials from `keycloak-credentials` secret -3. Queries all clients in the `forte` realm -4. Filters clients with `k8s.secret.sync: "true"` attribute -5. For each matching client, retrieves the auto-generated secret via Keycloak Admin API -6. Creates/updates a K8s Secret in the target namespace (from `k8s.secret.namespace` attribute) -7. Always writes a central copy to the `secrets` namespace (for external deployment retrieval) + +*Legacy path (existing clients like Gitea):* +1. Authenticates to Keycloak Admin API using admin credentials from `keycloak-credentials` secret +2. Queries all clients in the `forte` realm +3. Filters clients with `k8s.secret.sync: "true"` attribute +4. For each matching client, retrieves the auto-generated secret via Keycloak Admin API +5. Creates/updates a K8s Secret in the target namespace (from `k8s.secret.namespace` attribute) +6. Always writes a central copy to the `secrets` namespace + +*Self-service path (new clients):* +1. Lists Secrets in `keycloak` namespace with label `keycloak.forteapps.net/client-config=true` +2. For each config Secret, parses `client.json` and computes a config hash +3. Skips if hash matches annotation and credential Secret already exists +4. Creates or updates the Keycloak client via Admin API +5. Fetches the generated client secret +6. Upserts credential Secret in target namespace + central `secrets` namespace +7. Annotates config Secret with sync status, config hash, and timestamp **Resources**: -- `ServiceAccount`: `keycloak-secret-syncer` (namespace: `keycloak`) -- `ClusterRole`: `keycloak-secret-syncer` (secrets: get/create/update/patch; namespaces: get/list) -- `ClusterRoleBinding`: `keycloak-secret-syncer` -- `Job`: `keycloak-secret-syncer` (PostSync hook) +- `ServiceAccount`: `keycloak-client-registrar` (namespace: `keycloak`) +- `ClusterRole`: `keycloak-client-registrar` (secrets: get/list/create/update/patch; namespaces: get/list) +- `ClusterRoleBinding`: `keycloak-client-registrar` +- `CronJob`: `keycloak-client-registrar` -**Client Attributes** (set in `forte-realm.json`): +**Kyverno Policy**: `keycloak-client-config-cloner` — clones labeled Secrets from app namespaces to `keycloak` namespace (see [Kyverno Policies](#kyverno-policies)) + +**Legacy Client Attributes** (set in `forte-realm.json`): | Attribute | Required | Default | Description | |-----------|----------|---------|-------------| @@ -901,31 +917,68 @@ dind: | `k8s.secret.client-id-key` | No | `client-id` | Field name for client ID in the Secret | | `k8s.secret.client-secret-key` | No | `client-secret` | Field name for client secret in the Secret | -**Created Secret Format** (key names configurable via attributes): +**Self-Service Config Secret Schema**: ```yaml apiVersion: v1 kind: Secret metadata: - name: - namespace: + name: keycloak-client- + namespace: labels: - app.kubernetes.io/managed-by: keycloak-secret-syncer + keycloak.forteapps.net/client-config: "true" +stringData: + client.json: | + { + "clientId": "", + "name": "", + "redirectUris": ["https://.forteapps.net/*"], + "webOrigins": ["https://.forteapps.net"], + "defaultClientScopes": ["openid", "email", "profile"], + "protocolMappers": [], + "secret": { + "namespace": "", + "name": "-oidc-credentials", + "keys": { "clientId": "client-id", "clientSecret": "client-secret" } + } + } +``` + +**Created Credential Secret Format**: +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: + namespace: + labels: + app.kubernetes.io/managed-by: keycloak-client-registrar type: Opaque data: : : ``` +**Config Secret Annotations** (set by registrar): + +| Annotation | Description | +|-----------|-------------| +| `keycloak.forteapps.net/config-hash` | SHA-256 hash of client.json for change detection | +| `keycloak.forteapps.net/sync-status` | `synced` or `error` | +| `keycloak.forteapps.net/last-sync` | ISO 8601 timestamp of last successful sync | + **Verification**: ```bash -# Check job status -kubectl get jobs -n keycloak +# Check CronJob status +kubectl get cronjobs -n keycloak -# View syncer logs -kubectl logs -n keycloak job/keycloak-secret-syncer +# View latest registrar logs +kubectl logs -n keycloak job/$(kubectl get jobs -n keycloak --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}') # Verify created secret kubectl get secret -n -o yaml + +# Check config Secret annotations (self-service) +kubectl get secret keycloak-client- -n keycloak -o jsonpath='{.metadata.annotations}' ``` **See**: [Developer Guide - Adding a New Keycloak Client](DEVELOPER-GUIDE.md#adding-a-new-keycloak-client) @@ -1020,6 +1073,19 @@ spec: **Label Requirement**: Secrets must have `allowedToBeCloned: "true"` +### Keycloak Client Config Cloner + +**File**: `cluster-resources/policies/keycloak-client-cloner.yaml` + +**Purpose**: Clones Secrets labeled `keycloak.forteapps.net/client-config: "true"` from app namespaces to the `keycloak` namespace. This allows apps to declare their OIDC client configuration in their own namespace, which the [Keycloak Client Registrar](#keycloak-client-registrar) then processes. + +**Trigger**: Any Secret with label `keycloak.forteapps.net/client-config: "true"` created outside the `keycloak` namespace. + +**Behavior**: +- Generates a copy of the Secret in the `keycloak` namespace with the same name +- Adds source tracking annotations (`keycloak.forteapps.net/source-namespace`, `keycloak.forteapps.net/source-name`) +- `synchronize: true` — changes to the source Secret are reflected in the clone + ### Default Namespace Blocker **File**: `cluster-resources/policies/default-ns-blocker.yaml` diff --git a/infra/keycloak.yaml b/infra/keycloak.yaml index be9d5ef..4b448f2 100644 --- a/infra/keycloak.yaml +++ b/infra/keycloak.yaml @@ -43,6 +43,6 @@ spec: ignoreDifferences: - group: batch - kind: Job + kind: CronJob jsonPointers: - - /spec/template/spec/containers/0/args + - /spec/jobTemplate/spec/template/spec/containers/0/args diff --git a/infra/values/keycloak-values.yaml b/infra/values/keycloak-values.yaml index c151afe..f1d7c4e 100644 --- a/infra/values/keycloak-values.yaml +++ b/infra/values/keycloak-values.yaml @@ -105,213 +105,354 @@ keycloakConfigCli: } extraDeploy: -# -- ServiceAccount for the secret syncer Job +# -- ServiceAccount for the client registrar CronJob - apiVersion: v1 kind: ServiceAccount metadata: - name: keycloak-secret-syncer + name: keycloak-client-registrar namespace: keycloak # -- ClusterRole granting access to secrets and namespaces - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: keycloak-secret-syncer + name: keycloak-client-registrar rules: - apiGroups: [""] resources: ["secrets"] - verbs: ["get", "create", "update", "patch"] + verbs: ["get", "list", "create", "update", "patch"] - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list"] -# -- ClusterRoleBinding for the syncer ServiceAccount +# -- ClusterRoleBinding for the registrar ServiceAccount - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: keycloak-secret-syncer + name: keycloak-client-registrar roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: keycloak-secret-syncer + name: keycloak-client-registrar subjects: - kind: ServiceAccount - name: keycloak-secret-syncer + name: keycloak-client-registrar namespace: keycloak -# -- PostSync Job: extracts Keycloak client secrets into K8s Secrets +# -- CronJob: registers Keycloak clients and syncs secrets - apiVersion: batch/v1 - kind: Job + kind: CronJob metadata: - name: keycloak-secret-syncer + name: keycloak-client-registrar namespace: keycloak - annotations: - argocd.argoproj.io/hook: PostSync - argocd.argoproj.io/hook-delete-policy: BeforeHookCreation spec: - backoffLimit: 3 - template: + schedule: "*/2 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: spec: - serviceAccountName: keycloak-secret-syncer - restartPolicy: Never - containers: - - name: syncer - image: alpine:3.20 - command: ["/bin/sh", "-c"] - args: - - | - set -e - apk add --no-cache curl jq > /dev/null 2>&1 + backoffLimit: 3 + template: + spec: + serviceAccountName: keycloak-client-registrar + restartPolicy: Never + containers: + - name: registrar + image: alpine:3.20 + command: ["/bin/sh", "-c"] + args: + - | + set -e + apk add --no-cache curl jq > /dev/null 2>&1 - KEYCLOAK_URL="http://keycloak:80" - REALM="forte" + KEYCLOAK_URL="http://keycloak:80" + REALM="forte" + K8S_API="https://kubernetes.default.svc" + SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + CENTRAL_NS="secrets" - # Read admin credentials from the keycloak-credentials secret - ADMIN_USER="admin" - ADMIN_PASS=$(cat /secrets/admin-password) + # --- Authenticate to Keycloak Admin API --- + ADMIN_USER="admin" + ADMIN_PASS=$(cat /secrets/admin-password) - # Authenticate to Keycloak Admin API - echo "Authenticating to Keycloak..." - TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ - -d "client_id=admin-cli" \ - -d "username=${ADMIN_USER}" \ - -d "password=${ADMIN_PASS}" \ - -d "grant_type=password" | jq -r '.access_token') + echo "Authenticating to Keycloak..." + TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli" \ + -d "username=${ADMIN_USER}" \ + -d "password=${ADMIN_PASS}" \ + -d "grant_type=password" | jq -r '.access_token') - if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then - echo "ERROR: Failed to authenticate to Keycloak" - exit 1 - fi + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "ERROR: Failed to authenticate to Keycloak" + exit 1 + fi - # Get all clients in the realm - echo "Fetching clients from realm '${REALM}'..." - CLIENTS=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ - "${KEYCLOAK_URL}/admin/realms/${REALM}/clients") + # --- Helper functions --- - # Filter clients with k8s.secret.sync=true - SYNC_CLIENTS=$(echo "$CLIENTS" | jq -c '[.[] | select(.attributes["k8s.secret.sync"] == "true")]') - COUNT=$(echo "$SYNC_CLIENTS" | jq 'length') - echo "Found ${COUNT} client(s) with sync enabled" + # Upsert a K8s Secret: try PUT (update), fall back to POST (create) + upsert_secret() { + local ns="$1" name="$2" manifest="$3" + local code + code=$(curl -sf -o /dev/null -w "%{http_code}" \ + --cacert "$CA_CERT" \ + -H "Authorization: Bearer ${SA_TOKEN}" \ + -H "Content-Type: application/json" \ + -X PUT -d "$manifest" \ + "${K8S_API}/api/v1/namespaces/${ns}/secrets/${name}") + if [ "$code" = "200" ]; then + echo " Updated secret '${ns}/${name}'" + elif [ "$code" = "404" ]; then + code=$(curl -sf -o /dev/null -w "%{http_code}" \ + --cacert "$CA_CERT" \ + -H "Authorization: Bearer ${SA_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST -d "$manifest" \ + "${K8S_API}/api/v1/namespaces/${ns}/secrets") + if [ "$code" = "201" ]; then + echo " Created secret '${ns}/${name}'" + else + echo " ERROR: Failed to create secret '${ns}/${name}' (HTTP ${code})" + return 1 + fi + else + echo " ERROR: Failed to update secret '${ns}/${name}' (HTTP ${code})" + return 1 + fi + } - K8S_API="https://kubernetes.default.svc" - SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - CENTRAL_NS="secrets" + # Build a credential Secret JSON manifest + build_credential_secret() { + local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6" + cat < '${TARGET_NS}/${TARGET_NAME}' (keys: ${ID_KEY}, ${SECRET_KEY})" + sync_credentials "$CLIENT_ID" "$CLIENT_UUID" "$TARGET_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" + done + + # ============================================= + # NEW PATH — self-service config Secrets + # ============================================= + echo "" + echo "=== Self-service: config Secrets with label keycloak.forteapps.net/client-config=true ===" + + CONFIG_SECRETS=$(curl -sf \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ - -H "Content-Type: application/json" \ - -X POST -d "$manifest" \ - "${K8S_API}/api/v1/namespaces/${ns}/secrets") - if [ "$code" = "201" ]; then - echo " Created secret '${ns}/${name}'" - else - echo " ERROR: Failed to create secret '${ns}/${name}' (HTTP ${code})" - return 1 - fi - else - echo " ERROR: Failed to update secret '${ns}/${name}' (HTTP ${code})" - return 1 - fi - } + "${K8S_API}/api/v1/namespaces/keycloak/secrets?labelSelector=keycloak.forteapps.net/client-config=true") - # Build a Secret JSON manifest - # Args: namespace, name, id-key, secret-key, b64-id, b64-secret - build_manifest() { - local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6" - cat < secret '${TARGET_NS}/${TARGET_NAME}' (keys: ${ID_KEY}, ${SECRET_KEY})" + CLIENT_ID=$(echo "$CLIENT_JSON" | jq -r '.clientId') + echo "Processing self-service client '${CLIENT_ID}' from config '${CONFIG_NAME}'" - # Get the client secret from Keycloak - SECRET_VALUE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ - "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/client-secret" \ - | jq -r '.value') + # Compute config hash for change detection + CONFIG_HASH=$(printf '%s' "$CLIENT_JSON" | sha256sum | cut -d' ' -f1) + EXISTING_HASH=$(echo "$CONFIG_SECRET" | jq -r '.metadata.annotations["keycloak.forteapps.net/config-hash"] // ""') - if [ -z "$SECRET_VALUE" ] || [ "$SECRET_VALUE" = "null" ]; then - echo " WARNING: No secret found for client '${CLIENT_ID}', skipping" - continue - fi + # Extract secret delivery config from client.json + CRED_NS=$(echo "$CLIENT_JSON" | jq -r '.secret.namespace // "'"${SOURCE_NS}"'"') + CRED_NAME=$(echo "$CLIENT_JSON" | jq -r '.secret.name // "'"${CLIENT_ID}"'-oidc-credentials"') + CRED_ID_KEY=$(echo "$CLIENT_JSON" | jq -r '.secret.keys.clientId // "client-id"') + CRED_SECRET_KEY=$(echo "$CLIENT_JSON" | jq -r '.secret.keys.clientSecret // "client-secret"') - B64_CLIENT_ID=$(printf '%s' "$CLIENT_ID" | base64 | tr -d '\n') - B64_SECRET=$(printf '%s' "$SECRET_VALUE" | base64 | tr -d '\n') + # Check if credential Secret already exists in target namespace + CRED_EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" \ + --cacert "$CA_CERT" \ + -H "Authorization: Bearer ${SA_TOKEN}" \ + "${K8S_API}/api/v1/namespaces/${CRED_NS}/secrets/${CRED_NAME}") - # 1. Write to target namespace (if it exists) - NS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ - --cacert "$CA_CERT" \ - -H "Authorization: Bearer ${SA_TOKEN}" \ - "${K8S_API}/api/v1/namespaces/${TARGET_NS}") + # Skip if hash matches and credential Secret exists + if [ "$CONFIG_HASH" = "$EXISTING_HASH" ] && [ "$CRED_EXISTS" = "200" ]; then + echo " No changes detected, skipping" + continue + fi - if [ "$NS_STATUS" = "200" ]; then - MANIFEST=$(build_manifest "$TARGET_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") - upsert_secret "$TARGET_NS" "$TARGET_NAME" "$MANIFEST" || exit 1 - else - echo " WARNING: Namespace '${TARGET_NS}' does not exist, skipping target" - fi + # Build Keycloak client representation (strip our secret delivery config) + KC_CLIENT=$(echo "$CLIENT_JSON" | jq '{ + clientId: .clientId, + name: .name, + enabled: true, + protocol: "openid-connect", + clientAuthenticatorType: "client-secret", + standardFlowEnabled: true, + directAccessGrantsEnabled: false, + publicClient: false, + redirectUris: .redirectUris, + webOrigins: .webOrigins, + defaultClientScopes: .defaultClientScopes, + protocolMappers: (.protocolMappers // []) + }') - # 2. Always write a central copy to the secrets namespace - CENTRAL_MANIFEST=$(build_manifest "$CENTRAL_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") - upsert_secret "$CENTRAL_NS" "$TARGET_NAME" "$CENTRAL_MANIFEST" || exit 1 - done + # Check if client already exists + EXISTING=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_ID}" \ + | jq -r '.[0].id // empty') - echo "Secret sync complete" - volumeMounts: - - name: keycloak-credentials - mountPath: /secrets - readOnly: true - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 128Mi - volumes: - - name: keycloak-credentials - secret: - secretName: keycloak-credentials - items: - - key: admin-password - path: admin-password + if [ -n "$EXISTING" ]; then + echo " Updating existing Keycloak client (uuid: ${EXISTING})" + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -X PUT -d "$KC_CLIENT" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${EXISTING}") + if [ "$HTTP_CODE" != "204" ] && [ "$HTTP_CODE" != "200" ]; then + echo " ERROR: Failed to update client '${CLIENT_ID}' (HTTP ${HTTP_CODE})" + annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "error" + continue + fi + CLIENT_UUID="$EXISTING" + else + echo " Creating new Keycloak client '${CLIENT_ID}'" + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST -d "$KC_CLIENT" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/clients") + if [ "$HTTP_CODE" != "201" ]; then + echo " ERROR: Failed to create client '${CLIENT_ID}' (HTTP ${HTTP_CODE})" + annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "error" + continue + fi + # Fetch the newly created client's UUID + CLIENT_UUID=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_ID}" \ + | jq -r '.[0].id') + fi + + # Sync credentials to target namespace + sync_credentials "$CLIENT_ID" "$CLIENT_UUID" "$CRED_NS" "$CRED_NAME" "$CRED_ID_KEY" "$CRED_SECRET_KEY" + + # Annotate config Secret with hash and sync status + annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/config-hash" "$CONFIG_HASH" + annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "synced" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/last-sync" "$TIMESTAMP" + echo " Synced successfully" + done + + echo "" + echo "Client registrar run complete" + volumeMounts: + - name: keycloak-credentials + mountPath: /secrets + readOnly: true + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumes: + - name: keycloak-credentials + secret: + secretName: keycloak-credentials + items: + - key: admin-password + path: admin-password