INCIDENT FIX. Prod's external-dns DELETED EVERY ONE OF DEV'S PUBLIC DNS RECORDS. Both
clusters ran policy: sync against the SAME Cloudflare zone with the SAME implicit
txtOwnerId (`default`), so prod believed it owned dev's records, found no matching sources,
and removed them plus their TXT registry entries. Dev recovered ~30s after prod was scaled
to 0; verified restored against Cloudflare directly (my first check went through a pfSense
override and falsely showed healthy — local resolvers are not evidence about public DNS).
THE MISS WAS MINE, and it was a failure of imagination in how I briefed every agent. I put
"never touch dev" in brief after brief and enforced it — separate kubeconfigs, verified
--limit, sha-checked dev's kubeconfig before and after. Every one of those guards held. NONE
of them covered SHARED EXTERNAL RESOURCES. Prod reconciling its OWN correctly-authored
manifests reached across a third party and destroyed dev's state. Cluster-to-cluster
isolation was verified; the dependency that mattered was cluster-to-Cloudflare.
Operator's architecture: dev -> *.dev.home.keithjackson.dev, prod ->
*.prod.home.keithjackson.dev, apex CNAME <svc>.home -> <svc>.prod.home.
PHASE 1 ONLY (this commit): prod moves to its own subdomain. DEV IS NOT MIGRATED and its
effective hostname stays home.keithjackson.dev, so dev renders byte-identically and nothing
live moves. That alone ends the collision and unblocks prod. Phase 2 (dev -> dev.home. +
apex CNAMEs) is deferred to when the operator is present: it relocates Keycloak's issuer and
12 clients' redirect URIs, forces a rolling k3s restart and kubectl SSO re-issue for every
user, and moves gitea.home. — which is BOTH the GitOps source and the current push target.
BASE_DOMAIN WAS A DEAD KNOB — it existed in cluster-vars but the status report flagged it as
referenced by nothing, because hostnames were hardcoded throughout. Now real: 141 refs.
Searched the AUTHORITATIVE reconciled set by resolving each Flux spec.path transitively via
conformance/lib/status.py — the same resolver the L0 gate uses, so "what Flux reconciles"
has one definition. 185 occurrences across 47 files in 154 reconciled files. Tokenized:
Gateway listeners, the wildcard Certificate, coredns-custom, external-dns, Keycloak
instance/realm (26 redirect URIs), oauth2-proxy, Grafana OIDC, all HTTPRoutes, Homepage's 15
links, 30 runbook_urls, unifi cloud-init, render.py's ZONE constant (which drove
--oidc-issuer-url/--cookie-domain/--whitelist-domain on every forward-auth sidecar) and 11
catalog/ ServiceDefinitions re-rendered canonically. Left alone: 43 comment/prose
occurrences and non-reconciled paths.
TWO THAT CANNOT BE TOKENIZED, flagged not forced:
- headlamp's OIDC URLs live inside a SOPS-encrypted Secret; re-encrypting rewrites the MAC
over every value and the OFFLINE harness cannot verify it (kustomize does not decrypt),
so an error would break live-dev SSO with no pre-flight proof. Prod needs its own copy
anyway — client secrets are realm-scoped.
- kube-apiserver --oidc-issuer-url is a k3s SERVER FLAG, not a manifest, so Flux can never
reach it. Done on the inventory seam instead (k3s_base_domain -> k3s_oidc_issuer_url,
overridden in group_vars/k3s_prod). Verified per host: dev keeps the default, prod gets
prod.home.
EXTERNAL-DNS OWNER, the actual incident cause: txtOwnerId is now explicit per environment —
dev renders exactly `default` (the chart default it was already using, so its existing
records stay recognised) and prod gets `prod`. The BASE default is a NON-COLLIDING SENTINEL,
deliberately not `default`, so a future environment that forgets the knob owns nothing and
can delete nothing rather than silently inheriting dev's identity.
I OVERRODE ONE AGENT DECISION against our own prior finding: it narrowed prod's
domainFilters to prod.home.keithjackson.dev as defence in depth. FINDING F-DNS1
(DECISIONS-LOG 2026-08-17) already proved a subdomain filter DROPS THE PARENT ZONE —
ExternalDNS generates the record then skips it with "no hosted zone matching record DNS
Name". Prod would have published NOTHING. Set to the ZONE (keithjackson.dev) like dev.
Isolation comes from the subdomain split plus txtOwnerId, which is what the incident
actually needed and what a domain filter cannot provide.
DEV PROVEN UNCHANGED: 11 of 12 substituting paths byte-identical; the 12th differs by
exactly one line, `+ txtOwnerId: default` — the value dev was already using implicitly.
POSITIVE CONTROLS confirm the harness is not blind: perturbing BASE_DOMAIN moves 10 of 12
hashes (the 2 that don't provably contain no hostname), perturbing the owner id moves
exactly 1. Prod's rendered output has ZERO home.keithjackson.dev references outside prod.,
and its cert renders *.prod.home.keithjackson.dev — no overlap with dev's.
L0 substitution assertions 106 -> 254; BASE_DOMAIN is no longer flagged dead. Gate PASS.
PROD REMAINS SUSPENDED (platform + 10 downstream tiers) pending review; nothing applied.
Serves: R-ENV-02, R-DNS-01, R-DNS-02, R-DNS-03, R-TLS-01.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
602 lines
26 KiB
YAML
602 lines
26 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
|
|
NEXTCLOUD_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: nextcloud-client-secret
|
|
SEMAPHORE_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: semaphore-client-secret
|
|
HEADLAMP_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: headlamp-client-secret
|
|
CRAFTY_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: crafty-client-secret
|
|
PIHOLE_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: pihole-client-secret
|
|
JACKETT_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: jackett-client-secret
|
|
QBITTORRENT_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: qbittorrent-client-secret
|
|
RADARR_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: radarr-client-secret
|
|
SONARR_CLIENT_SECRET:
|
|
secret:
|
|
name: keycloak-realm-clients
|
|
key: sonarr-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.${BASE_DOMAIN}/login/generic_oauth
|
|
webOrigins:
|
|
- https://grafana.${BASE_DOMAIN}
|
|
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://*.${BASE_DOMAIN}/oauth2/callback
|
|
- https://auth.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://*.${BASE_DOMAIN}
|
|
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
|
|
# offline_access (optional): lets kubelogin request a long-lived OFFLINE refresh token via
|
|
# `--oidc-extra-scope=offline_access`, so the operator's kubectl isn't forced to re-auth on the
|
|
# ONLINE session idle/max timeouts (ssoSessionIdleTimeout 30m / ssoSessionMaxLifespan 10h).
|
|
# Offline tokens honour offlineSessionIdleTimeout (30d) with offlineSessionMaxLifespanEnabled=false
|
|
# (no hard cap) — see k8s-oidc-rbac.yaml header for the kubeconfig exec-arg. Optional (not default)
|
|
# so it is opt-in per the client's request scopes; realm session timeouts stay unchanged.
|
|
optionalClientScopes:
|
|
- offline_access
|
|
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.${BASE_DOMAIN}/user/oauth2/keycloak/callback
|
|
webOrigins:
|
|
- https://gitea.${BASE_DOMAIN}
|
|
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
|
|
# nextcloud — native OIDC SSO for the file-sync host (catalog/nextcloud.yaml auth.mode oidc; docs/13
|
|
# SSO row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is the
|
|
# user_oidc app's callback (/apps/user_oidc/code). The `groups` claim drives group provisioning in
|
|
# Nextcloud (user_oidc --group-provisioning; R-IDP-06). The LOCAL Nextcloud admin stays break-glass
|
|
# (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via NEXTCLOUD_CLIENT_SECRET.
|
|
- clientId: nextcloud
|
|
name: Nextcloud
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:NEXTCLOUD_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://nextcloud.${BASE_DOMAIN}/apps/user_oidc/code
|
|
webOrigins:
|
|
- https://nextcloud.${BASE_DOMAIN}
|
|
# Post-logout redirect allow-list (#logout "redirect failed"). On sign-out user_oidc redirects the
|
|
# browser to Keycloak's end_session_endpoint with post_logout_redirect_uri=<Nextcloud base '/'> and
|
|
# client_id=nextcloud but NO id_token_hint (provider sendIdTokenHint=0), so Keycloak validates the
|
|
# redirect against this client attribute — empty => "Invalid redirect uri" => the operator's
|
|
# "redirect failed". The wildcard covers the base URL and any logout landing path. Keycloak stores
|
|
# this as the client attribute `post.logout.redirect.uris` (## separates multiple values).
|
|
attributes:
|
|
post.logout.redirect.uris: https://nextcloud.${BASE_DOMAIN}/*
|
|
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
|
|
# semaphore — native OIDC SSO for the Ansible UI (catalog/semaphore.yaml auth.mode oidc; docs/13 SSO
|
|
# row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is
|
|
# Semaphore's OIDC callback (/api/auth/oidc/<provider>/redirect for provider `keycloak`). The
|
|
# `groups` claim is exposed for authorization (R-IDP-06). The LOCAL Semaphore admin stays break-glass
|
|
# (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via SEMAPHORE_CLIENT_SECRET.
|
|
- clientId: semaphore
|
|
name: Semaphore
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:SEMAPHORE_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://semaphore.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect
|
|
webOrigins:
|
|
- https://semaphore.${BASE_DOMAIN}
|
|
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
|
|
# 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.${BASE_DOMAIN}/oidc-callback
|
|
webOrigins:
|
|
- https://headlamp.${BASE_DOMAIN}
|
|
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"
|
|
# crafty — the Minecraft management panel (platform/crafty). Crafty's web UI has NO native OIDC,
|
|
# so it is gated by a PER-APP oauth2-proxy REVERSE-PROXY sidecar (Option B; platform/crafty +
|
|
# platform/oauth2-proxy/README.md) — this is that sidecar's confidential auth-code client, modeled
|
|
# on the grafana/oauth2-proxy clients. Redirect URI is the oauth2-proxy callback on Crafty's host.
|
|
# The `groups` claim (bare names) lets the sidecar enforce --allowed-groups=platform-admins
|
|
# (R-IDP-06). Secret injected from the SOPS keycloak-realm-clients Secret via CRAFTY_CLIENT_SECRET
|
|
# (R-IDP-04); the SAME value is mirrored in platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml
|
|
# (cross-namespace mirror, like grafana/gitea/headlamp). Provisioned live via kcadm at authoring
|
|
# time (2026-08-28) and captured here as the durable GitOps source of truth.
|
|
- clientId: crafty
|
|
name: Crafty Controller (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:CRAFTY_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://crafty.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://crafty.${BASE_DOMAIN}
|
|
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
|
|
# pihole — the DNS admin UI (catalog/pihole.yaml auth.mode forward-auth). PiHole's UI has NO
|
|
# native OIDC, so it is gated by a PER-APP oauth2-proxy REVERSE-PROXY sidecar (Option B, rendered
|
|
# by catalog/render.py) — this is that sidecar's confidential auth-code client, modeled on the
|
|
# crafty/oauth2-proxy clients. Redirect URI is the oauth2-proxy callback on PiHole's host. The
|
|
# `groups` claim (bare names) lets the sidecar enforce --allowed-group=platform-admins (R-IDP-06).
|
|
# Secret injected from the SOPS keycloak-realm-clients Secret via PIHOLE_CLIENT_SECRET (R-IDP-04);
|
|
# the SAME value is mirrored in apps/wave-0/pihole-oauth2-proxy-credentials.sops.yaml (cross-
|
|
# namespace mirror). Provisioned live via kcadm at authoring time (2026-08-31) and captured here
|
|
# as the durable GitOps source of truth (the operator imports the realm once; kcadm is authoritative).
|
|
- clientId: pihole
|
|
name: PiHole (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:PIHOLE_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://pihole.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://pihole.${BASE_DOMAIN}
|
|
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
|
|
# jackett/qbittorrent/radarr/sonarr — media stack UIs (catalog/*.yaml auth.mode forward-auth,
|
|
# auth.groups media-users). Each has a bare UI with no usable native OIDC, gated by a per-app
|
|
# oauth2-proxy REVERSE-PROXY sidecar (Option B, rendered by catalog/render.py) — these are those
|
|
# sidecars' confidential auth-code clients, modeled on the crafty/pihole clients. Redirect URI is
|
|
# the oauth2-proxy callback on each app's host; the `groups` mapper (bare names) lets the sidecar
|
|
# enforce --allowed-group=media-users (R-IDP-06). Secrets injected from the SOPS keycloak-realm-
|
|
# clients Secret via the *_CLIENT_SECRET placeholders (R-IDP-04); the SAME values are mirrored in
|
|
# apps/wave-*/<app>-oauth2-proxy-credentials.sops.yaml. Provisioned live via kcadm 2026-08-31 and
|
|
# captured here as the durable GitOps source of truth (operator imports once; kcadm authoritative).
|
|
- clientId: jackett
|
|
name: Jackett (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:JACKETT_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://jackett.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://jackett.${BASE_DOMAIN}
|
|
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
|
|
- clientId: qbittorrent
|
|
name: qBittorrent (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:QBITTORRENT_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://qbittorrent.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://qbittorrent.${BASE_DOMAIN}
|
|
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
|
|
- clientId: radarr
|
|
name: Radarr (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:RADARR_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://radarr.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://radarr.${BASE_DOMAIN}
|
|
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
|
|
- clientId: sonarr
|
|
name: Sonarr (oauth2-proxy)
|
|
protocol: openid-connect
|
|
enabled: true
|
|
publicClient: false
|
|
standardFlowEnabled: true
|
|
directAccessGrantsEnabled: false
|
|
secret: "$(env:SONARR_CLIENT_SECRET)"
|
|
redirectUris:
|
|
- https://sonarr.${BASE_DOMAIN}/oauth2/callback
|
|
webOrigins:
|
|
- https://sonarr.${BASE_DOMAIN}
|
|
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:
|
|
# Keycloak's conditional-user-role authenticator reads the role from `condUserRole`
|
|
# (ConditionalUserRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE). The prior key
|
|
# "condition.evaluated.user.role" is not a real property, so the role resolved to null →
|
|
# "Invalid role name submitted: null" → the REQUIRED conditional-OTP step threw and blocked
|
|
# every platform-admins login (e.g. Headlamp) with a 403. Value is the realm role name.
|
|
condUserRole: platform-admin
|
|
negate: "false"
|
|
browserFlow: browser-mfa
|