Files
homelab/k8s/infra/iam/scripts/authentik-provision.py
T

642 lines
28 KiB
Python
Raw Normal View History

#!/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
# <userinfo>/emails call, which Authentik 404s -> login fails entirely.
"email": "[email protected]",
})
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": "[email protected]",
"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/<provider_id>/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")