11---
22title : " OIDC for tenant Kubernetes clusters"
33linkTitle : " Tenant Kubernetes OIDC"
4- description : " Per-tenant Keycloak realms and OIDC authentication on tenant kube-apiservers"
4+ description : " OIDC authentication on tenant kube-apiservers via the platform cozy Keycloak realm "
55weight : 40
66aliases :
77 - /docs/next/oidc/tenant-kubernetes
@@ -11,7 +11,8 @@ This page covers OIDC authentication on the tenant Kubernetes clusters
1111managed by Cozystack (the per-tenant kube-apiservers backed by Kamaji).
1212It complements [ Enable OIDC Server] ({{< relref "enable_oidc.md" >}}),
1313which covers OIDC for the ** management** cluster (dashboard, kubeapps,
14- mgmt kubectl) using the platform ` cozy ` realm.
14+ mgmt kubectl). Both flows authenticate against the same platform ` cozy `
15+ Keycloak realm.
1516
1617In all examples below the tenant is ` acme ` (so its namespace is
1718` tenant-acme ` ), the tenant Kubernetes CR is ` prod-a ` (so its release
@@ -20,36 +21,42 @@ name is `kubernetes-prod-a`), and the platform root host is
2021
2122## Overview
2223
23- Each Cozystack tenant gets its own dedicated Keycloak realm for its
24- kube-apiservers and tenant-scoped applications. The management
25- identity domain (the ` cozy ` realm) stays separated from per-tenant
26- identity. Users granted access to a tenant cluster live in that
27- tenant's realm only — they cannot accidentally log into the management
28- cluster or another tenant.
29-
30- When the ` Kubernetes ` CR opts into OIDC (` spec.oidc.enabled: true ` ):
31-
32- 1 . The ` apps/tenant ` chart auto-provisions a ` ClusterKeycloakRealm `
33- named after the tenant (` tenant-acme ` ) and the standard ` groups `
34- ` KeycloakClientScope ` inside it. The realm name is published as
35- ` _namespace.oidc-realm ` in the tenant's ` cozystack-values ` Secret
36- so descendants and apps pick it up.
37- 2 . The ` apps/kubernetes ` chart creates a per-cluster public
38- ` KeycloakClient kubernetes-prod-a ` (with its own audience scope so
39- cross-cluster token replay fails inside the same realm), the
40- ` KeycloakRealmGroup prod-a ` , and wires the tenant kube-apiserver
41- via ` KamajiControlPlane.spec.apiServer.extraArgs ` .
42- 3 . A post-install Job (` kubernetes-prod-a-oidc-rbac ` ) applies a
43- ` ClusterRoleBinding ` inside the tenant cluster, binding the realm
44- group ` prod-a ` to the built-in ` cluster-admin ` ClusterRole.
45- Operators grant or revoke access by adding or removing users from
46- the Keycloak group.
47-
48- Operators do ** not** need to pre-toggle ` Tenant.spec.oidc.enabled `
49- during normal operation — the parent ` apps/tenant ` chart auto-detects
50- child Kubernetes CRs with ` oidc.enabled: true ` and provisions the
51- realm automatically. The explicit ` Tenant.spec.oidc.enabled=true ` is
52- only useful as a manual cleanup workaround (see Limitations below).
24+ Tenant kube-apiservers authenticate users against the platform ` cozy `
25+ Keycloak realm. Cross-cluster isolation is enforced via a per-cluster
26+ ** audience claim** — a token minted for cluster A's apiserver fails
27+ audience validation when replayed against cluster B's apiserver, even
28+ though both consume the same realm.
29+
30+ When the ` Kubernetes ` CR opts into OIDC (` spec.oidc.enabled: true ` ),
31+ the ` apps/kubernetes ` chart provisions the following objects:
32+
33+ 1 . A per-cluster public ` KeycloakClient `
34+ ` tenant-acme-kubernetes-prod-a ` in the cozy realm, with its own
35+ ` KeycloakClientScope ` audience mapper
36+ ` tenant-acme-kubernetes-prod-a-audience ` so cross-cluster token
37+ replay fails.
38+ 2 . A ` KeycloakRealmGroup tenant-acme-kubernetes-prod-a ` in the cozy
39+ realm. The name parallels the existing ` tenant-<X>-<level> ` group
40+ pattern used for the management cluster's per-tenant access tiers.
41+ 3 . OIDC arguments on the tenant kube-apiserver via
42+ ` KamajiControlPlane.spec.apiServer.extraArgs ` (`--oidc-issuer-url=
43+ https://keycloak.acme.example.com/realms/cozy` , `--oidc-client-id=
44+ tenant-acme-kubernetes-prod-a` , ` --oidc-username-claim=
45+ preferred_username` , ` --oidc-groups-claim=groups`).
46+ 4 . A post-install/post-upgrade Job
47+ (` kubernetes-prod-a-oidc-rbac ` ) that applies a ` ClusterRoleBinding `
48+ inside the tenant cluster, binding the realm group to the built-in
49+ ` cluster-admin ` ClusterRole.
50+
51+ Operators grant or revoke access by adding or removing users from the
52+ ` tenant-acme-kubernetes-prod-a ` group in the cozy realm.
53+
54+ ** Future work** : dedicated per-tenant Keycloak realms (with
55+ tenant-delegated identity management — the tenant owner manages their
56+ own users without involving the platform admin) are a planned follow-up.
57+ This iteration keeps identity ownership with the platform operator to
58+ avoid the bootstrap-credentials and realm-lifecycle complexity that
59+ realm-per-tenant introduces.
5360
5461## Prerequisites
5562
@@ -84,30 +91,17 @@ spec:
8491 enabled : true
8592` ` `
8693
87- Each chart in the chain reconciles on its own loop (default interval
88- 5 minutes), so the full cascade takes up to ~10 minutes worst case
89- from a cold start:
90-
91- 1. The ` apps/tenant` reconcile creates `ClusterKeycloakRealm tenant-acme`
92- and publishes `_namespace.oidc-realm=tenant-acme` to the tenant's
93- ` cozystack-values` Secret.
94- 2. The `apps/kubernetes` reconcile picks up the realm, provisions the
95- per-cluster `KeycloakClient kubernetes-prod-a`, the realm group
96- ` prod-a` , and adds OIDC flags to the kube-apiserver.
97- 3. The post-install Job binds `Group prod-a` to `cluster-admin` inside
98- the tenant cluster.
99-
100- Until step 1 completes, `apps/kubernetes` renders a
101- ` kubernetes-prod-a-awaiting-oidc-realm` ConfigMap beacon in the tenant
102- namespace and the kube-apiserver runs without OIDC arguments — the
103- client-cert (mTLS) admin kubeconfig stays usable throughout.
94+ On the next ` apps/kubernetes` reconcile (≤ 5 min) the kube-apiserver
95+ gets OIDC flags, the per-cluster `KeycloakClient` + audience scope +
96+ ` KeycloakRealmGroup` appear in the cozy realm, and the post-install
97+ Job creates the in-cluster `ClusterRoleBinding`.
10498
10599# # Create a user and grant access
106100
107- In Keycloak ( the tenant realm — `tenant-acme`) :
101+ In Keycloak, on the platform `cozy` realm :
108102
1091031. Create a user, set a non-temporary password, mark email verified.
110- 2. Add the user to the realm group named after the cluster (` prod-a`) .
104+ 2. Add the user to the realm group `tenant-acme-kubernetes- prod-a`.
111105 One membership = full `cluster-admin` access to that cluster.
112106
113107To revoke access, remove the user from the group.
@@ -161,8 +155,8 @@ users:
161155 args:
162156 - oidc-login
163157 - get-token
164- - --oidc-issuer-url=https://keycloak.acme.example.com/realms/tenant-acme
165- - --oidc-client-id=kubernetes-prod-a
158+ - --oidc-issuer-url=https://keycloak.acme.example.com/realms/cozy
159+ - --oidc-client-id=tenant-acme- kubernetes-prod-a
166160` ` `
167161
168162Running `kubectl get pods` opens the browser, logs the user into
@@ -171,29 +165,6 @@ on the `groups` claim.
171165
172166# # Limitations
173167
174- # ## Realm cleanup is not automatic after the last child OIDC cluster is removed
175-
176- The `apps/tenant` chart uses Helm's `lookup` function to discover
177- whether any child `Kubernetes` CR has `spec.oidc.enabled=true`.
178- Helm-controller does **not** re-render a chart when a `lookup` result
179- changes — it only re-renders when the chart source artifact or the
180- HelmRelease values change. Consequently, deleting the last
181- ` Kubernetes` CR with OIDC enabled does **not** trigger an
182- ` apps/tenant` re-render, and the orphan `ClusterKeycloakRealm` stays
183- in the tenant namespace.
184-
185- To force cleanup, the operator can :
186-
187- - Toggle `Tenant.spec.oidc.enabled=true` and then back to `false`.
188- Each toggle changes the HelmRelease values, which triggers a
189- re-render with the up-to-date lookup result. After the second
190- toggle, the chart no longer renders the realm and Helm prunes it.
191- This is the only legitimate use of `Tenant.spec.oidc.enabled` —
192- during normal operation the field stays at its default `false`.
193- - Or wait for the next platform upgrade that bumps any
194- chart-affecting source — the realm cleanup happens for free as a
195- side effect of the new render.
196-
197168# ## Self-signed Keycloak is not supported
198169
199170The tenant apiserver validates the OIDC issuer over HTTPS using the
@@ -228,7 +199,7 @@ match it once OIDC is off. Manual cleanup:
228199
229200` ` ` bash
230201KUBECONFIG=/tmp/prod-a-admin.kubeconfig kubectl delete clusterrolebinding \
231- --selector cozystack.io/oidc-cluster=prod-a
202+ --selector cozystack.io/oidc-cluster=tenant-acme-kubernetes- prod-a
232203` ` `
233204
234205(reuse the admin kubeconfig extracted in the "Wire kubectl" section).
@@ -241,7 +212,8 @@ browser-flow logins. For CI pipelines that need a non-interactive
241212token, the cluster-admin can patch the client on the live cluster :
242213
243214` ` ` bash
244- kubectl --context=mgmt -n tenant-acme patch keycloakclient kubernetes-prod-a \
215+ kubectl --context=mgmt -n tenant-acme patch keycloakclient \
216+ tenant-acme-kubernetes-prod-a \
245217 --type=merge --patch '{"spec":{"directAccess":true}}'
246218` ` `
247219
@@ -262,10 +234,11 @@ kubectl --context=mgmt -n tenant-acme get pod \
262234 tr ',' '\n ' | grep oidc
263235` ` `
264236
265- Confirm the issuer URL matches the realm — decode the id_token and
266- compare `iss` against the `--oidc-issuer-url` flag. Confirm `aud` in
267- the token equals `--oidc-client-id`; mismatch is the most common
268- cause when running multiple clusters in the same realm.
237+ Confirm the issuer URL matches the cozy realm — decode the id_token
238+ and compare `iss` against the `--oidc-issuer-url` flag. Confirm `aud`
239+ in the token equals `--oidc-client-id`; mismatch is the most common
240+ cause when running multiple clusters in the same realm (the per-cluster
241+ audience scope is what enforces isolation here).
269242
270243# ## Apiserver returns 403 for a user that is in the right group
271244
@@ -279,18 +252,14 @@ kubectl --context=mgmt -n tenant-acme \
279252 > /tmp/prod-a-admin.kubeconfig
280253
281254KUBECONFIG=/tmp/prod-a-admin.kubeconfig kubectl get clusterrolebinding \
282- --selector cozystack.io/oidc-cluster=prod-a
255+ --selector cozystack.io/oidc-cluster=tenant-acme-kubernetes- prod-a
283256` ` `
284257
285258The bootstrap Job runs as a `post-install` / `post-upgrade` hook;
286- check its logs in the management cluster :
259+ check its logs in the management cluster (the Job is garbage-collected
260+ on success — if you don't see it, that's the happy path) :
287261
288262` ` ` bash
289263kubectl --context=mgmt -n tenant-acme logs \
290264 job/kubernetes-prod-a-oidc-rbac
291265` ` `
292-
293- # ## Realm or scope objects stuck after CR deletion
294-
295- See "Realm cleanup is not automatic" under Limitations. Operator
296- intervention required (toggle `Tenant.spec.oidc`).
0 commit comments