#!/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, 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 = { "homelab-admins": ["*"], "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"], "paperless-admins": ["paperless:read", "paperless:write"], "immich-admins": ["immich:read", "immich:write"], "k8s-devops-admin": ["k8s:devops"], } 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"] # 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 = [ "grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins", "portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins", "paperless-admins", "immich-admins", "k8s-devops-admin", ] 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://immich.riotpiao.com/auth/login", "https://immich.riotpiao.com/user-settings", "https://immich.riotpiao.com/api/oauth/mobile-redirect", ], "launch_url": "https://immich.riotpiao.com", "display_name": "Immich", }, } app_pks_for_binding = [] for name, cfg in SERVICES.items(): # MinIO also needs the "policy" claim (via the minio scope mapping) so its # MINIO_IDENTITY_OPENID_CLAIM_NAME=policy maps homelab-admins -> consoleAdmin. # Immich needs "immich_role" so its OAuth roleClaim can grant admin. provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) \ + ([IMMICH_ROLE_MAPPING_PK] if name == "immich" else []) 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, }) 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": ["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": provider_mappings, "grant_types": ["authorization_code", "refresh_token"], "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']}") # ----------------------------------------------------------------------------- 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") # Per-service admin groups are app-scoped (unlike homelab-admins' blanket # binding above) - only grants visibility/access to that one application. # portainer/kmsvc/temporal/llm-serving 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", } 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") 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")