platform: deploy Headlamp dashboard with Keycloak OIDC + per-user RBAC

Add Headlamp (kubernetes-sigs/headlamp Helm chart) at
headlamp.home.keithjackson.dev with native Keycloak OIDC login and PER-USER
RBAC: Headlamp forwards each user's id_token to the k3s apiserver, so the
existing oidc:* group bindings (platform-admins->cluster-admin, viewers->view)
apply per user. The ServiceAccount is deliberately NOT bound to cluster-admin
(clusterRoleBinding.create=false), since user requests ride the forwarded token.

Key integration nuance: the apiserver is configured oidc-client-id=kubernetes,
so it only accepts tokens whose aud contains `kubernetes`. The new `headlamp`
Keycloak client carries an oidc-audience-mapper (included.client.audience=
kubernetes) so tokens carry aud=[headlamp, kubernetes] -- accepted by both
Headlamp's own verifier and the apiserver, leaving the apiserver config
untouched. A groups mapper emits the `groups` claim the apiserver maps to RBAC.

- platform/headlamp: namespace, HelmRepository/HelmRelease, SOPS OIDC secret
  (externalSecret envFrom), default-deny NetworkPolicy + Cilium ingress
  (gateway)/egress (apiserver+Keycloak) policies, README. Wired into
  platform/kustomization.yaml (platform tier).
- platform/headlamp/httproute.yaml wired into platform-config (Gateway API CR,
  dependsOn platform), modeled on grafana-httproute.
- keycloak-realm-import: add `headlamp` confidential client + groups/audience
  mappers + HEADLAMP_CLIENT_SECRET placeholder. Client also created LIVE via
  kcadm (realm import no-ops on the existing realm).
- keycloak-realm-clients.sops: add headlamp-client-secret (mirrored into
  headlamp-oidc-credentials clientSecret). Both encrypted to the repo age key.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-28 11:44:33 -05:00
1 parent b243c753b1
commit 5fb097d019
14 files changed
+430 -2

No files matched your search

@@ -10,6 +10,7 @@ stringData:
gitea-client-secret: ENC[AES256_GCM,data:ObU5sWSq2xEZKgqwccFo/kzs/nfhOVJ0NceBlgxgy6a6iOWeUN079iQpJ0ADoJyjsoQk1IQediFD06rwVSvNIQ==,iv:Bc4L8MngA6YKNRQRwEQojnUo5ixdS2t1AocvGMwE0oQ=,tag:YsozaKtL6CR68sPmGiL5wA==,type:str]
nextcloud-client-secret: ENC[AES256_GCM,data:Uh3+EqIr88CNNt3KfDYP8lbNIhcB9iQrxJvbaxek40hD+JYAJmUP31WQypyDY3BiSvkjE6urr9PJmYoDgz7tkw==,iv:Tg4WAgyydIUBB1mqdOZPt0HsCkWSGNPvbI4WtFKj9V8=,tag:1LgZYdDPVEM+9UROanBllw==,type:str]
semaphore-client-secret: ENC[AES256_GCM,data:BX5lXhFNX6vQh67G2+XM2vt9wymhL8Psx+NqQJneFup7gdZRjhk8paEU5dOfugZNwooieara7UFyAFm5+1BJFg==,iv:kmmz0ZiCZgsSowns3hjDTQzoD2lPCr9Oz1iS5WgbhEE=,tag:BbesQX6lxAhaGiqPqMQ9vA==,type:str]
headlamp-client-secret: ENC[AES256_GCM,data:hlQ5m6FeqwOqX9HTT8jC6NZmhQH0kMgUveMZ2Cfen1zLLxWfYXXC1QMBWSqrchY2EpyIr7vNPbFjbqffwiSDHQ==,iv:WS1F3gkWn18STl4c38AlCe7TU6jvZpl/eULf2TnjIZ4=,tag:n2BnQYrm4gZbLeemDBykjA==,type:str]
sops:
kms: []
gcp_kms: []
@@ -25,8 +26,8 @@ sops:
YXJGUUJiTnFndFhDZDU1bmxXTENCbmsKuhb3RQ3LZmuTPAXEck8b6iuZ5vxLb9fV
+fQELmU4kOTbZrPSxy+kpttLG5Cy5d6X2ieZXBf6opzpLTUhlBOkHA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-27T22:12:32Z"
mac: ENC[AES256_GCM,data:nruWApjP/BK694O9/BtSsQExmjtWTBNCfq+FyXDtockjRdDHRmyxuePVdfvm2F/OO7AuxI2EPYhr+a1SiB+RYGWfz7GCtbKW1iDPPmgiAnGxFH/HiuEGpa7S4+CVkNaD1j/z6jBkYe6oPko2IO/ezVtcTG/x7Wzst8vRDeid5Tw=,iv:wzuEl5cnycPMpoBGWrx1F2YoIgFufZ9se0yzVcQznEQ=,tag:B5pSL2IEC9T5maa7UZhYJw==,type:str]
lastmodified: "2026-08-28T16:39:25Z"
mac: ENC[AES256_GCM,data:HRQ1SISIr9DrfCz96vEDGd/vP0o6pkVy5iMAUS+310oWAWhTxeXFvROJAhfdL1Pq8GzgFnA8s/Z6mSwr2E/D3Sjp679dYrZJeuKqt7A/TFiIsTt2yka+xpH9uCtoX8XpQDXFgq2bpAeOYoUVIahHCm9/P6mRT+D0tOkUNCcgOPE=,iv:WNR1+aVE/Ru3UmNiTRwDVN3KshIJvCdlvo3TIJJQiLA=,tag:J9r9x2yeRhiTFCraW0XH9g==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -21,6 +21,8 @@
# (key: client-secret) [oauth2-proxy namespace]
# - grafana-client-secret -> platform/monitoring/grafana-oidc-credentials.sops.yaml
# (key: client-secret) [monitoring namespace]
# - headlamp-client-secret -> platform/headlamp/headlamp-oidc-credentials.sops.yaml
# (key: clientSecret) [headlamp namespace]
# (Cross-namespace Secrets cannot be shared, hence one value stored in two encrypted files.)
#
# Until this file is filled + added to the kustomization, the KeycloakRealmImport stays PENDING
@@ -35,3 +37,4 @@ type: Opaque
stringData:
grafana-client-secret: REPLACE-ME
oauth2-proxy-client-secret: REPLACE-ME
headlamp-client-secret: REPLACE-ME
@@ -35,6 +35,10 @@ spec:
secret:
name: keycloak-realm-clients
key: semaphore-client-secret
HEADLAMP_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: headlamp-client-secret
realm:
realm: homelab
enabled: true
@@ -260,6 +264,55 @@ spec:
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# headlamp — the Kubernetes dashboard (platform/headlamp). Confidential auth-code client modeled
# on grafana. Redirect URI is Headlamp's OIDC callback (<base>/oidc-callback). Headlamp forwards
# the logged-in user's id_token to the k3s apiserver as the bearer, so RBAC is applied PER USER
# via the existing oidc:* group bindings (k8s-oidc-rbac.yaml) — NOT via Headlamp's ServiceAccount.
#
# AUDIENCE MAPPER (the key integration nuance): the apiserver is configured oidc-client-id=
# kubernetes, so it only accepts ID tokens whose `aud` CONTAINS `kubernetes`. A plain `headlamp`
# client issues aud=[headlamp] -> the apiserver would REJECT it. The oidc-audience-mapper below
# ADDS the `kubernetes` client to the audience, giving aud=[headlamp, kubernetes]: Headlamp's own
# verifier accepts it (aud contains headlamp) AND the apiserver accepts it (aud contains
# kubernetes). This keeps the apiserver's oidc config untouched (preferred over widening
# api-audiences). The `groups` mapper (same as the other clients) carries the group claim the
# apiserver maps to oidc:<group> for the RBAC bindings.
- clientId: headlamp
name: Headlamp (Kubernetes dashboard)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:HEADLAMP_CLIENT_SECRET)"
redirectUris:
- https://headlamp.home.keithjackson.dev/oidc-callback
webOrigins:
- https://headlamp.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# Add `kubernetes` to the token audience so the k3s apiserver (oidc-client-id=kubernetes)
# accepts the forwarded id_token. `included.client.audience` names the existing `kubernetes`
# client; the mapper appends it to `aud` in the ID token (and access token, harmless).
- name: audience-kubernetes
protocol: openid-connect
protocolMapper: oidc-audience-mapper
config:
"included.client.audience": kubernetes
"id.token.claim": "true"
"access.token.claim": "true"
# --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the
# platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP.
# Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails