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:
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
|
||||
|
||||
Reference in new issue
Block a user