From 3d8a965718bda24ef796cd0d9a0e856e25d0f5b5 Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:01:55 -0700 Subject: [PATCH] =?UTF-8?q?refactor(iam):=20extract=20provision=20python?= =?UTF-8?q?=20to=20scripts/authentik-provision.py=20+=20fix=20app-list=20i?= =?UTF-8?q?dempotency=20=E2=80=94=20configMapGenerator=20(stable=20name)?= =?UTF-8?q?=20replaces=20inline=20script;=20superuser=5Ffull=5Flist=3Dtrue?= =?UTF-8?q?=20stops=20the=20400=20that=20aborted=20grant=5Ftypes=20patchin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k8s/security/iam/authentik-provision-job.yaml | 356 +----------------- k8s/security/iam/kustomization.yaml | 14 + .../iam/scripts/authentik-provision.py | 348 +++++++++++++++++ 3 files changed, 366 insertions(+), 352 deletions(-) create mode 100644 k8s/security/iam/scripts/authentik-provision.py diff --git a/k8s/security/iam/authentik-provision-job.yaml b/k8s/security/iam/authentik-provision-job.yaml index 840f6d9..ca65c8e 100644 --- a/k8s/security/iam/authentik-provision-job.yaml +++ b/k8s/security/iam/authentik-provision-job.yaml @@ -4,365 +4,17 @@ # cluster does — no separate manual bootstrap step like setup_talos_iam.sh / # provision_oidc.py, which never got migrated off the old helmfile workflow). # -# What it does (see the embedded script's docstring below): creates the +# What it does (see scripts/authentik-provision.py docstring): creates the # "groups" scope mapping, homelab-admins / grafana-admins groups, the "rock" # admin user, OAuth2 providers + Applications for grafana/minio/forgejo/argocd, -# and binds homelab-admins to all of them. +# and binds homelab-admins to all of them. The script is generated into the +# authentik-provision-script ConfigMap by kustomize configMapGenerator (see +# kustomization.yaml), not embedded here. # # RBAC: this Job only touches Secrets (get existing client secrets, create new # ones for forgejo/argocd/rock) across the namespaces those services live in. # It never touches any other resource type. apiVersion: v1 -kind: ConfigMap -metadata: - name: authentik-provision-script - namespace: iam -data: - authentik-provision.py: | - #!/usr/bin/env python3 - """ - Authentik OAuth provisioning - idempotent, safe to re-run (ArgoCD PostSync hook). - - Creates/updates, in order: - 1. A custom "groups" OAuth2 scope mapping (Authentik ships openid/email/profile - by default but NOT groups - required for ArgoCD RBAC group mapping and - Grafana's role_attribute_path, both of which read a `groups` claim). - 2. Groups: homelab-admins (is_superuser=true), grafana-admins. - 3. User "rock": created if missing, always (re-)synced into both groups above. - Password is generated once and only written to the k8s Secret - rock-credentials (iam ns) the first time the user is created - re-runs - never rotate an existing password. - 4. OAuth2/OIDC providers + Applications for: grafana, minio, forgejo, argocd. - Client secrets are read from existing k8s Secrets (grafana-oidc, minio-oidc) - if present, or generated once and written out (forgejo-oidc, oidc-secret) - the first time. - 5. PolicyBinding of homelab-admins -> every Application above, so "rock" (and - anyone else in that group) has guaranteed access regardless of each app's - default visibility. - - Talks to Authentik over the in-cluster Service (authentik-server.iam.svc:80), - authenticating with the bootstrap token. Everything is done with GET-then- - create-or-patch so this can be re-run on every ArgoCD sync without duplicating - or clobbering objects (PostSync hook, not a one-shot Job with hook-delete). - - kubectl is used only to read/write the small set of Secrets this script - touches - it shells out rather than using the Python k8s client to keep the - container image to stdlib Python + the kubectl binary, no pip installs. - """ - import json - import os - import secrets - import string - import subprocess - import sys - import urllib.error - import urllib.request - - AUTHENTIK_URL = "http://authentik-server.iam.svc.cluster.local" - TOKEN = os.environ["AUTHENTIK_BOOTSTRAP_TOKEN"] - - - def api(method, path, data=None): - url = f"{AUTHENTIK_URL}{path}" - body = json.dumps(data).encode() if data is not None else None - req = urllib.request.Request( - url, - data=body, - method=method, - headers={ - "Authorization": f"Bearer {TOKEN}", - "Content-Type": "application/json", - }, - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read() - return resp.status, (json.loads(raw) if raw else {}) - except urllib.error.HTTPError as e: - raw = e.read() - try: - parsed = json.loads(raw) if raw else {} - except json.JSONDecodeError: - parsed = {"raw": raw.decode(errors="replace")} - return e.code, parsed - - - def die(msg): - print(f"FATAL: {msg}", file=sys.stderr) - sys.exit(1) - - - def gen_secret(n=40): - alphabet = string.ascii_letters + string.digits - return "".join(secrets.choice(alphabet) for _ in range(n)) - - - def kubectl_get_secret_key(namespace, name, key): - """Returns decoded value, or None if the secret/key doesn't exist.""" - p = subprocess.run( - ["kubectl", "-n", namespace, "get", "secret", name, "-o", f"jsonpath={{.data.{key}}}"], - capture_output=True, text=True, - ) - if p.returncode != 0 or not p.stdout.strip(): - return None - import base64 - return base64.b64decode(p.stdout).decode() - - - def kubectl_create_secret(namespace, name, literals: dict): - """Idempotent: create-or-update via dry-run|apply, same pattern used - elsewhere in this repo (setup_vault.sh, apply-vault-secrets.sh).""" - args = ["kubectl", "-n", namespace, "create", "secret", "generic", name] - for k, v in literals.items(): - args += [f"--from-literal={k}={v}"] - args += ["--dry-run=client", "-o", "yaml"] - render = subprocess.run(args, capture_output=True, text=True) - if render.returncode != 0: - die(f"rendering secret {namespace}/{name}: {render.stderr}") - apply = subprocess.run(["kubectl", "apply", "-f", "-"], input=render.stdout, - capture_output=True, text=True) - if apply.returncode != 0: - die(f"applying secret {namespace}/{name}: {apply.stderr}") - print(f" secret {namespace}/{name}: {apply.stdout.strip()}") - - - def get_or_create(list_path, create_path, query, payload, patch_existing=None): - status, res = api("GET", f"{list_path}?{query}") - if status != 200: - die(f"GET {list_path}?{query} -> {status} {res}") - results = res.get("results", []) - if results: - obj = results[0] - if patch_existing: - status, obj2 = api("PATCH", f"{create_path}{obj['pk']}/", patch_existing) - if status not in (200, 201): - die(f"PATCH {create_path}{obj['pk']}/ -> {status} {obj2}") - return obj2 - return obj - status, obj = api("POST", create_path, payload) - if status not in (200, 201): - die(f"POST {create_path} -> {status} {obj}") - return obj - - - # ----------------------------------------------------------------------------- - print("[1/5] Ensuring custom 'groups' scope mapping exists...") - groups_mapping = get_or_create( - "/api/v3/propertymappings/provider/scope/", - "/api/v3/propertymappings/provider/scope/", - "scope_name=groups", - { - "name": "homelab: groups claim", - "scope_name": "groups", - # request.user.ak_groups is deprecated in authentik 2026.x (logs a - # deprecation warning on every token issue) -> use request.user.groups. - "expression": ( - "return {\"groups\": [group.name for group in request.user.groups.all()]}" - ), - }, - # Force the expression onto the already-created mapping on re-run. - patch_existing={ - "expression": ( - "return {\"groups\": [group.name for group in request.user.groups.all()]}" - ), - }, - ) - GROUPS_MAPPING_PK = groups_mapping["pk"] - - # Fetch the standard openid/email/profile mapping pks (shipped by default). - status, res = api("GET", "/api/v3/propertymappings/provider/scope/") - by_scope = {m["scope_name"]: m["pk"] for m in res["results"]} - SCOPE_PKS = [by_scope["openid"], by_scope["email"], by_scope["profile"], GROUPS_MAPPING_PK] - - status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent") - AUTHORIZATION_FLOW_PK = res["results"][0]["pk"] - status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-invalidation-flow") - INVALIDATION_FLOW_PK = res["results"][0]["pk"] - status, res = api("GET", "/api/v3/crypto/certificatekeypairs/?has_key=true") - SIGNING_KEY_PK = res["results"][0]["pk"] - - # ----------------------------------------------------------------------------- - print("[2/5] Ensuring groups homelab-admins / grafana-admins exist...") - homelab_admins = get_or_create( - "/api/v3/core/groups/", "/api/v3/core/groups/", - "name=homelab-admins", - {"name": "homelab-admins", "is_superuser": True}, - ) - grafana_admins = get_or_create( - "/api/v3/core/groups/", "/api/v3/core/groups/", - "name=grafana-admins", - {"name": "grafana-admins", "is_superuser": False}, - ) - - # ----------------------------------------------------------------------------- - print("[3/5] Ensuring user 'rock' exists with admin group membership...") - status, res = api("GET", "/api/v3/core/users/?username=rock") - rock_password = None - if res.get("results"): - rock = res["results"][0] - status, rock = api("PATCH", f"/api/v3/core/users/{rock['pk']}/", { - "groups": [homelab_admins["pk"], grafana_admins["pk"]], - "is_active": True, - }) - if status not in (200, 201): - die(f"PATCH user rock -> {status} {rock}") - print(" rock already exists, group membership synced (password unchanged)") - else: - rock_password = gen_secret(24) - status, rock = api("POST", "/api/v3/core/users/", { - "username": "rock", - "name": "Rock", - "is_active": True, - "groups": [homelab_admins["pk"], grafana_admins["pk"]], - "path": "users", - "type": "internal", - }) - if status not in (200, 201): - die(f"POST user rock -> {status} {rock}") - status, pw_res = api("POST", f"/api/v3/core/users/{rock['pk']}/set_password/", - {"password": rock_password}) - if status not in (200, 204): - die(f"set_password for rock -> {status} {pw_res}") - kubectl_create_secret("iam", "rock-credentials", { - "username": "rock", - "password": rock_password, - }) - print(" rock created, credentials stored in iam/rock-credentials") - - # ----------------------------------------------------------------------------- - print("[4/5] Ensuring OAuth2 providers + applications for grafana/minio/forgejo/argocd...") - - SERVICES = { - "grafana": { - "client_secret_source": ("logging", "grafana-oidc", "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"), - "redirect_uris": ["https://grafana.riotpiao.com/login/generic_oauth"], - "launch_url": "https://grafana.riotpiao.com", - "display_name": "Grafana", - }, - "minio": { - "client_secret_source": ("storage", "minio-oidc", "MINIO_IDENTITY_OPENID_CLIENT_SECRET"), - "redirect_uris": ["https://minio.riotpiao.com/oauth_callback"], - "launch_url": "https://minio.riotpiao.com", - "display_name": "MinIO", - }, - "forgejo": { - # No secret exists yet for forgejo - generate + store on first run. - "client_secret_source": ("cicd", "forgejo-oidc", "CLIENT_SECRET"), - "generate_if_missing": True, - "redirect_uris": [ - "https://forgejo.riotpiao.com/user/oauth2/authentik/callback", - "https://forgejo.riotpiao.com/user/oauth2/openidconnect/callback", - ], - "launch_url": "https://forgejo.riotpiao.com", - "display_name": "Forgejo", - }, - "argocd": { - # oidc-secret uses hyphenated keys (client-id/client-secret) per - # argocd-values.yaml's `$oidc-secret:client-id` / `:client-secret` refs. - "client_secret_source": ("argocd", "oidc-secret", "client-secret"), - "generate_if_missing": True, - "extra_secret_literals": {"client-id": "argocd"}, - "redirect_uris": ["https://argocd.riotpiao.com/auth/callback"], - "launch_url": "https://argocd.riotpiao.com", - "display_name": "Argo CD", - }, - } - - app_pks_for_binding = [] - - for name, cfg in SERVICES.items(): - ns, secret_name, key = cfg["client_secret_source"] - client_secret = kubectl_get_secret_key(ns, secret_name, key) - if client_secret is None: - if not cfg.get("generate_if_missing"): - print(f" WARNING: {ns}/{secret_name} key {key} not found and " - f"generate_if_missing not set for '{name}' - skipping provider/app") - continue - client_secret = gen_secret(40) - literals = {key: client_secret} - literals.update(cfg.get("extra_secret_literals", {})) - kubectl_create_secret(ns, secret_name, literals) - print(f" {name}: generated new client secret -> {ns}/{secret_name}") - else: - print(f" {name}: using existing client secret from {ns}/{secret_name}") - - provider = get_or_create( - "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", - f"name={name}", - { - "name": name, - "client_id": name, - "client_secret": client_secret, - "client_type": "confidential", - "authorization_flow": AUTHORIZATION_FLOW_PK, - "invalidation_flow": INVALIDATION_FLOW_PK, - "signing_key": SIGNING_KEY_PK, - "property_mappings": SCOPE_PKS, - "sub_mode": "hashed_user_id", - "include_claims_in_id_token": True, - # authentik 2026.x requires grant_types to be set explicitly; the - # API defaults it to [] when omitted, which makes /authorize reject - # every login with "Invalid grant_type for provider" -> - # invalid_request. authorization_code = the web SSO flow all these - # apps use; refresh_token = long-lived sessions (offline_access). - "grant_types": ["authorization_code", "refresh_token"], - "redirect_uris": [ - {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"] - ], - }, - # Keep the redirect_uris/mappings/grant_types in sync on re-run, but - # never touch client_secret again once created (that's the source of - # truth in the k8s Secret, and re-sending it here is harmless anyway). - patch_existing={ - "property_mappings": SCOPE_PKS, - "grant_types": ["authorization_code", "refresh_token"], - "redirect_uris": [ - {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"] - ], - }, - ) - - application = get_or_create( - "/api/v3/core/applications/", "/api/v3/core/applications/", - f"slug={name}", - { - "name": cfg["display_name"], - "slug": name, - "provider": provider["pk"], - "meta_launch_url": cfg["launch_url"], - }, - patch_existing={ - "provider": provider["pk"], - "meta_launch_url": cfg["launch_url"], - }, - ) - app_pks_for_binding.append((name, application["pk"])) - print(f" {name}: provider pk={provider['pk']} application pk={application['pk']}") - - # ----------------------------------------------------------------------------- - print("[5/5] Binding homelab-admins to every application (guaranteed access for rock)...") - for name, app_pk in app_pks_for_binding: - get_or_create( - "/api/v3/policies/bindings/", "/api/v3/policies/bindings/", - f"target={app_pk}&group={homelab_admins['pk']}", - { - "target": app_pk, - "group": homelab_admins["pk"], - "order": 0, - "enabled": True, - }, - ) - print(f" {name}: homelab-admins bound") - - print("\nDone. Summary:") - print(" groups: homelab-admins (superuser), grafana-admins") - print(" user: rock -> homelab-admins + grafana-admins") - print(f" apps: {', '.join(n for n, _ in app_pks_for_binding)}") - if rock_password: - print(" NOTE: rock's password was generated this run - see") - print(" kubectl -n iam get secret rock-credentials -o jsonpath='{.data.password}' | base64 -d") ---- -apiVersion: v1 kind: ServiceAccount metadata: name: authentik-provisioner diff --git a/k8s/security/iam/kustomization.yaml b/k8s/security/iam/kustomization.yaml index 833d917..0d760b5 100644 --- a/k8s/security/iam/kustomization.yaml +++ b/k8s/security/iam/kustomization.yaml @@ -14,6 +14,20 @@ kind: Kustomization resources: - key-rotation-cronjob.yaml - authentik-provision-job.yaml + +# Provisioning/verification python lives in scripts/*.py (real files, linted + +# diff-friendly) and is generated into ConfigMaps here rather than embedded in +# the job YAML. disableNameSuffixHash keeps the names stable so the Jobs' +# configMap volume refs and PostSync hook-delete semantics keep working; each +# hook Job is recreated per sync so it always mounts the latest script. +configMapGenerator: + - name: authentik-provision-script + namespace: iam + files: + - authentik-provision.py=scripts/authentik-provision.py + +generatorOptions: + disableNameSuffixHash: true # authentik-migrations-job.yaml removed — redundant + broken. The authentik # `server` entrypoint runs migrations itself; this standalone job lacked the # authentik-secrets envFrom (Secret key missing) and always failed. diff --git a/k8s/security/iam/scripts/authentik-provision.py b/k8s/security/iam/scripts/authentik-provision.py new file mode 100644 index 0000000..fab4cfa --- /dev/null +++ b/k8s/security/iam/scripts/authentik-provision.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +""" +Authentik OAuth provisioning - idempotent, safe to re-run (ArgoCD PostSync hook). + +Creates/updates, in order: + 1. A custom "groups" OAuth2 scope mapping (Authentik ships openid/email/profile + by default but NOT groups - required for ArgoCD RBAC group mapping and + Grafana's role_attribute_path, both of which read a `groups` claim). + 2. Groups: homelab-admins (is_superuser=true), grafana-admins. + 3. User "rock": created if missing, always (re-)synced into both groups above. + Password is generated once and only written to the k8s Secret + rock-credentials (iam ns) the first time the user is created - re-runs + never rotate an existing password. + 4. OAuth2/OIDC providers + Applications for: grafana, minio, forgejo, argocd. + Client secrets are read from existing k8s Secrets (grafana-oidc, minio-oidc) + if present, or generated once and written out (forgejo-oidc, oidc-secret) + the first time. + 5. PolicyBinding of homelab-admins -> every Application above, so "rock" (and + anyone else in that group) has guaranteed access regardless of each app's + default visibility. + +Talks to Authentik over the in-cluster Service (authentik-server.iam.svc:80), +authenticating with the bootstrap token. Everything is done with GET-then- +create-or-patch so this can be re-run on every ArgoCD sync without duplicating +or clobbering objects (PostSync hook, not a one-shot Job with hook-delete). + +kubectl is used only to read/write the small set of Secrets this script +touches - it shells out rather than using the Python k8s client to keep the +container image to stdlib Python + the kubectl binary, no pip installs. +""" +import json +import os +import secrets +import string +import subprocess +import sys +import urllib.error +import urllib.request + +AUTHENTIK_URL = "http://authentik-server.iam.svc.cluster.local" +TOKEN = os.environ["AUTHENTIK_BOOTSTRAP_TOKEN"] + + +def api(method, path, data=None): + url = f"{AUTHENTIK_URL}{path}" + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request( + url, + data=body, + method=method, + headers={ + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + return resp.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as e: + raw = e.read() + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError: + parsed = {"raw": raw.decode(errors="replace")} + return e.code, parsed + + +def die(msg): + print(f"FATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +def gen_secret(n=40): + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +def kubectl_get_secret_key(namespace, name, key): + """Returns decoded value, or None if the secret/key doesn't exist.""" + p = subprocess.run( + ["kubectl", "-n", namespace, "get", "secret", name, "-o", f"jsonpath={{.data.{key}}}"], + capture_output=True, text=True, + ) + if p.returncode != 0 or not p.stdout.strip(): + return None + import base64 + return base64.b64decode(p.stdout).decode() + + +def kubectl_create_secret(namespace, name, literals: dict): + """Idempotent: create-or-update via dry-run|apply, same pattern used + elsewhere in this repo (setup_vault.sh, apply-vault-secrets.sh).""" + args = ["kubectl", "-n", namespace, "create", "secret", "generic", name] + for k, v in literals.items(): + args += [f"--from-literal={k}={v}"] + args += ["--dry-run=client", "-o", "yaml"] + render = subprocess.run(args, capture_output=True, text=True) + if render.returncode != 0: + die(f"rendering secret {namespace}/{name}: {render.stderr}") + apply = subprocess.run(["kubectl", "apply", "-f", "-"], input=render.stdout, + capture_output=True, text=True) + if apply.returncode != 0: + die(f"applying secret {namespace}/{name}: {apply.stderr}") + print(f" secret {namespace}/{name}: {apply.stdout.strip()}") + + +def get_or_create(list_path, create_path, query, payload, patch_existing=None): + status, res = api("GET", f"{list_path}?{query}") + if status != 200: + die(f"GET {list_path}?{query} -> {status} {res}") + results = res.get("results", []) + if results: + obj = results[0] + if patch_existing: + status, obj2 = api("PATCH", f"{create_path}{obj['pk']}/", patch_existing) + if status not in (200, 201): + die(f"PATCH {create_path}{obj['pk']}/ -> {status} {obj2}") + return obj2 + return obj + status, obj = api("POST", create_path, payload) + if status not in (200, 201): + die(f"POST {create_path} -> {status} {obj}") + return obj + + +# ----------------------------------------------------------------------------- +print("[1/5] Ensuring custom 'groups' scope mapping exists...") +groups_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=groups", + { + "name": "homelab: groups claim", + "scope_name": "groups", + # request.user.ak_groups is deprecated in authentik 2026.x (logs a + # deprecation warning on every token issue) -> use request.user.groups. + "expression": ( + "return {\"groups\": [group.name for group in request.user.groups.all()]}" + ), + }, + # Force the expression onto the already-created mapping on re-run. + patch_existing={ + "expression": ( + "return {\"groups\": [group.name for group in request.user.groups.all()]}" + ), + }, +) +GROUPS_MAPPING_PK = groups_mapping["pk"] + +# Fetch the standard openid/email/profile mapping pks (shipped by default). +status, res = api("GET", "/api/v3/propertymappings/provider/scope/") +by_scope = {m["scope_name"]: m["pk"] for m in res["results"]} +SCOPE_PKS = [by_scope["openid"], by_scope["email"], by_scope["profile"], GROUPS_MAPPING_PK] + +status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent") +AUTHORIZATION_FLOW_PK = res["results"][0]["pk"] +status, res = api("GET", "/api/v3/flows/instances/?slug=default-provider-invalidation-flow") +INVALIDATION_FLOW_PK = res["results"][0]["pk"] +status, res = api("GET", "/api/v3/crypto/certificatekeypairs/?has_key=true") +SIGNING_KEY_PK = res["results"][0]["pk"] + +# ----------------------------------------------------------------------------- +print("[2/5] Ensuring groups homelab-admins / grafana-admins exist...") +homelab_admins = get_or_create( + "/api/v3/core/groups/", "/api/v3/core/groups/", + "name=homelab-admins", + {"name": "homelab-admins", "is_superuser": True}, +) +grafana_admins = get_or_create( + "/api/v3/core/groups/", "/api/v3/core/groups/", + "name=grafana-admins", + {"name": "grafana-admins", "is_superuser": False}, +) + +# ----------------------------------------------------------------------------- +print("[3/5] Ensuring user 'rock' exists with admin group membership...") +status, res = api("GET", "/api/v3/core/users/?username=rock") +rock_password = None +if res.get("results"): + rock = res["results"][0] + status, rock = api("PATCH", f"/api/v3/core/users/{rock['pk']}/", { + "groups": [homelab_admins["pk"], grafana_admins["pk"]], + "is_active": True, + }) + if status not in (200, 201): + die(f"PATCH user rock -> {status} {rock}") + print(" rock already exists, group membership synced (password unchanged)") +else: + rock_password = gen_secret(24) + status, rock = api("POST", "/api/v3/core/users/", { + "username": "rock", + "name": "Rock", + "is_active": True, + "groups": [homelab_admins["pk"], grafana_admins["pk"]], + "path": "users", + "type": "internal", + }) + if status not in (200, 201): + die(f"POST user rock -> {status} {rock}") + status, pw_res = api("POST", f"/api/v3/core/users/{rock['pk']}/set_password/", + {"password": rock_password}) + if status not in (200, 204): + die(f"set_password for rock -> {status} {pw_res}") + kubectl_create_secret("iam", "rock-credentials", { + "username": "rock", + "password": rock_password, + }) + print(" rock created, credentials stored in iam/rock-credentials") + +# ----------------------------------------------------------------------------- +print("[4/5] Ensuring OAuth2 providers + applications for grafana/minio/forgejo/argocd...") + +SERVICES = { + "grafana": { + "client_secret_source": ("logging", "grafana-oidc", "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"), + "redirect_uris": ["https://grafana.riotpiao.com/login/generic_oauth"], + "launch_url": "https://grafana.riotpiao.com", + "display_name": "Grafana", + }, + "minio": { + "client_secret_source": ("storage", "minio-oidc", "MINIO_IDENTITY_OPENID_CLIENT_SECRET"), + "redirect_uris": ["https://minio.riotpiao.com/oauth_callback"], + "launch_url": "https://minio.riotpiao.com", + "display_name": "MinIO", + }, + "forgejo": { + # No secret exists yet for forgejo - generate + store on first run. + "client_secret_source": ("cicd", "forgejo-oidc", "CLIENT_SECRET"), + "generate_if_missing": True, + "redirect_uris": [ + "https://forgejo.riotpiao.com/user/oauth2/authentik/callback", + "https://forgejo.riotpiao.com/user/oauth2/openidconnect/callback", + ], + "launch_url": "https://forgejo.riotpiao.com", + "display_name": "Forgejo", + }, + "argocd": { + # oidc-secret uses hyphenated keys (client-id/client-secret) per + # argocd-values.yaml's `$oidc-secret:client-id` / `:client-secret` refs. + "client_secret_source": ("argocd", "oidc-secret", "client-secret"), + "generate_if_missing": True, + "extra_secret_literals": {"client-id": "argocd"}, + "redirect_uris": ["https://argocd.riotpiao.com/auth/callback"], + "launch_url": "https://argocd.riotpiao.com", + "display_name": "Argo CD", + }, +} + +app_pks_for_binding = [] + +for name, cfg in SERVICES.items(): + ns, secret_name, key = cfg["client_secret_source"] + client_secret = kubectl_get_secret_key(ns, secret_name, key) + if client_secret is None: + if not cfg.get("generate_if_missing"): + print(f" WARNING: {ns}/{secret_name} key {key} not found and " + f"generate_if_missing not set for '{name}' - skipping provider/app") + continue + client_secret = gen_secret(40) + literals = {key: client_secret} + literals.update(cfg.get("extra_secret_literals", {})) + kubectl_create_secret(ns, secret_name, literals) + print(f" {name}: generated new client secret -> {ns}/{secret_name}") + else: + print(f" {name}: using existing client secret from {ns}/{secret_name}") + + provider = get_or_create( + "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", + f"name={name}", + { + "name": name, + "client_id": name, + "client_secret": client_secret, + "client_type": "confidential", + "authorization_flow": AUTHORIZATION_FLOW_PK, + "invalidation_flow": INVALIDATION_FLOW_PK, + "signing_key": SIGNING_KEY_PK, + "property_mappings": SCOPE_PKS, + "sub_mode": "hashed_user_id", + "include_claims_in_id_token": True, + # authentik 2026.x requires grant_types to be set explicitly; the + # API defaults it to [] when omitted, which makes /authorize reject + # every login with "Invalid grant_type for provider" -> + # invalid_request. authorization_code = the web SSO flow all these + # apps use; refresh_token = long-lived sessions (offline_access). + "grant_types": ["authorization_code", "refresh_token"], + "redirect_uris": [ + {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"] + ], + }, + # Keep the redirect_uris/mappings/grant_types in sync on re-run, but + # never touch client_secret again once created (that's the source of + # truth in the k8s Secret, and re-sending it here is harmless anyway). + patch_existing={ + "property_mappings": SCOPE_PKS, + "grant_types": ["authorization_code", "refresh_token"], + "redirect_uris": [ + {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"] + ], + }, + ) + + application = get_or_create( + "/api/v3/core/applications/", "/api/v3/core/applications/", + # superuser_full_list=true is REQUIRED: the applications list applies + # access-policy filtering to the results array (these apps are bound to + # homelab-admins, and the bootstrap-token user akadmin is not a member), + # so without it the GET returns an empty results list even though the app + # exists -> get_or_create falls through to POST -> 400 "already exists", + # which aborted the whole loop before later providers got grant_types. + f"slug={name}&superuser_full_list=true", + { + "name": cfg["display_name"], + "slug": name, + "provider": provider["pk"], + "meta_launch_url": cfg["launch_url"], + }, + patch_existing={ + "provider": provider["pk"], + "meta_launch_url": cfg["launch_url"], + }, + ) + app_pks_for_binding.append((name, application["pk"])) + print(f" {name}: provider pk={provider['pk']} application pk={application['pk']}") + +# ----------------------------------------------------------------------------- +print("[5/5] Binding homelab-admins to every application (guaranteed access for rock)...") +for name, app_pk in app_pks_for_binding: + get_or_create( + "/api/v3/policies/bindings/", "/api/v3/policies/bindings/", + f"target={app_pk}&group={homelab_admins['pk']}", + { + "target": app_pk, + "group": homelab_admins["pk"], + "order": 0, + "enabled": True, + }, + ) + print(f" {name}: homelab-admins bound") + +print("\nDone. Summary:") +print(" groups: homelab-admins (superuser), grafana-admins") +print(" user: rock -> homelab-admins + grafana-admins") +print(f" apps: {', '.join(n for n, _ in app_pks_for_binding)}") +if rock_password: + print(" NOTE: rock's password was generated this run - see") + print(" kubectl -n iam get secret rock-credentials -o jsonpath='{.data.password}' | base64 -d")