#!/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 = "https://authentik.riotpiao.com" 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, labels: dict = None): """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()}") if labels: # argocd's `$secret:key` substitution only reads Secrets carrying # app.kubernetes.io/part-of: argocd — without it OIDC login fails with # oauth2 "invalid_client" (empty client_secret sent to the IdP). label_args = ["kubectl", "-n", namespace, "label", "secret", name, "--overwrite"] + [f"{k}={v}" for k, v in labels.items()] subprocess.run(label_args, capture_output=True, text=True) 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"] # Generic "permissions" claim, computed from group membership - lets each app # (and eventually k8s RBAC via --oidc-groups-claim) check a permission string # like "paperless:write" instead of hardcoding a group name. homelab-admins # gets "*" (everything); every other admin group gets its own read+write pair. # k8s-devops-admin is declared but has no k8s Role/RoleBinding target yet - # foundation for a future short-lived federated-operator credential. _PERMISSIONS_EXPR = """ GROUP_PERMISSIONS = { # Universal admin "homelab-admins": ["*"], # Service admin groups (full control) "grafana-admins": ["grafana:read", "grafana:write"], "minio-admins": ["minio:read", "minio:write"], "forgejo-admins": ["forgejo:read", "forgejo:write"], "homarr-admins": ["homarr:read", "homarr:write"], "portainer-admins": ["portainer:read", "portainer:write"], "kmsvc-admins": ["kmsvc:read", "kmsvc:write"], "temporal-admins": ["temporal:read", "temporal:write"], "llm-admins": ["llm:read", "llm:write", "llm:inference"], "paperless-admins": ["paperless:read", "paperless:write"], "immich-admins": ["immich:read", "immich:write"], "poimen-memory-admins": ["memory:read", "memory:write", "memory:admin"], "k8s-devops-admin": ["k8s:devops"], "vault-service-api": ["vault:read", "vault:write"], # Capability groups (non-admin users) "llm-users": ["llm:inference"], "memory-users": ["memory:read"], "memory-writers": ["memory:read", "memory:write"], } perms = set() for group in request.user.groups.all(): perms.update(GROUP_PERMISSIONS.get(group.name, [])) return {"permissions": sorted(perms)} """.strip() permissions_mapping = get_or_create( "/api/v3/propertymappings/provider/scope/", "/api/v3/propertymappings/provider/scope/", "scope_name=permissions", { "name": "homelab: permissions claim", "scope_name": "permissions", "expression": _PERMISSIONS_EXPR, }, patch_existing={"expression": _PERMISSIONS_EXPR}, ) PERMISSIONS_MAPPING_PK = permissions_mapping["pk"] # Immich reads a "immich_role" claim on every login (not just user-creation - # fixed upstream in immich-app/immich#29991) and syncs isAdmin from it, so # this is the actual mechanism that makes "rock" an Immich admin - not # Immich's first-user-is-admin fallback, which races badly with OAuth login. _IMMICH_ROLE_EXPR = ( "return {\"immich_role\": \"admin\" " "if request.user.ak_groups.filter(name__in=[\"homelab-admins\", \"immich-admins\"]).exists() " "else \"user\"}" ) immich_role_mapping = get_or_create( "/api/v3/propertymappings/provider/scope/", "/api/v3/propertymappings/provider/scope/", "scope_name=immich_role", { "name": "homelab: immich role claim", "scope_name": "immich_role", "expression": _IMMICH_ROLE_EXPR, }, patch_existing={"expression": _IMMICH_ROLE_EXPR}, ) IMMICH_ROLE_MAPPING_PK = immich_role_mapping["pk"] # MinIO maps OIDC users to a MinIO policy via a "policy" claim # (MINIO_IDENTITY_OPENID_CLAIM_NAME=policy). Emit consoleAdmin (full admin) for # homelab-admins members, readonly for everyone else. Without this claim MinIO # assigns no policy and OIDC users get no access. _POLICY_EXPR = ( "return {\"policy\": \"consoleAdmin\" " "if request.user.ak_groups.filter(name=\"homelab-admins\").exists() " "else \"readonly\"}" ) policy_mapping = get_or_create( "/api/v3/propertymappings/provider/scope/", "/api/v3/propertymappings/provider/scope/", "scope_name=minio", { "name": "homelab: minio policy claim", "scope_name": "minio", "expression": _POLICY_EXPR, }, patch_existing={"expression": _POLICY_EXPR}, ) POLICY_MAPPING_PK = policy_mapping["pk"] # Memory service (Poimen) claims - fine-grained access control. # Returns memory_projects, memory_visibility, memory_role based on: # 1. User attributes (memory_projects, memory_visibility) # 2. Group membership (homelab-admins, poimen-memory-admins) # 3. Service account configs (portfolio-agent, etc.) _MEMORY_EXPR = """ # Service account specific configs (checked first) SA_CONFIGS = { "portfolio-agent": { "projects": ["homelab", "portfolio"], "visibility": "public", "role": "portfolio-agent" }, "memory-agent": { "projects": ["*"], "visibility": "private", "role": "authenticated-user" } } username = request.user.username if username in SA_CONFIGS: cfg = SA_CONFIGS[username] return { "memory_projects": cfg["projects"], "memory_visibility": cfg["visibility"], "memory_role": cfg["role"] } # Default from user attributes projects = request.user.attributes.get("memory_projects", []) visibility = request.user.attributes.get("memory_visibility", "public") role = "user" # Admin group overrides if request.user.ak_groups.filter(name="homelab-admins").exists(): projects = ["*"] visibility = "private" role = "admin" elif request.user.ak_groups.filter(name="poimen-memory-admins").exists(): # memory-admins get full visibility but respect project restrictions visibility = "private" role = "admin" return { "memory_projects": projects if projects else [], "memory_visibility": visibility, "memory_role": role } """.strip() memory_mapping = get_or_create( "/api/v3/propertymappings/provider/scope/", "/api/v3/propertymappings/provider/scope/", "scope_name=memory", { "name": "homelab: memory service claims", "scope_name": "memory", "expression": _MEMORY_EXPR, }, patch_existing={"expression": _MEMORY_EXPR}, ) MEMORY_MAPPING_PK = memory_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, PERMISSIONS_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 homelab-admins + per-service admin groups exist...") homelab_admins = get_or_create( "/api/v3/core/groups/", "/api/v3/core/groups/", "name=homelab-admins", {"name": "homelab-admins", "is_superuser": True}, ) # App-scoped, not Authentik superusers (unlike homelab-admins) - each maps to # read+write in its own service via the "permissions" claim above (k8s Role/ # RoleBinding in k8s/infra/rbac/, or an app's own adapter e.g. paperless's). # k8s-devops-admin is declared with no target yet - foundation for a future # short-lived federated-operator credential. SERVICE_ADMIN_GROUP_NAMES = [ # Service admin groups (full control of their service) "grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins", "portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins", "paperless-admins", "immich-admins", "poimen-memory-admins", "k8s-devops-admin", # Vault Identity Group aliasing target for service/API access "vault-service-api", # Capability groups (non-admin users with specific permissions) "llm-users", # Can call LLM inference, no admin "memory-users", # Can query memory, no write "memory-writers", # Can query and write to memory ] service_admin_groups = {} for group_name in SERVICE_ADMIN_GROUP_NAMES: service_admin_groups[group_name] = get_or_create( "/api/v3/core/groups/", "/api/v3/core/groups/", f"name={group_name}", {"name": group_name, "is_superuser": False}, ) grafana_admins = service_admin_groups["grafana-admins"] paperless_admins = service_admin_groups["paperless-admins"] # ----------------------------------------------------------------------------- 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"]] + [g["pk"] for g in service_admin_groups.values()], "is_active": True, # email is REQUIRED: Grafana's OIDC login reads the email claim from # userinfo; an empty email makes Grafana fall back to a GitHub-style # /emails call, which Authentik 404s -> login fails entirely. "email": "locartrock@gmail.com", }) 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, # Required for Grafana OIDC (see PATCH branch above). "email": "locartrock@gmail.com", "groups": [homelab_admins["pk"]] + [g["pk"] for g in service_admin_groups.values()], "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"}, # argocd only reads $secret refs from Secrets labelled part-of: argocd. "secret_labels": {"app.kubernetes.io/part-of": "argocd"}, "redirect_uris": ["https://argocd.riotpiao.com/auth/callback"], "launch_url": "https://argocd.riotpiao.com", "display_name": "Argo CD", }, "homarr": { "client_secret_source": ("dashboard", "homarr-oidc", "client-secret"), "generate_if_missing": True, "extra_secret_literals": {"client-id": "homarr"}, "redirect_uris": ["https://homarr.riotpiao.com/api/auth/callback/oidc"], "launch_url": "https://homarr.riotpiao.com", "display_name": "Homarr", }, "paperless": { # No secret exists yet for paperless - generate + store on first run. # django-allauth's generic openid_connect provider callback path is # /accounts/oidc//login/callback/ - provider_id "authentik" # is set in PAPERLESS_SOCIALACCOUNT_PROVIDERS (see configmap.yaml). "client_secret_source": ("paperless", "paperless-oidc", "CLIENT_SECRET"), "generate_if_missing": True, "redirect_uris": ["https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"], "launch_url": "https://paperless.riotpiao.com", "display_name": "Paperless-ngx", }, "immich": { # No secret exists yet for immich - generate + store on first run. "client_secret_source": ("immich", "immich-oidc", "CLIENT_SECRET"), "generate_if_missing": True, # /auth/login + /user-settings are Immich's own web callback routes; # /api/oauth/mobile-redirect forwards to the app.immich:///oauth-callback # custom scheme Authentik can't register directly (see docs.immich.app/ # administration/oauth - "custom scheme" workaround). "redirect_uris": [ "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user-settings", "https://img.riotpiao.com/api/oauth/mobile-redirect", ], "launch_url": "https://img.riotpiao.com", "display_name": "Immich", }, "vault": { # Human/CLI login only (`vault login -method=oidc`) - not wired to any # workload. No secret exists yet - generate + store on first run. # localhost:8250/oidc/callback is the vault CLI's documented fixed # callback port for `vault login -method=oidc`; the other is the # browser/UI flow's callback path (mount path "oidc"). "client_secret_source": ("iam", "vault-oidc", "CLIENT_SECRET"), "generate_if_missing": True, "extra_secret_literals": {"client-id": "vault"}, "redirect_uris": [ "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback", "http://localhost:8250/oidc/callback", ], "launch_url": "https://vault.riotpiao.com", "display_name": "Vault", }, "poimen-memory": { # Service-to-service API auth (no browser redirect) - generate secret on first run. "client_secret_source": ("poimen", "poimen-memory-oidc", "CLIENT_SECRET"), "generate_if_missing": True, "extra_secret_literals": {"client-id": "poimen-memory"}, "redirect_uris": [], # No browser flow, service-to-service only "launch_url": "https://memory.riotpiao.com", "display_name": "Poimen Memory", }, "local-llm": { # JWT auth for local LLM API access - service-to-service, no browser flow. # Client validates JWT tokens issued by this provider using the public key. "client_secret_source": ("llm-serving", "local-llm-jwt", "client-secret"), "generate_if_missing": True, "extra_secret_literals": {"client-id": "local-llm"}, "redirect_uris": [], # No browser flow, JWT/service-to-service only "launch_url": "https://llm.riotpiao.com", "display_name": "Local LLM", }, } app_pks_for_binding = [] for name, cfg in SERVICES.items(): # Service-specific scope mappings: # - MinIO: "policy" claim for MINIO_IDENTITY_OPENID_CLAIM_NAME # - Immich: "immich_role" for OAuth roleClaim # - poimen-memory, local-llm: "memory" scope for fine-grained access provider_mappings = SCOPE_PKS[:] if name == "minio": provider_mappings.append(POLICY_MAPPING_PK) if name == "immich": provider_mappings.append(IMMICH_ROLE_MAPPING_PK) if name in ("poimen-memory", "local-llm", "portfolio-agent"): provider_mappings.append(MEMORY_MAPPING_PK) 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, labels=cfg.get("secret_labels")) print(f" {name}: generated new client secret -> {ns}/{secret_name}") else: print(f" {name}: using existing client secret from {ns}/{secret_name}") if name == "paperless": # paperless-ngx's django-allauth OIDC config takes client_id/secret # bundled inside one JSON blob (PAPERLESS_SOCIALACCOUNT_PROVIDERS), not # discrete env vars - compose it here and store it alongside # CLIENT_SECRET so the Deployment can source it directly via # secretKeyRef, no shell wrapper needed. Runs every time (not just on # generate), so it stays in sync if the client_secret is ever rotated # by hand. providers_json = json.dumps({ "openid_connect": { "APPS": [{ "provider_id": "authentik", "name": "Authentik", "client_id": "paperless", "secret": client_secret, "settings": { "server_url": "https://authentik.riotpiao.com/application/o/paperless/.well-known/openid-configuration", # "groups"/"permissions" aren't default OIDC scopes - # must be requested explicitly for Authentik's scope # mappings above to actually be returned. paperless's # adapter.py ConfigMap reads the "permissions" claim # to grant is_staff+is_superuser. "scope": ["openid", "profile", "email", "groups", "permissions"], }, }], }, }) kubectl_create_secret("paperless", "paperless-oidc", { "CLIENT_SECRET": client_secret, "SOCIALACCOUNT_PROVIDERS_JSON": providers_json, }) if name == "immich": # Immich reads its whole system-config from IMMICH_CONFIG_FILE (a # mounted JSON file, see k8s/apps/immich/deployment.yaml), not # discrete env vars. "immich_role" must be in `scope` for Authentik # to actually include that claim in the token (non-default scopes # are opt-in per-client, same reason paperless requests "permissions" # explicitly). roleClaim is re-evaluated on every login (immich-app/ # immich#29991) so this is the actual admin-grant mechanism for rock, # not Immich's racy first-user-is-admin fallback. immich_config_json = json.dumps({ "oauth": { "enabled": True, "issuerUrl": "https://authentik.riotpiao.com/application/o/immich/", "clientId": "immich", "clientSecret": client_secret, "scope": "openid email profile immich_role", "roleClaim": "immich_role", "autoRegister": True, "autoLaunch": False, "buttonText": "Login with Authentik", "mobileRedirectUri": "app.immich:///oauth-callback", }, }) kubectl_create_secret("immich", "immich-oidc", { "CLIENT_SECRET": client_secret, "config.json": immich_config_json, }) # Service-to-service (client_credentials): poimen-memory # Browser SSO (authorization_code): all others grant_types = [ "urn:ietf:params:oauth:grant-type:device_code", # device code flow (CLI/headless) "client_credentials" # service-to-service ] if name == "poimen-memory" else [ "authorization_code", # web SSO "refresh_token" # long-lived sessions ] 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": provider_mappings, "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": grant_types, "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": provider_mappings, "grant_types": grant_types, "redirect_uris": [ {"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"] ], }, ) # superuser_full_list=true is REQUIRED on the LIST: 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 -> fall through to POST -> 400 "already exists". # # We deliberately do NOT patch_existing here: the application DETAIL endpoint # (PATCH /applications/{pk}/) enforces the same access policy and does NOT # honor superuser_full_list, so PATCH-by-pk returns 404 for akadmin once the # homelab-admins binding exists. That 404 aborted the loop before later # providers got their grant_types. slug/provider/launch_url are set at # creation and are stable (provider is get_or_create'd by name, stable pk), # so find-or-create is sufficient. application = get_or_create( "/api/v3/core/applications/", "/api/v3/core/applications/", f"slug={name}&superuser_full_list=true", { "name": cfg["display_name"], "slug": name, "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']}") # ----------------------------------------------------------------------------- # Separate from the SERVICES loop above: this is a PUBLIC client (PKCE, no # client_secret) for `kubectl` OIDC login, not a confidential-client app # login. Foundation for k8s/infra/rbac/ - kube-apiserver's --oidc-* flags # (controlplane.tftpl) validate tokens issued against this provider. # Redirect URI matches kubelogin's (int128/kubelogin) documented default; # adjust here if a different kubectl OIDC plugin/port is actually used. print("Ensuring public OAuth2 client 'kubernetes' for kubectl OIDC login...") k8s_provider = get_or_create( "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", "name=kubernetes", { "name": "kubernetes", "client_id": "kubernetes", "client_type": "public", "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, "grant_types": ["authorization_code", "refresh_token"], "redirect_uris": [ {"matching_mode": "strict", "url": "http://localhost:8000"}, ], }, patch_existing={ "property_mappings": SCOPE_PKS, "grant_types": ["authorization_code", "refresh_token"], "redirect_uris": [ {"matching_mode": "strict", "url": "http://localhost:8000"}, ], }, ) k8s_application = get_or_create( "/api/v3/core/applications/", "/api/v3/core/applications/", "slug=kubernetes&superuser_full_list=true", { "name": "Kubernetes", "slug": "kubernetes", "provider": k8s_provider["pk"], "meta_launch_url": "https://authentik.riotpiao.com", }, ) app_pks_for_binding.append(("kubernetes", k8s_application["pk"])) print(f" kubernetes: provider pk={k8s_provider['pk']} application pk={k8s_application['pk']}") # ----------------------------------------------------------------------------- # Headless authentication flow for service accounts (password grant). # Default flow has MFA/interactive stages that break password grant. # This flow: identification -> password -> login (no MFA, no consent prompts). print("\n[SERVICE ACCOUNT FLOW] Ensuring headless authentication flow...") sa_flow = get_or_create( "/api/v3/flows/instances/", "/api/v3/flows/instances/", "slug=service-account-authentication", { "slug": "service-account-authentication", "name": "Service Account Authentication (Headless)", "title": "Service Account Login", "designation": "authentication", "policy_engine_mode": "any", }, ) SA_AUTHENTICATION_FLOW_PK = sa_flow["pk"] print(f" flow pk={SA_AUTHENTICATION_FLOW_PK}") # Bind stages to the flow (identification -> password -> login) # Get default stages (reuse existing ones) status, ident_stages = api("GET", "/api/v3/stages/identification/") status, pass_stages = api("GET", "/api/v3/stages/password/") status, login_stages = api("GET", "/api/v3/stages/user_login/") if ident_stages.get("results") and pass_stages.get("results") and login_stages.get("results"): ident_pk = ident_stages["results"][0]["pk"] pass_pk = pass_stages["results"][0]["pk"] login_pk = login_stages["results"][0]["pk"] # Check if bindings already exist status, existing_bindings = api("GET", f"/api/v3/flows/bindings/?target={SA_AUTHENTICATION_FLOW_PK}") if not existing_bindings.get("results"): # Create bindings api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": ident_pk, "order": 10}) api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": pass_pk, "order": 20}) api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": login_pk, "order": 30}) print(" bound stages: identification -> password -> login") else: print(f" stages already bound ({len(existing_bindings['results'])} bindings)") else: print(" WARNING: Could not find default stages to bind") # ----------------------------------------------------------------------------- # Service accounts for programmatic API access (password grant). # These are Authentik users with type=service_account, not OAuth applications. # They authenticate via password grant to get JWTs with user claims. print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...") SERVICE_ACCOUNTS = { "portfolio-agent": { "groups": ["llm-users", "memory-users"], # LLM inference + memory read "attributes": { "memory_projects": ["homelab", "portfolio"], "memory_visibility": "public", }, "secret_ns": "portfolio", "secret_name": "portfolio-agent-oidc", }, "memory-agent": { "groups": ["llm-users", "memory-writers"], # Internal memory service "attributes": { "memory_projects": ["*"], "memory_visibility": "private", }, "secret_ns": "poimen", "secret_name": "memory-agent-oidc", }, } for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): # Check if secret already exists (don't regenerate credentials) existing_secret = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "CLIENT_SECRET") # Get or create the service account user status, res = api("GET", f"/api/v3/core/users/?username={sa_name}") if status != 200: die(f"GET users for {sa_name} -> {status} {res}") if res.get("results"): sa_user = res["results"][0] # Update attributes and groups group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", { "attributes": sa_cfg["attributes"], "groups": group_pks, "is_active": True, }) if status not in (200, 201): die(f"PATCH service account {sa_name} -> {status} {sa_user}") print(f" {sa_name}: updated existing service account") else: # Create new service account user group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] status, sa_user = api("POST", "/api/v3/core/users/", { "username": sa_name, "name": f"Service Account: {sa_name}", "type": "service_account", "path": "service-accounts", "is_active": True, "attributes": sa_cfg["attributes"], "groups": group_pks, }) if status not in (200, 201): die(f"POST service account {sa_name} -> {status} {sa_user}") print(f" {sa_name}: created new service account") # Create OAuth provider for this service account # Supports both password grant (user claims) and client_credentials (fallback) # Password grant requires authentication_flow and app_password token sa_client_secret = existing_secret or gen_secret(40) sa_grant_types = ["password", "client_credentials", "refresh_token"] sa_provider = get_or_create( "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", f"name={sa_name}", { "name": sa_name, "client_id": sa_name, "client_secret": sa_client_secret, "client_type": "confidential", "authorization_flow": AUTHORIZATION_FLOW_PK, "authentication_flow": SA_AUTHENTICATION_FLOW_PK, # Headless flow for password grant "invalidation_flow": INVALIDATION_FLOW_PK, "signing_key": SIGNING_KEY_PK, "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], "sub_mode": "hashed_user_id", "include_claims_in_id_token": True, "grant_types": sa_grant_types, "redirect_uris": [], }, patch_existing={ "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], "grant_types": sa_grant_types, "authentication_flow": SA_AUTHENTICATION_FLOW_PK, }, ) # Create application for the service account sa_application = get_or_create( "/api/v3/core/applications/", "/api/v3/core/applications/", f"slug={sa_name}&superuser_full_list=true", { "name": f"Service Account: {sa_name}", "slug": sa_name, "provider": sa_provider["pk"], "meta_launch_url": "", }, ) app_pks_for_binding.append((sa_name, sa_application["pk"])) # Create App Password token for password grant # Authentik's password grant checks against Token with intent=app_password, not user password # Note: API doesn't allow setting key at creation, must use set_key endpoint after existing_app_password = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "APP_PASSWORD") if not existing_app_password: token_identifier = f"{sa_name}-app-password" # Check if token exists status, existing_tokens = api("GET", f"/api/v3/core/tokens/?identifier={token_identifier}") if status == 200 and existing_tokens.get("results"): # Token exists, just set a new key pass else: # Create the token first status, token_resp = api("POST", "/api/v3/core/tokens/", { "identifier": token_identifier, "user": sa_user["pk"], "intent": "app_password", "expiring": False, "description": f"App password for {sa_name} OAuth password grant", }) if status not in (200, 201): print(f" WARNING: Failed to create app password token for {sa_name}: {token_resp}") # Set the key via set_key endpoint (works on existing or newly created token) app_password_key = gen_secret(40) status, _ = api("POST", f"/api/v3/core/tokens/{token_identifier}/set_key/", { "key": app_password_key, }) if status not in (200, 204): print(f" WARNING: Failed to set app password key for {sa_name}") app_password_key = None else: print(f" {sa_name}: created/updated app password token") else: app_password_key = existing_app_password print(f" {sa_name}: reusing existing app password") # Store credentials in k8s Secret # Supports both password grant (APP_PASSWORD) and client_credentials (CLIENT_SECRET) secret_data = { "CLIENT_ID": sa_name, "CLIENT_SECRET": sa_client_secret, "USERNAME": sa_name, "TOKEN_URL": "https://authentik.riotpiao.com/application/o/token/", "ISSUER": f"https://authentik.riotpiao.com/application/o/{sa_name}/", } if app_password_key: secret_data["APP_PASSWORD"] = app_password_key if not existing_secret or (app_password_key and not existing_app_password): kubectl_create_secret(sa_cfg["secret_ns"], sa_cfg["secret_name"], secret_data) print(f" {sa_name}: stored credentials -> {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") else: print(f" {sa_name}: reusing existing credentials from {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}") # ----------------------------------------------------------------------------- print("\n[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") # Per-service admin groups are app-scoped (unlike homelab-admins' blanket # binding above) - only grants visibility/access to that one application. # portainer/kmsvc/temporal have no Authentik Application (no OIDC # login integration), so their groups exist for the "permissions" claim / # future k8s RBAC only - nothing to bind here. SERVICE_GROUP_TO_APP_SLUG = { "grafana-admins": "grafana", "minio-admins": "minio", "forgejo-admins": "forgejo", "homarr-admins": "homarr", "paperless-admins": "paperless", "immich-admins": "immich", "llm-admins": "local-llm", "llm-users": "local-llm", "poimen-memory-admins": "poimen-memory", "memory-users": "poimen-memory", "memory-writers": "poimen-memory", } for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items(): app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None) if not app_pk: continue group_pk = service_admin_groups[group_name]["pk"] get_or_create( "/api/v3/policies/bindings/", "/api/v3/policies/bindings/", f"target={app_pk}&group={group_pk}", { "target": app_pk, "group": group_pk, "order": 0, "enabled": True, }, ) print(f" {app_slug}: {group_name} bound") # JWT configuration for local-llm print("\n[JWT] Fetching Authentik signing key for local-llm...") status, signing_key_res = api("GET", f"/api/v3/crypto/certificatekeypairs/{SIGNING_KEY_PK}/") if status == 200: jwt_cert = signing_key_res.get("certificate", "") print(f" Public certificate available for JWT validation (base64-encoded below)\n") import base64 cert_b64 = base64.b64encode(jwt_cert.encode()).decode() print(f"Save this to local-llm config for JWT token validation:") print(f" AUTHENTIK_JWT_CERT={cert_b64}") print(f"\nJWT issuer URL: https://authentik.riotpiao.com/application/o/local-llm/") print(f"Local-LLM credentials are stored in: kubectl -n llm-serving get secret local-llm-jwt") print("\nDone. Summary:") print(" groups: homelab-admins (superuser) + " + ", ".join(SERVICE_ADMIN_GROUP_NAMES)) print(" user: rock -> homelab-admins + all service admin groups") 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")