1 Commits

Author SHA1 Message Date
fbc6f361f4 client cloner 2026-04-17 15:38:28 +02:00
6 changed files with 551 additions and 245 deletions

View File

@@ -243,8 +243,8 @@ spec:
- name: AUTH_OIDC_CLIENT_SECRET - name: AUTH_OIDC_CLIENT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: auth-oidc name: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oidc-credentials-secret\" || 'auth-oidc' }}"
key: client-secret key: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oidc-credentials-secret-key\" || 'client-secret' }}"
resources: resources:
limits: limits:
cpu: 50m cpu: 50m
@@ -410,8 +410,8 @@ spec:
- name: AUTH_OAUTH_CLIENT_SECRET - name: AUTH_OAUTH_CLIENT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: auth-oauth name: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oauth-credentials-secret\" || 'auth-oauth' }}"
key: client-secret key: "{{ request.object.metadata.annotations.\"policies.forteapps.io/auth-oauth-credentials-secret-key\" || 'client-secret' }}"
- name: AUTH_OAUTH_DELEGATION_CLIENT_SECRET - name: AUTH_OAUTH_DELEGATION_CLIENT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@@ -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}}"

View File

@@ -1250,20 +1250,119 @@ kubectl logs -n myapp <pod-name> -c authn
## Adding a New Keycloak Client ## 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 Both methods are served by the **Keycloak Client Registrar** CronJob, which runs every 2 minutes.
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
### 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: `<clientId>-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`: 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**: **Important**:
- Do **NOT** include a `"secret"` field — Keycloak generates one automatically - Do **NOT** include a `"secret"` field — Keycloak generates one automatically
- The `attributes` block tells the syncer where to create the K8s Secret - The `attributes` block tells the registrar where to create the K8s Secret
- The target namespace must exist before the syncer runs (ArgoCD creates it via `CreateNamespace=true`)
- Set `client-id-key` / `client-secret-key` to match what the consuming app expects (defaults: `client-id` / `client-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 #### Step 2: Reference the Secret in Your Application
In your application's Helm values, reference the syncer-created secret:
```yaml ```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 existingSecret: myapp-oidc-credentials
``` ```
For Gitea-style oauth config: #### Step 3: Commit and Push
```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
```bash ```bash
cd ~/dev/k8s/launchpad cd ~/dev/k8s/launchpad
@@ -1324,27 +1409,9 @@ git commit -m "Add myapp Keycloak client with auto-sync"
git push git push
``` ```
ArgoCD will: ArgoCD will sync the Keycloak config, and the registrar CronJob will pick up the new client within 2 minutes.
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
### Step 4: Verify #### Legacy Sync Attribute Reference
```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
| Attribute | Required | Default | Description | | 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-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 | | `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 ### 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 ```bash
# View the central copy # View the central copy
@@ -1369,16 +1434,13 @@ kubectl get secret myapp-oidc-credentials -n secrets \
-o jsonpath='{.data.client-secret}' | base64 -d -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 registrar runs as a CronJob every 2 minutes (`concurrencyPolicy: Forbid`)
- 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
- If the target namespace doesn't exist, the target write is skipped with a warning (the central copy still happens) - 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 - A central copy is **always** written to the `secrets` namespace for every synced client
- The syncer uses the `keycloak-credentials` secret for admin authentication - The registrar uses the `keycloak-credentials` secret for admin authentication
- Created secrets have the label `app.kubernetes.io/managed-by: keycloak-secret-syncer` - Created secrets have the label `app.kubernetes.io/managed-by: keycloak-client-registrar`
--- ---

View File

@@ -123,6 +123,7 @@ launchpad/
│ ├── replicaset-cleaner.yaml │ ├── replicaset-cleaner.yaml
│ ├── default-ns-blocker.yaml │ ├── default-ns-blocker.yaml
│ ├── secret-cloner.yaml │ ├── secret-cloner.yaml
│ ├── keycloak-client-cloner.yaml
│ └── auth-sidecar-injector.yaml │ └── auth-sidecar-injector.yaml
├── secrets/ # Application secrets (sealed) ├── secrets/ # Application secrets (sealed)
@@ -869,29 +870,44 @@ dind:
- Gitea admin panel (`/admin/runners`) — runners show as Online - Gitea admin panel (`/admin/runners`) — runners show as Online
- Create test workflow in `.gitea/workflows/test.yml` — job executes - 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` **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**: **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 *Legacy path (existing clients like Gitea):*
3. Queries all clients in the `forte` realm 1. Authenticates to Keycloak Admin API using admin credentials from `keycloak-credentials` secret
4. Filters clients with `k8s.secret.sync: "true"` attribute 2. Queries all clients in the `forte` realm
5. For each matching client, retrieves the auto-generated secret via Keycloak Admin API 3. Filters clients with `k8s.secret.sync: "true"` attribute
6. Creates/updates a K8s Secret in the target namespace (from `k8s.secret.namespace` attribute) 4. For each matching client, retrieves the auto-generated secret via Keycloak Admin API
7. Always writes a central copy to the `secrets` namespace (for external deployment retrieval) 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**: **Resources**:
- `ServiceAccount`: `keycloak-secret-syncer` (namespace: `keycloak`) - `ServiceAccount`: `keycloak-client-registrar` (namespace: `keycloak`)
- `ClusterRole`: `keycloak-secret-syncer` (secrets: get/create/update/patch; namespaces: get/list) - `ClusterRole`: `keycloak-client-registrar` (secrets: get/list/create/update/patch; namespaces: get/list)
- `ClusterRoleBinding`: `keycloak-secret-syncer` - `ClusterRoleBinding`: `keycloak-client-registrar`
- `Job`: `keycloak-secret-syncer` (PostSync hook) - `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 | | 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-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 | | `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 ```yaml
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: <k8s.secret.name> name: keycloak-client-<app>
namespace: <k8s.secret.namespace> namespace: <app-namespace>
labels: labels:
app.kubernetes.io/managed-by: keycloak-secret-syncer keycloak.forteapps.net/client-config: "true"
stringData:
client.json: |
{
"clientId": "<app>",
"name": "<App Name>",
"redirectUris": ["https://<app>.forteapps.net/*"],
"webOrigins": ["https://<app>.forteapps.net"],
"defaultClientScopes": ["openid", "email", "profile"],
"protocolMappers": [],
"secret": {
"namespace": "<app-namespace>",
"name": "<app>-oidc-credentials",
"keys": { "clientId": "client-id", "clientSecret": "client-secret" }
}
}
```
**Created Credential Secret Format**:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: <target-name>
namespace: <target-namespace>
labels:
app.kubernetes.io/managed-by: keycloak-client-registrar
type: Opaque type: Opaque
data: data:
<client-id-key>: <base64-encoded client ID> <client-id-key>: <base64-encoded client ID>
<client-secret-key>: <base64-encoded client secret> <client-secret-key>: <base64-encoded client secret>
``` ```
**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**: **Verification**:
```bash ```bash
# Check job status # Check CronJob status
kubectl get jobs -n keycloak kubectl get cronjobs -n keycloak
# View syncer logs # View latest registrar logs
kubectl logs -n keycloak job/keycloak-secret-syncer kubectl logs -n keycloak job/$(kubectl get jobs -n keycloak --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}')
# Verify created secret # Verify created secret
kubectl get secret <name> -n <namespace> -o yaml kubectl get secret <name> -n <namespace> -o yaml
# Check config Secret annotations (self-service)
kubectl get secret keycloak-client-<app> -n keycloak -o jsonpath='{.metadata.annotations}'
``` ```
**See**: [Developer Guide - Adding a New Keycloak Client](DEVELOPER-GUIDE.md#adding-a-new-keycloak-client) **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"` **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 ### Default Namespace Blocker
**File**: `cluster-resources/policies/default-ns-blocker.yaml` **File**: `cluster-resources/policies/default-ns-blocker.yaml`

View File

@@ -43,6 +43,6 @@ spec:
ignoreDifferences: ignoreDifferences:
- group: batch - group: batch
kind: Job kind: CronJob
jsonPointers: jsonPointers:
- /spec/template/spec/containers/0/args - /spec/jobTemplate/spec/template/spec/containers/0/args

View File

@@ -105,213 +105,354 @@ keycloakConfigCli:
} }
extraDeploy: extraDeploy:
# -- ServiceAccount for the secret syncer Job # -- ServiceAccount for the client registrar CronJob
- apiVersion: v1 - apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: keycloak-secret-syncer name: keycloak-client-registrar
namespace: keycloak namespace: keycloak
# -- ClusterRole granting access to secrets and namespaces # -- ClusterRole granting access to secrets and namespaces
- apiVersion: rbac.authorization.k8s.io/v1 - apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole kind: ClusterRole
metadata: metadata:
name: keycloak-secret-syncer name: keycloak-client-registrar
rules: rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["secrets"] resources: ["secrets"]
verbs: ["get", "create", "update", "patch"] verbs: ["get", "list", "create", "update", "patch"]
- apiGroups: [""] - apiGroups: [""]
resources: ["namespaces"] resources: ["namespaces"]
verbs: ["get", "list"] verbs: ["get", "list"]
# -- ClusterRoleBinding for the syncer ServiceAccount # -- ClusterRoleBinding for the registrar ServiceAccount
- apiVersion: rbac.authorization.k8s.io/v1 - apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding kind: ClusterRoleBinding
metadata: metadata:
name: keycloak-secret-syncer name: keycloak-client-registrar
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
kind: ClusterRole kind: ClusterRole
name: keycloak-secret-syncer name: keycloak-client-registrar
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: keycloak-secret-syncer name: keycloak-client-registrar
namespace: keycloak namespace: keycloak
# -- PostSync Job: extracts Keycloak client secrets into K8s Secrets # -- CronJob: registers Keycloak clients and syncs secrets
- apiVersion: batch/v1 - apiVersion: batch/v1
kind: Job kind: CronJob
metadata: metadata:
name: keycloak-secret-syncer name: keycloak-client-registrar
namespace: keycloak namespace: keycloak
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec: spec:
backoffLimit: 3 schedule: "*/2 * * * *"
template: concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec: spec:
serviceAccountName: keycloak-secret-syncer backoffLimit: 3
restartPolicy: Never template:
containers: spec:
- name: syncer serviceAccountName: keycloak-client-registrar
image: alpine:3.20 restartPolicy: Never
command: ["/bin/sh", "-c"] containers:
args: - name: registrar
- | image: alpine:3.20
set -e command: ["/bin/sh", "-c"]
apk add --no-cache curl jq > /dev/null 2>&1 args:
- |
set -e
apk add --no-cache curl jq > /dev/null 2>&1
KEYCLOAK_URL="http://keycloak:80" KEYCLOAK_URL="http://keycloak:80"
REALM="forte" 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 # --- Authenticate to Keycloak Admin API ---
ADMIN_USER="admin" ADMIN_USER="admin"
ADMIN_PASS=$(cat /secrets/admin-password) ADMIN_PASS=$(cat /secrets/admin-password)
# Authenticate to Keycloak Admin API echo "Authenticating to Keycloak..."
echo "Authenticating to Keycloak..." TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -d "client_id=admin-cli" \
-d "client_id=admin-cli" \ -d "username=${ADMIN_USER}" \
-d "username=${ADMIN_USER}" \ -d "password=${ADMIN_PASS}" \
-d "password=${ADMIN_PASS}" \ -d "grant_type=password" | jq -r '.access_token')
-d "grant_type=password" | jq -r '.access_token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "ERROR: Failed to authenticate to Keycloak" echo "ERROR: Failed to authenticate to Keycloak"
exit 1 exit 1
fi fi
# Get all clients in the realm # --- Helper functions ---
echo "Fetching clients from realm '${REALM}'..."
CLIENTS=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients")
# Filter clients with k8s.secret.sync=true # Upsert a K8s Secret: try PUT (update), fall back to POST (create)
SYNC_CLIENTS=$(echo "$CLIENTS" | jq -c '[.[] | select(.attributes["k8s.secret.sync"] == "true")]') upsert_secret() {
COUNT=$(echo "$SYNC_CLIENTS" | jq 'length') local ns="$1" name="$2" manifest="$3"
echo "Found ${COUNT} client(s) with sync enabled" 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" # Build a credential Secret JSON manifest
SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) build_credential_secret() {
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6"
CENTRAL_NS="secrets" cat <<MANIFEST
{
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "${name}",
"namespace": "${ns}",
"labels": {
"app.kubernetes.io/managed-by": "keycloak-client-registrar"
}
},
"type": "Opaque",
"data": {
"${id_key}": "${b64_id}",
"${secret_key}": "${b64_secret}"
}
}
MANIFEST
}
# Upsert a K8s Secret: try PUT (update), fall back to POST (create) # Sync credentials to target + central namespace
upsert_secret() { sync_credentials() {
local ns="$1" name="$2" manifest="$3" local client_id="$1" client_uuid="$2" target_ns="$3" target_name="$4" id_key="$5" secret_key="$6"
local code
code=$(curl -sf -o /dev/null -w "%{http_code}" \ # Get the client secret from Keycloak
--cacert "$CA_CERT" \ local secret_value
-H "Authorization: Bearer ${SA_TOKEN}" \ secret_value=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${client_uuid}/client-secret" \
-X PUT -d "$manifest" \ | jq -r '.value')
"${K8S_API}/api/v1/namespaces/${ns}/secrets/${name}")
if [ "$code" = "200" ]; then if [ -z "$secret_value" ] || [ "$secret_value" = "null" ]; then
echo " Updated secret '${ns}/${name}'" echo " WARNING: No secret found for client '${client_id}', skipping"
elif [ "$code" = "404" ]; then return 0
code=$(curl -sf -o /dev/null -w "%{http_code}" \ fi
local b64_id b64_secret
b64_id=$(printf '%s' "$client_id" | base64 | tr -d '\n')
b64_secret=$(printf '%s' "$secret_value" | base64 | tr -d '\n')
# Write to target namespace (if it exists)
local ns_status
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}")
if [ "$ns_status" = "200" ]; then
local manifest
manifest=$(build_credential_secret "$target_ns" "$target_name" "$id_key" "$secret_key" "$b64_id" "$b64_secret")
upsert_secret "$target_ns" "$target_name" "$manifest" || return 1
else
echo " WARNING: Namespace '${target_ns}' does not exist, skipping target"
fi
# Always write a central copy to the secrets namespace
local central_manifest
central_manifest=$(build_credential_secret "$CENTRAL_NS" "$target_name" "$id_key" "$secret_key" "$b64_id" "$b64_secret")
upsert_secret "$CENTRAL_NS" "$target_name" "$central_manifest" || return 1
}
# Annotate a K8s Secret with sync status
annotate_secret() {
local ns="$1" name="$2" key="$3" value="$4"
local patch
patch=$(printf '{"metadata":{"annotations":{"%s":"%s"}}}' "$key" "$value")
curl -sf -o /dev/null \
--cacert "$CA_CERT" \
-H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/strategic-merge-patch+json" \
-X PATCH -d "$patch" \
"${K8S_API}/api/v1/namespaces/${ns}/secrets/${name}"
}
# =============================================
# LEGACY PATH — sync existing realm clients
# =============================================
echo "=== Legacy sync: clients with k8s.secret.sync=true ==="
CLIENTS=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients")
SYNC_CLIENTS=$(echo "$CLIENTS" | jq -c '[.[] | select(.attributes["k8s.secret.sync"] == "true")]')
COUNT=$(echo "$SYNC_CLIENTS" | jq 'length')
echo "Found ${COUNT} legacy client(s) with sync enabled"
echo "$SYNC_CLIENTS" | jq -c '.[]' | while read -r CLIENT; do
CLIENT_ID=$(echo "$CLIENT" | jq -r '.clientId')
CLIENT_UUID=$(echo "$CLIENT" | jq -r '.id')
TARGET_NS=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.namespace"]')
TARGET_NAME=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.name"]')
ID_KEY=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.client-id-key"] // "client-id"')
SECRET_KEY=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.client-secret-key"] // "client-secret"')
echo "Processing legacy client '${CLIENT_ID}' -> '${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" \ --cacert "$CA_CERT" \
-H "Authorization: Bearer ${SA_TOKEN}" \ -H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/json" \ "${K8S_API}/api/v1/namespaces/keycloak/secrets?labelSelector=keycloak.forteapps.net/client-config=true")
-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
}
# Build a Secret JSON manifest CONFIG_COUNT=$(echo "$CONFIG_SECRETS" | jq '.items | length')
# Args: namespace, name, id-key, secret-key, b64-id, b64-secret echo "Found ${CONFIG_COUNT} config Secret(s) to process"
build_manifest() {
local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6"
cat <<MANIFEST
{
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "${name}",
"namespace": "${ns}",
"labels": {
"app.kubernetes.io/managed-by": "keycloak-secret-syncer"
}
},
"type": "Opaque",
"data": {
"${id_key}": "${b64_id}",
"${secret_key}": "${b64_secret}"
}
}
MANIFEST
}
echo "$SYNC_CLIENTS" | jq -c '.[]' | while read -r CLIENT; do echo "$CONFIG_SECRETS" | jq -c '.items[]' | while read -r CONFIG_SECRET; do
CLIENT_ID=$(echo "$CLIENT" | jq -r '.clientId') CONFIG_NAME=$(echo "$CONFIG_SECRET" | jq -r '.metadata.name')
CLIENT_UUID=$(echo "$CLIENT" | jq -r '.id') SOURCE_NS=$(echo "$CONFIG_SECRET" | jq -r '.metadata.annotations["keycloak.forteapps.net/source-namespace"] // .metadata.labels["keycloak.forteapps.net/source-namespace"] // "unknown"')
TARGET_NS=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.namespace"]')
TARGET_NAME=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.name"]')
# Configurable key names (defaults: client-id, client-secret) # Decode client.json from the Secret data
ID_KEY=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.client-id-key"] // "client-id"') CLIENT_JSON_B64=$(echo "$CONFIG_SECRET" | jq -r '.data["client.json"] // empty')
SECRET_KEY=$(echo "$CLIENT" | jq -r '.attributes["k8s.secret.client-secret-key"] // "client-secret"') if [ -z "$CLIENT_JSON_B64" ]; then
echo "WARNING: Config Secret '${CONFIG_NAME}' missing client.json field, skipping"
continue
fi
CLIENT_JSON=$(printf '%s' "$CLIENT_JSON_B64" | base64 -d)
echo "Processing client '${CLIENT_ID}' -> 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 # Compute config hash for change detection
SECRET_VALUE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ CONFIG_HASH=$(printf '%s' "$CLIENT_JSON" | sha256sum | cut -d' ' -f1)
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/client-secret" \ EXISTING_HASH=$(echo "$CONFIG_SECRET" | jq -r '.metadata.annotations["keycloak.forteapps.net/config-hash"] // ""')
| jq -r '.value')
if [ -z "$SECRET_VALUE" ] || [ "$SECRET_VALUE" = "null" ]; then # Extract secret delivery config from client.json
echo " WARNING: No secret found for client '${CLIENT_ID}', skipping" CRED_NS=$(echo "$CLIENT_JSON" | jq -r '.secret.namespace // "'"${SOURCE_NS}"'"')
continue CRED_NAME=$(echo "$CLIENT_JSON" | jq -r '.secret.name // "'"${CLIENT_ID}"'-oidc-credentials"')
fi 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') # Check if credential Secret already exists in target namespace
B64_SECRET=$(printf '%s' "$SECRET_VALUE" | base64 | tr -d '\n') 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) # Skip if hash matches and credential Secret exists
NS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ if [ "$CONFIG_HASH" = "$EXISTING_HASH" ] && [ "$CRED_EXISTS" = "200" ]; then
--cacert "$CA_CERT" \ echo " No changes detected, skipping"
-H "Authorization: Bearer ${SA_TOKEN}" \ continue
"${K8S_API}/api/v1/namespaces/${TARGET_NS}") fi
if [ "$NS_STATUS" = "200" ]; then # Build Keycloak client representation (strip our secret delivery config)
MANIFEST=$(build_manifest "$TARGET_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") KC_CLIENT=$(echo "$CLIENT_JSON" | jq '{
upsert_secret "$TARGET_NS" "$TARGET_NAME" "$MANIFEST" || exit 1 clientId: .clientId,
else name: .name,
echo " WARNING: Namespace '${TARGET_NS}' does not exist, skipping target" enabled: true,
fi 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 # Check if client already exists
CENTRAL_MANIFEST=$(build_manifest "$CENTRAL_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") EXISTING=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
upsert_secret "$CENTRAL_NS" "$TARGET_NAME" "$CENTRAL_MANIFEST" || exit 1 "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_ID}" \
done | jq -r '.[0].id // empty')
echo "Secret sync complete" if [ -n "$EXISTING" ]; then
volumeMounts: echo " Updating existing Keycloak client (uuid: ${EXISTING})"
- name: keycloak-credentials HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
mountPath: /secrets -H "Authorization: Bearer ${TOKEN}" \
readOnly: true -H "Content-Type: application/json" \
resources: -X PUT -d "$KC_CLIENT" \
requests: "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${EXISTING}")
cpu: 50m if [ "$HTTP_CODE" != "204" ] && [ "$HTTP_CODE" != "200" ]; then
memory: 64Mi echo " ERROR: Failed to update client '${CLIENT_ID}' (HTTP ${HTTP_CODE})"
limits: annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "error"
cpu: 200m continue
memory: 128Mi fi
volumes: CLIENT_UUID="$EXISTING"
- name: keycloak-credentials else
secret: echo " Creating new Keycloak client '${CLIENT_ID}'"
secretName: keycloak-credentials HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
items: -H "Authorization: Bearer ${TOKEN}" \
- key: admin-password -H "Content-Type: application/json" \
path: admin-password -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