diff --git a/k8s/infra/iam/authentik-provision-job.yaml b/k8s/infra/iam/authentik-provision-job.yaml deleted file mode 100644 index b2fa7fb..0000000 --- a/k8s/infra/iam/authentik-provision-job.yaml +++ /dev/null @@ -1,252 +0,0 @@ -# Authentik OAuth provisioning — MANUAL operation, NOT auto-run. -# -# Security-sensitive IAM changes should be reviewed and run locally: -# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ -# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d) -# sed 's|http://authentik-server.iam.svc.cluster.local|https://authentik.riotpiao.com|g' \ -# k8s/infra/iam/scripts/authentik-provision.py | python3 -# -# What it does (see scripts/authentik-provision.py docstring): creates scope -# mappings (groups, permissions, memory, policy, immich_role), admin groups, -# the "rock" admin user, OAuth2 providers + Applications, service accounts -# (portfolio-agent, memory-agent), and binds groups to applications. -# -# This file provides the RBAC (ServiceAccount + RoleBindings) needed if you -# ever want to run the Job in-cluster manually (kubectl create -f), but the -# PostSync hook is deliberately removed — IAM is not GitOps-auto-deployed. -# -# RBAC: this Job only touches Secrets (get existing client secrets, create new -# ones) across the namespaces those services live in. -apiVersion: v1 -kind: ServiceAccount -metadata: - name: authentik-provisioner - namespace: iam ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: authentik-provisioner -rules: - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list", "create", "update", "patch"] ---- -# One RoleBinding per namespace the script touches (least-privilege: Secrets -# only, and only in these 5 namespaces — not a cluster-wide ClusterRoleBinding). -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: iam -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: cicd -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: argocd -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: logging -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: storage -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: paperless -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: immich -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: llm-serving -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: portfolio -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: poimen -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: authentik-provisioner - namespace: dashboard -subjects: - - kind: ServiceAccount - name: authentik-provisioner - namespace: iam -roleRef: - kind: ClusterRole - name: authentik-provisioner - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: authentik-provision - namespace: iam - # No ArgoCD hook - run manually when IAM changes are needed -spec: - ttlSecondsAfterFinished: 600 - backoffLimit: 3 - template: - spec: - serviceAccountName: authentik-provisioner - restartPolicy: Never - securityContext: - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - containers: - - name: provision - image: python:3.12-alpine - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: ["ALL"] - env: - - name: AUTHENTIK_BOOTSTRAP_TOKEN - valueFrom: - secretKeyRef: - name: authentik-secrets - key: AUTHENTIK_BOOTSTRAP_TOKEN - volumeMounts: - - name: script - mountPath: /script - command: - - /bin/sh - - -c - - | - set -e - echo "waiting for authentik-server..." - until wget -q -O /dev/null http://authentik-server.iam.svc.cluster.local/-/health/ready/ 2>/dev/null; do - sleep 5 - done - echo "installing kubectl (via python urllib - no apk/curl: this" - echo "container runs as non-root UID 1000 and can't write to" - echo "apk's directories or /usr/local/bin, both root-owned in" - echo "the python:3.12-alpine image; /tmp is world-writable)..." - python3 -c " - import urllib.request, os, stat - kver = urllib.request.urlopen('https://dl.k8s.io/release/stable.txt').read().decode().strip() - url = f'https://dl.k8s.io/release/{kver}/bin/linux/amd64/kubectl' - urllib.request.urlretrieve(url, '/tmp/kubectl') - st = os.stat('/tmp/kubectl') - os.chmod('/tmp/kubectl', st.st_mode | stat.S_IEXEC) - " - export PATH="/tmp:$PATH" - echo "running provisioning script..." - python3 /script/authentik-provision.py - volumes: - - name: script - configMap: - name: authentik-provision-script diff --git a/k8s/infra/iam/kustomization.yaml b/k8s/infra/iam/kustomization.yaml index de471d8..f0d119b 100644 --- a/k8s/infra/iam/kustomization.yaml +++ b/k8s/infra/iam/kustomization.yaml @@ -1,35 +1,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -# NOTE: no top-level `namespace:` transformer here (removed) - it used to -# force-rewrite metadata.namespace to "iam" on every resource in this -# kustomization, which was harmless while every manifest here only ever -# targeted the iam namespace itself. authentik-provision-job.yaml's -# RoleBindings deliberately target cicd/argocd/logging/storage (least- -# privilege access for the authentik-provisioner ServiceAccount to touch -# Secrets in those namespaces) - the namespace transformer would have -# silently rewritten all of them back to iam, breaking the RBAC. Every -# manifest in this directory already sets its own explicit -# metadata.namespace, so dropping the transformer changes nothing for the -# existing resources/. + resources: - - authentik-provision-job.yaml - rbac-dashboard-rolebinding.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. -# SOPS secrets (*.enc.yaml) handled by ArgoCD SOPS plugin at sync time -# authentik/vault deployed via ArgoCD Helm source +# IAM provisioning is manual-only (security-sensitive). +# Script: scripts/iam/authentik-provision.py +# Run: +# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ +# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d) +# python3 scripts/iam/authentik-provision.py diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py deleted file mode 100644 index 9a4dc02..0000000 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ /dev/null @@ -1,907 +0,0 @@ -#!/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 = { - # 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']}") - -# ----------------------------------------------------------------------------- -# Service accounts for programmatic API access (client_credentials flow). -# These are Authentik users with type=service_account, not OAuth applications. -# They authenticate via client_credentials grant to get JWTs with claims. -print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...") - -SERVICE_ACCOUNTS = { - "portfolio-agent": { - "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping - "attributes": { - "memory_projects": ["homelab", "portfolio"], - "memory_visibility": "public", - }, - "secret_ns": "portfolio", - "secret_name": "portfolio-agent-oidc", - }, - "memory-agent": { - "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping - "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 (client_credentials flow) - sa_client_secret = existing_secret or gen_secret(40) - 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, - "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": ["client_credentials"], - "redirect_uris": [], # No redirects for client_credentials - }, - patch_existing={ - "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], - "grant_types": ["client_credentials"], - }, - ) - - # 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"])) - - # Store credentials in k8s Secret - if not existing_secret: - kubectl_create_secret(sa_cfg["secret_ns"], sa_cfg["secret_name"], { - "CLIENT_ID": sa_name, - "CLIENT_SECRET": sa_client_secret, - "TOKEN_URL": "https://authentik.riotpiao.com/application/o/token/", - "ISSUER": f"https://authentik.riotpiao.com/application/o/{sa_name}/", - }) - print(f" {sa_name}: generated 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")