diff --git a/ci/render-keycloak-client-urls.py b/ci/render-keycloak-client-urls.py new file mode 100755 index 0000000..266ca82 --- /dev/null +++ b/ci/render-keycloak-client-urls.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# ============================================================================= +# ci/render-keycloak-client-urls.py +# +# Render platform/keycloak/client-urls.yaml FROM platform/keycloak/keycloak-realm-import.yaml. +# +# WHY THIS EXISTS +# --------------- +# The Keycloak realm import is a ONE-SHOT that fails OPEN: once it rests at +# Done=True / Started=False it never re-runs, so a redirect URI changed in git is +# never applied to the realm. The client-secret half of that failure class is +# already healed by a level-triggered CronJob (platform/keycloak/client-secret- +# reconciler.yaml). This renderer supplies the DESIRED STATE that the same CronJob +# needs to heal the URL half. +# +# THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with +# automountServiceAccountToken: false. It has NO Kubernetes API access at all, by +# design (R-SEC-02) — so it cannot read the KeycloakRealmImport CR to learn what +# the realm is supposed to look like. Its inputs must be MOUNTED. Hence a +# ConfigMap. +# +# A hand-authored ConfigMap would be a SECOND SOURCE OF TRUTH for every public +# hostname in the realm, free to disagree with the realm import — and "two +# declarations that can disagree" is the same shape as the bug this whole effort +# is closing. So the ConfigMap is GENERATED from the realm import and the +# generation is GATED: conformance/tests/l0-static/keycloak-client-secret- +# reconciler/check.sh runs this script with --check on every merge, and the merge +# fails if the committed file is not what this renderer produces. That is the same +# render==golden idiom catalog/render.py + ci/render-golden.sh already use for +# apps/. +# +# STRINGS ARE COPIED VERBATIM, NEVER RE-DERIVED. If a URL in the realm import is +# written as a Flux substitution token (https://grafana.${BASE_DOMAIN}/...), the +# token is carried through into the ConfigMap CHARACTER FOR CHARACTER. Both files +# are rendered by the SAME Flux Kustomization (./platform-config, whose +# postBuild.substituteFrom reads cluster-vars), so both are substituted in the +# same pass with the same values and cannot disagree about what BASE_DOMAIN is. +# This renderer never parses, splits, or reconstructs a hostname, and never reads +# cluster-vars — so there is no second place for the domain to be decided. +# +# USAGE +# python3 ci/render-keycloak-client-urls.py # print the rendered file +# python3 ci/render-keycloak-client-urls.py --write # write it (regenerate) +# python3 ci/render-keycloak-client-urls.py --check # exit 1 if it has drifted +# ============================================================================= +from __future__ import annotations + +import argparse +import difflib +import json +import sys +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover + print("FATAL: PyYAML is required (same dependency as catalog/render.py)", + file=sys.stderr) + raise SystemExit(1) + +ROOT = Path(__file__).resolve().parent.parent +IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml" +OUT_FILE = "platform/keycloak/client-urls.yaml" +DATA_KEY = "client-urls.json" +CONFIGMAP_NAME = "keycloak-client-urls" +NAMESPACE = "keycloak" + +# The client fields this renderer exports, and therefore the ONLY fields the +# reconciler may write. Every one of them is an environment-derived URL: it changes +# when BASE_DOMAIN changes, and a stale value breaks OIDC login (a wrong +# redirectUri) or silently widens trust (a stale wildcard left behind). +# +# DELIBERATELY ABSENT: `secret` (healed by the secret pass from the SOPS Secret, +# and it must never appear in a ConfigMap), and every non-URL field — +# protocolMappers, defaultClientScopes, attributes, flags. Those are not +# environment-derived, so nothing re-renders them on a domain flip; keeping them +# out keeps the reconciler's write surface as narrow as the problem. +LIST_FIELDS = ("redirectUris", "webOrigins") +STR_FIELDS = ("rootUrl", "baseUrl", "adminUrl") + + +def render(root: Path) -> str: + """Return the full text of client-urls.yaml as it should be committed.""" + docs = list(yaml.safe_load_all((root / IMPORT_FILE).read_text())) + imp = next((d for d in docs if d and d.get("kind") == "KeycloakRealmImport"), None) + if imp is None: + raise SystemExit("FATAL: no KeycloakRealmImport document in %s" % IMPORT_FILE) + clients = (imp.get("spec") or {}).get("realm", {}).get("clients") or [] + if not clients: + # Fail closed. Emitting an empty desired state would make the reconciler + # believe every client is unmanaged and silently stop reconciling URLs. + raise SystemExit("FATAL: %s declares zero clients — refusing to render an " + "empty desired state" % IMPORT_FILE) + + want: dict[str, dict] = {} + for c in clients: + cid = c.get("clientId") + if not isinstance(cid, str) or not cid: + raise SystemExit("FATAL: %s has a client with no clientId" % IMPORT_FILE) + entry: dict[str, object] = {} + for f in LIST_FIELDS: + if f not in c: + continue # not declared => NOT MANAGED (never forced empty) + v = c[f] + if not isinstance(v, list) or not all(isinstance(x, str) for x in v): + raise SystemExit("FATAL: %s client %r field %s is not a list of strings" + % (IMPORT_FILE, cid, f)) + entry[f] = list(v) # verbatim, order as authored + for f in STR_FIELDS: + if f not in c: + continue + v = c[f] + if not isinstance(v, str): + raise SystemExit("FATAL: %s client %r field %s is not a string" + % (IMPORT_FILE, cid, f)) + entry[f] = v + if entry: + want[cid] = entry + if not want: + raise SystemExit("FATAL: no client in %s declares any of %s — refusing to render " + "an empty desired state" + % (IMPORT_FILE, ", ".join(LIST_FIELDS + STR_FIELDS))) + + body = json.dumps(want, indent=2, sort_keys=True, ensure_ascii=False) + indented = "\n".join((" " + ln) if ln else "" for ln in body.splitlines()) + return HEADER + indented + "\n" + + +HEADER = """--- +# ============================================================================= +# GENERATED FILE — DO NOT EDIT BY HAND. +# +# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm.clients) +# regenerate: python3 ci/render-keycloak-client-urls.py --write +# gated by: conformance/tests/l0-static/keycloak-client-secret-reconciler +# (runs this renderer with --check on every merge) +# +# WHAT THIS IS: the DESIRED STATE for every environment-derived client URL in the +# `homelab` realm — redirectUris, webOrigins, rootUrl, baseUrl, adminUrl. The +# client-secret reconciler CronJob mounts it and makes the live realm match it. +# +# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs +# once Done=True, so a redirect URI edited in git never reaches the realm. Prod's +# stored redirect URIs were measured still pointing at the pre-migration hostname +# while git declared the new one, and every OIDC login would have failed with a +# redirect_uri mismatch. The reconciler is the level-triggered loop that closes +# that; this file is what it reconciles TOWARDS. +# +# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of every public +# hostname would be a second source of truth, free to disagree with the realm +# import. It is rendered from the import and the render is gated, so the two +# cannot drift. URL strings are copied VERBATIM — if the source uses a +# ${BASE_DOMAIN} token it appears here unchanged, and both files are substituted +# by the SAME Flux Kustomization (./platform-config), so the domain is decided in +# exactly one place. +# +# A field that is NOT declared on a client in the realm import is simply ABSENT +# here, and the reconciler leaves it alone. Absence means UNMANAGED, never +# "force to empty". +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: %s + namespace: %s + labels: + app.kubernetes.io/name: %s + app.kubernetes.io/part-of: keycloak +data: + %s: | +""" % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME, DATA_KEY) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + g = ap.add_mutually_exclusive_group() + g.add_argument("--write", action="store_true", help="write %s" % OUT_FILE) + g.add_argument("--check", action="store_true", + help="exit 1 if the committed file has drifted from the source") + args = ap.parse_args() + + want = render(ROOT) + target = ROOT / OUT_FILE + + if args.write: + target.write_text(want, encoding="utf-8") + print("wrote %s" % OUT_FILE) + return 0 + + if args.check: + if not target.is_file(): + print("CHECK FAIL: %s does not exist. Run: python3 " + "ci/render-keycloak-client-urls.py --write" % OUT_FILE, file=sys.stderr) + return 1 + have = target.read_text(encoding="utf-8") + if have == want: + print("client-urls.yaml is in sync with %s" % IMPORT_FILE) + return 0 + print("CHECK FAIL: %s has DRIFTED from %s — the reconciler would converge the " + "realm on a desired state the realm import does not declare.\n" + "Regenerate with: python3 ci/render-keycloak-client-urls.py --write\n" + % (OUT_FILE, IMPORT_FILE), file=sys.stderr) + for line in difflib.unified_diff(have.splitlines(True), want.splitlines(True), + fromfile="committed " + OUT_FILE, + tofile="rendered from " + IMPORT_FILE): + sys.stderr.write(line) + return 1 + + sys.stdout.write(want) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/platform-config/kustomization.yaml b/platform-config/kustomization.yaml index 6726e29..6d5cfed 100644 --- a/platform-config/kustomization.yaml +++ b/platform-config/kustomization.yaml @@ -93,6 +93,17 @@ resources: # so it applies after the keycloak-realm-clients Secret (rendered by ./platform) and # alongside the import whose failure mode it covers. - ../platform/keycloak/client-secret-reconciler.yaml + # The DESIRED STATE for every environment-derived client URL (redirectUris, webOrigins, + # rootUrl, baseUrl, adminUrl), GENERATED from keycloak-realm-import.yaml by + # ci/render-keycloak-client-urls.py and gated by the L0 check. The reconciler pod has no + # Kubernetes API access (automountServiceAccountToken: false), so it cannot read the + # KeycloakRealmImport CR — its desired state must be MOUNTED, hence this ConfigMap. + # + # IT MUST BE RENDERED BY THIS SAME KUSTOMIZATION as the realm import above: both are then + # substituted in one pass by this Kustomization's postBuild.substituteFrom (cluster-vars), + # so a ${BASE_DOMAIN} token in either resolves to the same value and the two declarations + # cannot disagree about the domain. + - ../platform/keycloak/client-urls.yaml - ../platform/keycloak/k8s-oidc-rbac.yaml # maps OIDC groups -> cluster roles (kubectl SSO RBAC) - ../platform/keycloak/httproute.yaml - ../platform/keycloak/servicemonitor.yaml diff --git a/platform/keycloak/client-urls.yaml b/platform/keycloak/client-urls.yaml new file mode 100644 index 0000000..8fe5123 --- /dev/null +++ b/platform/keycloak/client-urls.yaml @@ -0,0 +1,157 @@ +--- +# ============================================================================= +# GENERATED FILE — DO NOT EDIT BY HAND. +# +# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm.clients) +# regenerate: python3 ci/render-keycloak-client-urls.py --write +# gated by: conformance/tests/l0-static/keycloak-client-secret-reconciler +# (runs this renderer with --check on every merge) +# +# WHAT THIS IS: the DESIRED STATE for every environment-derived client URL in the +# `homelab` realm — redirectUris, webOrigins, rootUrl, baseUrl, adminUrl. The +# client-secret reconciler CronJob mounts it and makes the live realm match it. +# +# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs +# once Done=True, so a redirect URI edited in git never reaches the realm. Prod's +# stored redirect URIs were measured still pointing at the pre-migration hostname +# while git declared the new one, and every OIDC login would have failed with a +# redirect_uri mismatch. The reconciler is the level-triggered loop that closes +# that; this file is what it reconciles TOWARDS. +# +# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of every public +# hostname would be a second source of truth, free to disagree with the realm +# import. It is rendered from the import and the render is gated, so the two +# cannot drift. URL strings are copied VERBATIM — if the source uses a +# ${BASE_DOMAIN} token it appears here unchanged, and both files are substituted +# by the SAME Flux Kustomization (./platform-config), so the domain is decided in +# exactly one place. +# +# A field that is NOT declared on a client in the realm import is simply ABSENT +# here, and the reconciler leaves it alone. Absence means UNMANAGED, never +# "force to empty". +# ============================================================================= +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycloak-client-urls + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak-client-urls + app.kubernetes.io/part-of: keycloak +data: + client-urls.json: | + { + "crafty": { + "redirectUris": [ + "https://crafty.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://crafty.${BASE_DOMAIN}" + ] + }, + "gitea": { + "redirectUris": [ + "https://gitea.${BASE_DOMAIN}/user/oauth2/keycloak/callback" + ], + "webOrigins": [ + "https://gitea.${BASE_DOMAIN}" + ] + }, + "grafana": { + "redirectUris": [ + "https://grafana.${BASE_DOMAIN}/login/generic_oauth" + ], + "webOrigins": [ + "https://grafana.${BASE_DOMAIN}" + ] + }, + "headlamp": { + "redirectUris": [ + "https://headlamp.${BASE_DOMAIN}/oidc-callback" + ], + "webOrigins": [ + "https://headlamp.${BASE_DOMAIN}" + ] + }, + "jackett": { + "redirectUris": [ + "https://jackett.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://jackett.${BASE_DOMAIN}" + ] + }, + "kubernetes": { + "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" + ] + }, + "nextcloud": { + "redirectUris": [ + "https://nextcloud.${BASE_DOMAIN}/apps/user_oidc/code" + ], + "webOrigins": [ + "https://nextcloud.${BASE_DOMAIN}" + ] + }, + "oauth2-proxy": { + "redirectUris": [ + "https://*.${BASE_DOMAIN}/oauth2/callback", + "https://auth.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://*.${BASE_DOMAIN}" + ] + }, + "pihole": { + "redirectUris": [ + "https://pihole.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://pihole.${BASE_DOMAIN}" + ] + }, + "qbittorrent": { + "redirectUris": [ + "https://qbittorrent.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://qbittorrent.${BASE_DOMAIN}" + ] + }, + "radarr": { + "redirectUris": [ + "https://radarr.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://radarr.${BASE_DOMAIN}" + ] + }, + "semaphore": { + "redirectUris": [ + "https://semaphore.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect" + ], + "webOrigins": [ + "https://semaphore.${BASE_DOMAIN}" + ] + }, + "sonarr": { + "redirectUris": [ + "https://sonarr.${BASE_DOMAIN}/oauth2/callback" + ], + "webOrigins": [ + "https://sonarr.${BASE_DOMAIN}" + ] + } + }