Wires the bootstrapping-critical Gitea git host to the Keycloak `homelab` realm as a native confidential OIDC client, moving it off the local-admin-only login while keeping the local admin as the break-glass path (docs/15 availability caveat; R-IDP-02/06). Realm (source of truth): add the `gitea` confidential client to platform/keycloak/keycloak-realm-import.yaml (modeled on grafana) + its GITEA_CLIENT_SECRET placeholder sourced from the keycloak-realm-clients Secret. NOTE: a KeycloakRealmImport no-ops on an already-existing realm, so the live client was ALSO created via kcadm (id 70a6ca60-317a-42ce-8331-a60e380838ee); the YAML keeps git authoritative for a rebuild. Secrets (SOPS/age, never plaintext): add gitea-client-secret to platform/keycloak/keycloak-realm-clients.sops.yaml (keycloak ns) and mirror the same value as oidc-client-secret into apps/wave-0/gitea-bootstrap-secret.sops.yaml (gitea ns) so the app can read it in-namespace. Both == the live client secret. App config (catalog source + re-render): catalog/gitea.yaml gains the GITEA_OIDC_CLIENT_SECRET env (from the mirrored Secret), [oauth2_client] auto-registration/account-linking app.ini env, and an idempotent postStart step that registers the "keycloak" OAuth2 login source (auto-discovery; groups claim; platform-admins -> Gitea admin). apps/wave-0/gitea.yaml re-rendered via `render.py --no-image-automation`. Verified: realm import Done=True/HasErrors=False and platform-config Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run; static gate (lint/l0/l1) green. Browser SSO login still needs a human check. Co-Authored-By: Claude Opus 4.8 <[email protected]>
251 lines
9.9 KiB
YAML
251 lines
9.9 KiB
YAML
---
|
|
# Identity config as code (R-IDP-03): the `homelab` realm — groups, roles, OIDC clients, brute-force
|
|
# hardening, and a conditional MFA flow that REQUIRES OTP for platform-admins (R-IDP-04). Reconciled
|
|
# declaratively by the Keycloak Operator; CARRIED BY platform-config (dependsOn platform).
|
|
#
|
|
# Client secrets are NOT in this file: they are injected from the SOPS-encrypted keycloak-realm-clients
|
|
# Secret via spec.placeholders ($(env:...)) — so secrets exist only encrypted (R-IDP-04; SOPS, docs/07).
|
|
# Until that Secret is filled (operator hand-off, see keycloak-realm-clients.sops.yaml.example), this
|
|
# import stays PENDING on the unresolved placeholder.
|
|
apiVersion: k8s.keycloak.org/v2alpha1
|
|
kind: KeycloakRealmImport
|
|
metadata:
|
|
name: homelab
|
|
namespace: keycloak
|
|
spec:
|
|
keycloakCRName: keycloak
|
|
placeholders:
|
|
GRAFANA_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: grafana-client-secret
|
|
OAUTH2_PROXY_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: oauth2-proxy-client-secret
|
|
GITEA_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: gitea-client-secret
|
|
realm:
|
|
realm: homelab
|
|
enabled: true
|
|
displayName: "Homelab SSO"
|
|
loginWithEmailAllowed: true
|
|
registrationAllowed: false
|
|
resetPasswordAllowed: true
|
|
# Hardening (docs/15): brute-force detection ON; SSL required for external (edge-TLS) requests.
|
|
bruteForceProtected: true
|
|
permanentLockout: false
|
|
sslRequired: external
|
|
# --- Authorization model (R-IDP-06): groups drive access; platform-admins carry a realm role. ---
|
|
roles:
|
|
realm:
|
|
- name: platform-admin
|
|
description: "Privileged platform administrators — MFA required (R-IDP-04)."
|
|
groups:
|
|
- name: platform-admins
|
|
realmRoles:
|
|
- platform-admin
|
|
- name: media-users
|
|
- name: viewers
|
|
# --- OIDC clients (native SSO). Group membership is exposed as a `groups` claim so apps and
|
|
# oauth2-proxy can authorize by group (R-IDP-06). ---
|
|
clients:
|
|
# Grafana — the concrete native-OIDC SSO proof target (docs/10 / deliverable #4).
|
|
- clientId: grafana
|
|
name: Grafana
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:GRAFANA_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://grafana.home.keithjackson.dev/login/generic_oauth
|
|
webOrigins:
|
|
- https://grafana.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
|
|
# oauth2-proxy — forward-auth for non-OIDC UIs (PiHole, *arr, Prometheus, Alertmanager, …).
|
|
- clientId: oauth2-proxy
|
|
name: oauth2-proxy (forward-auth)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:OAUTH2_PROXY_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://*.home.keithjackson.dev/oauth2/callback
|
|
- https://auth.home.keithjackson.dev/oauth2/callback
|
|
webOrigins:
|
|
- https://*.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
|
|
# kubernetes — kubectl OIDC login via kubelogin (int128/kubelogin `oidc-login`). PUBLIC client +
|
|
# PKCE (S256) so NO client secret lives in any user's kubeconfig; kubelogin runs the auth-code
|
|
# +PKCE flow against a localhost callback and caches the ID token. The k3s API server validates
|
|
# that ID token (kube-apiserver oidc-issuer-url=this realm, oidc-client-id=kubernetes,
|
|
# oidc-username-claim=email, oidc-groups-claim=groups); RBAC binds the `groups` claim — see
|
|
# k8s-oidc-rbac.yaml (oidc:platform-admins -> cluster-admin, oidc:viewers -> view). The cert
|
|
# kubeconfig remains the break-glass admin, independent of Keycloak/OIDC availability.
|
|
- clientId: kubernetes
|
|
name: Kubernetes (kubectl)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: true
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
attributes:
|
|
"pkce.code.challenge.method": "S256"
|
|
redirectUris:
|
|
- http://localhost:8000
|
|
- http://localhost:8000/
|
|
- http://localhost:18000
|
|
- http://localhost:18000/
|
|
- http://127.0.0.1:8000
|
|
- http://127.0.0.1:8000/
|
|
- http://127.0.0.1:18000
|
|
- http://127.0.0.1:18000/
|
|
webOrigins:
|
|
- http://localhost
|
|
- http://127.0.0.1
|
|
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
|
|
# gitea — native OIDC SSO for the bootstrapping-critical git host (catalog/gitea.yaml auth.mode
|
|
# oidc; docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02). Confidential auth-code client modeled on
|
|
# grafana. Redirect URI is Gitea's OAuth2 callback for the auth source named `keycloak`
|
|
# (/user/oauth2/<source>/callback). The `groups` claim drives authorization: Gitea maps the
|
|
# platform-admins group -> Gitea admin (auth source --admin-group; R-IDP-06). The LOCAL Gitea admin
|
|
# remains the break-glass path (docs/15 availability caveat). Secret injected from the SOPS
|
|
# keycloak-realm-clients Secret via the GITEA_CLIENT_SECRET placeholder (R-IDP-04).
|
|
- clientId: gitea
|
|
name: Gitea
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:GITEA_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://gitea.home.keithjackson.dev/user/oauth2/keycloak/callback
|
|
webOrigins:
|
|
- https://gitea.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
|
|
# --- 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
|
|
# the whole import — if so, the fallback is to comment this block + browserFlow and configure
|
|
# the conditional-OTP-by-role flow via the admin console. ---
|
|
authenticationFlows:
|
|
- alias: browser-mfa
|
|
description: "Browser flow with conditional OTP required for platform-admins."
|
|
providerId: basic-flow
|
|
topLevel: true
|
|
builtIn: false
|
|
authenticationExecutions:
|
|
- authenticator: auth-cookie
|
|
authenticatorFlow: false
|
|
requirement: ALTERNATIVE
|
|
priority: 10
|
|
userSetupAllowed: false
|
|
- authenticator: identity-provider-redirector
|
|
authenticatorFlow: false
|
|
requirement: ALTERNATIVE
|
|
priority: 20
|
|
userSetupAllowed: false
|
|
- authenticatorFlow: true
|
|
requirement: ALTERNATIVE
|
|
priority: 30
|
|
flowAlias: browser-mfa-forms
|
|
userSetupAllowed: false
|
|
- alias: browser-mfa-forms
|
|
description: "Username/password then conditional OTP."
|
|
providerId: basic-flow
|
|
topLevel: false
|
|
builtIn: false
|
|
authenticationExecutions:
|
|
- authenticator: auth-username-password-form
|
|
authenticatorFlow: false
|
|
requirement: REQUIRED
|
|
priority: 10
|
|
userSetupAllowed: false
|
|
- authenticatorFlow: true
|
|
requirement: CONDITIONAL
|
|
priority: 20
|
|
flowAlias: browser-mfa-conditional-otp
|
|
userSetupAllowed: false
|
|
- alias: browser-mfa-conditional-otp
|
|
description: "Require OTP only for users with the platform-admin role."
|
|
providerId: basic-flow
|
|
topLevel: false
|
|
builtIn: false
|
|
authenticationExecutions:
|
|
- authenticator: conditional-user-role
|
|
authenticatorFlow: false
|
|
requirement: REQUIRED
|
|
priority: 10
|
|
authenticatorConfig: conditional-otp-platform-admin
|
|
userSetupAllowed: false
|
|
- authenticator: auth-otp-form
|
|
authenticatorFlow: false
|
|
requirement: REQUIRED
|
|
priority: 20
|
|
userSetupAllowed: false
|
|
authenticatorConfig:
|
|
- alias: conditional-otp-platform-admin
|
|
config:
|
|
"condition.evaluated.user.role": platform-admin
|
|
negate: "false"
|
|
browserFlow: browser-mfa
|