Skip to content

Commit be7368b

Browse files
committed
docs(operations): rewrite tenant Kubernetes OIDC page for cozy-realm flow
Companion to the cozystack PR pivot away from per-tenant Keycloak realms. The implementation now authenticates tenant kube-apiservers against the platform cozy realm and enforces cross-cluster isolation via a per-cluster audience claim rather than realm separation. Per-tenant realms are deferred to a follow-up that also ships tenant-delegated identity management (the missing piece that would justify the realm-per-tenant operational surface area). Page changes: * Drop the per-tenant realm Overview framing; describe the cozy-realm flow with audience-claim isolation as the cross-cluster guard. * Update object names everywhere to the new globally-unique shape: KeycloakClient / KeycloakRealmGroup are now `<namespace>-<release-name>` (e.g. `tenant-acme-kubernetes-prod-a`), paralleling the existing `tenant-<X>-<level>` group pattern for the management cluster. * Update issuer URL examples to `https://keycloak.<root-host>/realms/cozy`. * Drop the "orphan realm cleanup workaround" Limitations section — no per-tenant realm means no orphan realm. * Drop the up-to-10-minute cascade timing + awaiting-oidc-realm beacon description — the cozy realm is always provisioned by install-cozystack before any tenant Kubernetes CR can request OIDC, so there is no race. * Keep: self-signed Keycloak unsupported, hard-coded JWT claims, runtime toggle does not clean up CRB, CI/headless KeycloakClient patch, the full troubleshooting section. * Add an explicit "Future work" callout flagging per-tenant realms + tenant-delegated identity management as planned follow-up so the reader understands today's design tradeoff. * Update troubleshooting cleanup recipe selectors to the new label value `cozystack.io/oidc-cluster=tenant-acme-kubernetes-prod-a`. Pairs with cozystack PR #3044. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
1 parent febf81d commit be7368b

1 file changed

Lines changed: 58 additions & 89 deletions

File tree

content/en/docs/next/operations/oidc/tenant_clusters.md

Lines changed: 58 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: "OIDC for tenant Kubernetes clusters"
33
linkTitle: "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"
55
weight: 40
66
aliases:
77
- /docs/next/oidc/tenant-kubernetes
@@ -11,7 +11,8 @@ This page covers OIDC authentication on the tenant Kubernetes clusters
1111
managed by Cozystack (the per-tenant kube-apiservers backed by Kamaji).
1212
It complements [Enable OIDC Server]({{< relref "enable_oidc.md" >}}),
1313
which 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

1617
In 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

109103
1. 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

113107
To 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

168162
Running `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

199170
The 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
230201
KUBECONFIG=/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
241212
token, 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
281254
KUBECONFIG=/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

285258
The 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
289263
kubectl --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

Comments
 (0)