1050 lines
45 KiB
Python
1050 lines
45 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""
|
||
|
|
Authentik OAuth provisioning - idempotent, safe to re-run (ArgoCD PostSync hook).
|
||
|
|
|
||
|
|
Creates/updates, in order:
|
||
|
|
1. A custom "groups" OAuth2 scope mapping (Authentik ships openid/email/profile
|
||
|
|
by default but NOT groups - required for ArgoCD RBAC group mapping and
|
||
|
|
Grafana's role_attribute_path, both of which read a `groups` claim).
|
||
|
|
2. Groups: homelab-admins (is_superuser=true), grafana-admins.
|
||
|
|
3. User "rock": created if missing, always (re-)synced into both groups above.
|
||
|
|
Password is generated once and only written to the k8s Secret
|
||
|
|
rock-credentials (iam ns) the first time the user is created - re-runs
|
||
|
|
never rotate an existing password.
|
||
|
|
4. OAuth2/OIDC providers + Applications for: grafana, minio, forgejo, argocd.
|
||
|
|
Client secrets are read from existing k8s Secrets (grafana-oidc, minio-oidc)
|
||
|
|
if present, or generated once and written out (forgejo-oidc, oidc-secret)
|
||
|
|
the first time.
|
||
|
|
5. PolicyBinding of homelab-admins -> every Application above, so "rock" (and
|
||
|
|
anyone else in that group) has guaranteed access regardless of each app's
|
||
|
|
default visibility.
|
||
|
|
|
||
|
|
Talks to Authentik over the in-cluster Service (authentik-server.iam.svc:80),
|
||
|
|
authenticating with the bootstrap token. Everything is done with GET-then-
|
||
|
|
create-or-patch so this can be re-run on every ArgoCD sync without duplicating
|
||
|
|
or clobbering objects (PostSync hook, not a one-shot Job with hook-delete).
|
||
|
|
|
||
|
|
kubectl is used only to read/write the small set of Secrets this script
|
||
|
|
touches - it shells out rather than using the Python k8s client to keep the
|
||
|
|
container image to stdlib Python + the kubectl binary, no pip installs.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import secrets
|
||
|
|
import string
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
|
||
AUTHENTIK_URL = "https://authentik.riotpiao.com"
|
|||
TOKEN = os.environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
|||
|
|
|
||
|
|
|
||
|
|
def api(method, path, data=None):
|
||
|
|
url = f"{AUTHENTIK_URL}{path}"
|
||
|
|
body = json.dumps(data).encode() if data is not None else None
|
||
|
|
req = urllib.request.Request(
|
||
|
|
url,
|
||
|
|
data=body,
|
||
|
|
method=method,
|
||
|
|
headers={
|
||
|
|
"Authorization": f"Bearer {TOKEN}",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
|
|
raw = resp.read()
|
||
|
|
return resp.status, (json.loads(raw) if raw else {})
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
raw = e.read()
|
||
|
|
try:
|
||
|
|
parsed = json.loads(raw) if raw else {}
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
parsed = {"raw": raw.decode(errors="replace")}
|
||
|
|
return e.code, parsed
|
||
|
|
|
||
|
|
|
||
|
|
def die(msg):
|
||
|
|
print(f"FATAL: {msg}", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
def gen_secret(n=40):
|
||
|
|
alphabet = string.ascii_letters + string.digits
|
||
|
|
return "".join(secrets.choice(alphabet) for _ in range(n))
|
||
|
|
|
||
|
|
|
||
|
|
def kubectl_get_secret_key(namespace, name, key):
|
||
|
|
"""Returns decoded value, or None if the secret/key doesn't exist."""
|
||
|
|
p = subprocess.run(
|
||
|
|
["kubectl", "-n", namespace, "get", "secret", name, "-o", f"jsonpath={{.data.{key}}}"],
|
||
|
|
capture_output=True, text=True,
|
||
|
|
)
|
||
|
|
if p.returncode != 0 or not p.stdout.strip():
|
||
|
|
return None
|
||
|
|
import base64
|
||
|
|
return base64.b64decode(p.stdout).decode()
|
||
|
|
|
||
|
|
|
||
def kubectl_create_secret(namespace, name, literals: dict, labels: dict = None):
|
|||
"""Idempotent: create-or-update via dry-run|apply, same pattern used
|
|||
|
|
elsewhere in this repo (setup_vault.sh, apply-vault-secrets.sh)."""
|
||
|
|
args = ["kubectl", "-n", namespace, "create", "secret", "generic", name]
|
||
|
|
for k, v in literals.items():
|
||
|
|
args += [f"--from-literal={k}={v}"]
|
||
|
|
args += ["--dry-run=client", "-o", "yaml"]
|
||
|
|
render = subprocess.run(args, capture_output=True, text=True)
|
||
|
|
if render.returncode != 0:
|
||
|
|
die(f"rendering secret {namespace}/{name}: {render.stderr}")
|
||
|
|
apply = subprocess.run(["kubectl", "apply", "-f", "-"], input=render.stdout,
|
||
|
|
capture_output=True, text=True)
|
||
|
|
if apply.returncode != 0:
|
||
|
|
die(f"applying secret {namespace}/{name}: {apply.stderr}")
|
||
|
|
print(f" secret {namespace}/{name}: {apply.stdout.strip()}")
|
||
if labels:
|
|||
|
|
# argocd's `$secret:key` substitution only reads Secrets carrying
|
||
|
|
# app.kubernetes.io/part-of: argocd — without it OIDC login fails with
|
||
|
|
# oauth2 "invalid_client" (empty client_secret sent to the IdP).
|
||
|
|
label_args = ["kubectl", "-n", namespace, "label", "secret", name,
|
||
|
|
"--overwrite"] + [f"{k}={v}" for k, v in labels.items()]
|
||
|
|
subprocess.run(label_args, capture_output=True, text=True)
|
||
|
|||
|
|
|
||
|
|
def get_or_create(list_path, create_path, query, payload, patch_existing=None):
|
||
|
|
status, res = api("GET", f"{list_path}?{query}")
|
||
|
|
if status != 200:
|
||
|
|
die(f"GET {list_path}?{query} -> {status} {res}")
|
||
|
|
results = res.get("results", [])
|
||
|
|
if results:
|
||
|
|
obj = results[0]
|
||
|
|
if patch_existing:
|
||
|
|
status, obj2 = api("PATCH", f"{create_path}{obj['pk']}/", patch_existing)
|
||
|
|
if status not in (200, 201):
|
||
|
|
die(f"PATCH {create_path}{obj['pk']}/ -> {status} {obj2}")
|
||
|
|
return obj2
|
||
|
|
return obj
|
||
|
|
status, obj = api("POST", create_path, payload)
|
||
|
|
if status not in (200, 201):
|
||
|
|
die(f"POST {create_path} -> {status} {obj}")
|
||
|
|
return obj
|
||
|
|
|
||
|
|
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
print("[1/5] Ensuring custom 'groups' scope mapping exists...")
|
||
|
|
groups_mapping = get_or_create(
|
||
|
|
"/api/v3/propertymappings/provider/scope/",
|
||
|
|
"/api/v3/propertymappings/provider/scope/",
|
||
|
|
"scope_name=groups",
|
||
|
|
{
|
||
|
|
"name": "homelab: groups claim",
|
||
|
|
"scope_name": "groups",
|
||
|
|
# request.user.ak_groups is deprecated in authentik 2026.x (logs a
|
||
|
|
# deprecation warning on every token issue) -> use request.user.groups.
|
||
|
|
"expression": (
|
||
|
|
"return {\"groups\": [group.name for group in request.user.groups.all()]}"
|
||
|
|
),
|
||
|
|
},
|
||
|
|
# Force the expression onto the already-created mapping on re-run.
|
||
|
|
patch_existing={
|
||
|
|
"expression": (
|
||
|
|
"return {\"groups\": [group.name for group in request.user.groups.all()]}"
|
||
|
|
),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
GROUPS_MAPPING_PK = groups_mapping["pk"]
|
||
|
|
|
||
# Generic "permissions" claim, computed from group membership - lets each app
|
|||
|
|
# (and eventually k8s RBAC via --oidc-groups-claim) check a permission string
|
||
|
|
# like "paperless:write" instead of hardcoding a group name. homelab-admins
|
||
|
|
# gets "*" (everything); every other admin group gets its own read+write pair.
|
||
|
|
# k8s-devops-admin is declared but has no k8s Role/RoleBinding target yet -
|
||
|
|
# foundation for a future short-lived federated-operator credential.
|
||
|
|
_PERMISSIONS_EXPR = """
|
||
|
|
GROUP_PERMISSIONS = {
|
||
# Universal admin
|
|||
"homelab-admins": ["*"],
|
|||
|
|||
|
|
# Service admin groups (full control)
|
||
"grafana-admins": ["grafana:read", "grafana:write"],
|
|||
|
|
"minio-admins": ["minio:read", "minio:write"],
|
||
|
|
"forgejo-admins": ["forgejo:read", "forgejo:write"],
|
||
|
|
"homarr-admins": ["homarr:read", "homarr:write"],
|
||
|
|
"portainer-admins": ["portainer:read", "portainer:write"],
|
||
|
|
"kmsvc-admins": ["kmsvc:read", "kmsvc:write"],
|
||
|
|
"temporal-admins": ["temporal:read", "temporal:write"],
|
||
"llm-admins": ["llm:read", "llm:write", "llm:inference"],
|
|||
"paperless-admins": ["paperless:read", "paperless:write"],
|
|||
"immich-admins": ["immich:read", "immich:write"],
|
|||
"poimen-memory-admins": ["memory:read", "memory:write", "memory:admin"],
|
|||
"k8s-devops-admin": ["k8s:devops"],
|
|||
"vault-service-api": ["vault:read", "vault:write"],
|
|||
|
|||
|
|
# Capability groups (non-admin users)
|
||
|
|
"llm-users": ["llm:inference"],
|
||
|
|
"memory-users": ["memory:read"],
|
||
|
|
"memory-writers": ["memory:read", "memory:write"],
|
||
"s3-users": ["s3:read"],
|
|||
|
|
"s3-writers": ["s3:read", "s3:write"],
|
||
|
|
"sqs-users": ["sqs:read"],
|
||
|
|
"sqs-writers": ["sqs:read", "sqs: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"]
|
||
|
|
|
||
# Roles claim for service accounts - for client_credentials flow, the auto-generated
|
|||
|
|
# user doesn't have roles, so we look up the actual service account by client_id.
|
||
|
|
# Format: ["llm:inference", "memory:read", "memory:write"]
|
||
|
|
_ROLES_EXPR = """
|
||
|
|
from authentik.core.models import User
|
||
|
|
|
||
|
|
# Try user's own roles first (password grant uses the actual user)
|
||
|
|
roles = request.user.attributes.get("roles", [])
|
||
|
|
|
||
|
|
# For client_credentials, user is auto-generated - look up by client_id
|
||
|
|
if not roles and request.http_request:
|
||
|
|
client_id = request.http_request.POST.get("client_id", "")
|
||
|
|
if client_id:
|
||
|
|
sa_user = User.objects.filter(username=client_id, path="service-accounts").first()
|
||
|
|
if sa_user:
|
||
|
|
roles = sa_user.attributes.get("roles", [])
|
||
|
|
|
||
|
|
return {"roles": roles}
|
||
|
|
""".strip()
|
||
|
|
roles_mapping = get_or_create(
|
||
|
|
"/api/v3/propertymappings/provider/scope/",
|
||
|
|
"/api/v3/propertymappings/provider/scope/",
|
||
|
|
"scope_name=roles",
|
||
|
|
{
|
||
|
|
"name": "homelab: roles claim",
|
||
|
|
"scope_name": "roles",
|
||
|
|
"expression": _ROLES_EXPR,
|
||
|
|
},
|
||
|
|
patch_existing={"expression": _ROLES_EXPR},
|
||
|
|
)
|
||
|
|
ROLES_MAPPING_PK = roles_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
|
||
|
|
# <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://img.riotpiao.com/auth/login",
|
|||
|
|
"https://img.riotpiao.com/user-settings",
|
||
|
|
"https://img.riotpiao.com/api/oauth/mobile-redirect",
|
||
],
|
|||
"launch_url": "https://img.riotpiao.com",
|
|||
"display_name": "Immich",
|
|||
|
|
},
|
||
"vault": {
|
|||
|
|
# Human/CLI login only (`vault login -method=oidc`) - not wired to any
|
||
|
|
# workload. No secret exists yet - generate + store on first run.
|
||
|
|
# localhost:8250/oidc/callback is the vault CLI's documented fixed
|
||
|
|
# callback port for `vault login -method=oidc`; the other is the
|
||
|
|
# browser/UI flow's callback path (mount path "oidc").
|
||
|
|
"client_secret_source": ("iam", "vault-oidc", "CLIENT_SECRET"),
|
||
|
|
"generate_if_missing": True,
|
||
|
|
"extra_secret_literals": {"client-id": "vault"},
|
||
|
|
"redirect_uris": [
|
||
|
|
"https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback",
|
||
|
|
"http://localhost:8250/oidc/callback",
|
||
|
|
],
|
||
|
|
"launch_url": "https://vault.riotpiao.com",
|
||
|
|
"display_name": "Vault",
|
||
|
|
},
|
||
"poimen-memory": {
|
|||
|
|
# Service-to-service API auth (no browser redirect) - generate secret on first run.
|
||
|
|
"client_secret_source": ("poimen", "poimen-memory-oidc", "CLIENT_SECRET"),
|
||
|
|
"generate_if_missing": True,
|
||
|
|
"extra_secret_literals": {"client-id": "poimen-memory"},
|
||
|
|
"redirect_uris": [], # No browser flow, service-to-service only
|
||
|
|
"launch_url": "https://memory.riotpiao.com",
|
||
|
|
"display_name": "Poimen Memory",
|
||
|
|
},
|
||
"local-llm": {
|
|||
|
|
# JWT auth for local LLM API access - service-to-service, no browser flow.
|
||
|
|
# Client validates JWT tokens issued by this provider using the public key.
|
||
|
|
"client_secret_source": ("llm-serving", "local-llm-jwt", "client-secret"),
|
||
|
|
"generate_if_missing": True,
|
||
|
|
"extra_secret_literals": {"client-id": "local-llm"},
|
||
|
|
"redirect_uris": [], # No browser flow, JWT/service-to-service only
|
||
|
|
"launch_url": "https://llm.riotpiao.com",
|
||
|
|
"display_name": "Local LLM",
|
||
|
|
},
|
||
}
|
|||
|
|
|
||
|
|
app_pks_for_binding = []
|
||
|
|
|
||
|
|
for name, cfg in SERVICES.items():
|
||
# Service-specific scope mappings:
|
|||
|
|
# - MinIO: "policy" claim for MINIO_IDENTITY_OPENID_CLAIM_NAME
|
||
|
|
# - Immich: "immich_role" for OAuth roleClaim
|
||
|
|
# - poimen-memory, local-llm: "memory" scope for fine-grained access
|
||
|
|
provider_mappings = SCOPE_PKS[:]
|
||
|
|
if name == "minio":
|
||
|
|
provider_mappings.append(POLICY_MAPPING_PK)
|
||
|
|
if name == "immich":
|
||
|
|
provider_mappings.append(IMMICH_ROLE_MAPPING_PK)
|
||
|
|
if name in ("poimen-memory", "local-llm", "portfolio-agent"):
|
||
|
|
provider_mappings.append(MEMORY_MAPPING_PK)
|
||
ns, secret_name, key = cfg["client_secret_source"]
|
|||
|
|
client_secret = kubectl_get_secret_key(ns, secret_name, key)
|
||
|
|
if client_secret is None:
|
||
|
|
if not cfg.get("generate_if_missing"):
|
||
|
|
print(f" WARNING: {ns}/{secret_name} key {key} not found and "
|
||
|
|
f"generate_if_missing not set for '{name}' - skipping provider/app")
|
||
|
|
continue
|
||
|
|
client_secret = gen_secret(40)
|
||
|
|
literals = {key: client_secret}
|
||
|
|
literals.update(cfg.get("extra_secret_literals", {}))
|
||
kubectl_create_secret(ns, secret_name, literals,
|
|||
|
|
labels=cfg.get("secret_labels"))
|
||
print(f" {name}: generated new client secret -> {ns}/{secret_name}")
|
|||
|
|
else:
|
||
|
|
print(f" {name}: using existing client secret from {ns}/{secret_name}")
|
||
|
|
|
||
if name == "paperless":
|
|||
|
|
# paperless-ngx's django-allauth OIDC config takes client_id/secret
|
||
|
|
# bundled inside one JSON blob (PAPERLESS_SOCIALACCOUNT_PROVIDERS), not
|
||
|
|
# discrete env vars - compose it here and store it alongside
|
||
|
|
# CLIENT_SECRET so the Deployment can source it directly via
|
||
|
|
# secretKeyRef, no shell wrapper needed. Runs every time (not just on
|
||
|
|
# generate), so it stays in sync if the client_secret is ever rotated
|
||
|
|
# by hand.
|
||
|
|
providers_json = json.dumps({
|
||
|
|
"openid_connect": {
|
||
|
|
"APPS": [{
|
||
|
|
"provider_id": "authentik",
|
||
|
|
"name": "Authentik",
|
||
|
|
"client_id": "paperless",
|
||
|
|
"secret": client_secret,
|
||
|
|
"settings": {
|
||
|
|
"server_url": "https://authentik.riotpiao.com/application/o/paperless/.well-known/openid-configuration",
|
||
# "groups"/"permissions" aren't default OIDC scopes -
|
|||
|
|
# must be requested explicitly for Authentik's scope
|
||
|
|
# mappings above to actually be returned. paperless's
|
||
|
|
# adapter.py ConfigMap reads the "permissions" claim
|
||
|
|
# to grant is_staff+is_superuser.
|
||
|
|
"scope": ["openid", "profile", "email", "groups", "permissions"],
|
||
},
|
|||
|
|
}],
|
||
|
|
},
|
||
|
|
})
|
||
|
|
kubectl_create_secret("paperless", "paperless-oidc", {
|
||
|
|
"CLIENT_SECRET": client_secret,
|
||
|
|
"SOCIALACCOUNT_PROVIDERS_JSON": providers_json,
|
||
|
|
})
|
||
|
|
|
||
if name == "immich":
|
|||
|
|
# Immich reads its whole system-config from IMMICH_CONFIG_FILE (a
|
||
|
|
# mounted JSON file, see k8s/apps/immich/deployment.yaml), not
|
||
|
|
# discrete env vars. "immich_role" must be in `scope` for Authentik
|
||
|
|
# to actually include that claim in the token (non-default scopes
|
||
|
|
# are opt-in per-client, same reason paperless requests "permissions"
|
||
|
|
# explicitly). roleClaim is re-evaluated on every login (immich-app/
|
||
|
|
# immich#29991) so this is the actual admin-grant mechanism for rock,
|
||
|
|
# not Immich's racy first-user-is-admin fallback.
|
||
|
|
immich_config_json = json.dumps({
|
||
|
|
"oauth": {
|
||
|
|
"enabled": True,
|
||
|
|
"issuerUrl": "https://authentik.riotpiao.com/application/o/immich/",
|
||
|
|
"clientId": "immich",
|
||
|
|
"clientSecret": client_secret,
|
||
|
|
"scope": "openid email profile immich_role",
|
||
|
|
"roleClaim": "immich_role",
|
||
|
|
"autoRegister": True,
|
||
|
|
"autoLaunch": False,
|
||
|
|
"buttonText": "Login with Authentik",
|
||
|
|
"mobileRedirectUri": "app.immich:///oauth-callback",
|
||
|
|
},
|
||
|
|
})
|
||
|
|
kubectl_create_secret("immich", "immich-oidc", {
|
||
|
|
"CLIENT_SECRET": client_secret,
|
||
|
|
"config.json": immich_config_json,
|
||
|
|
})
|
||
|
|
|
||
# Service-to-service (client_credentials): poimen-memory
|
|||
|
|
# Browser SSO (authorization_code): all others
|
||
|
|
grant_types = [
|
||
|
|
"urn:ietf:params:oauth:grant-type:device_code", # device code flow (CLI/headless)
|
||
|
|
"client_credentials" # service-to-service
|
||
|
|
] if name == "poimen-memory" else [
|
||
|
|
"authorization_code", # web SSO
|
||
|
|
"refresh_token" # long-lived sessions
|
||
|
|
]
|
||
|
|
|
||
provider = get_or_create(
|
|||
|
|
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
|
||
|
|
f"name={name}",
|
||
|
|
{
|
||
|
|
"name": name,
|
||
|
|
"client_id": name,
|
||
|
|
"client_secret": client_secret,
|
||
|
|
"client_type": "confidential",
|
||
|
|
"authorization_flow": AUTHORIZATION_FLOW_PK,
|
||
|
|
"invalidation_flow": INVALIDATION_FLOW_PK,
|
||
|
|
"signing_key": SIGNING_KEY_PK,
|
||
"property_mappings": provider_mappings,
|
|||
"sub_mode": "hashed_user_id",
|
|||
|
|
"include_claims_in_id_token": True,
|
||
|
|
# authentik 2026.x requires grant_types to be set explicitly; the
|
||
|
|
# API defaults it to [] when omitted, which makes /authorize reject
|
||
|
|
# every login with "Invalid grant_type for provider" ->
|
||
|
|
# invalid_request. authorization_code = the web SSO flow all these
|
||
|
|
# apps use; refresh_token = long-lived sessions (offline_access).
|
||
"grant_types": grant_types,
|
|||
"redirect_uris": [
|
|||
|
|
{"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"]
|
||
|
|
],
|
||
|
|
},
|
||
|
|
# Keep the redirect_uris/mappings/grant_types in sync on re-run, but
|
||
|
|
# never touch client_secret again once created (that's the source of
|
||
|
|
# truth in the k8s Secret, and re-sending it here is harmless anyway).
|
||
|
|
patch_existing={
|
||
"property_mappings": provider_mappings,
|
|||
"grant_types": grant_types,
|
|||
"redirect_uris": [
|
|||
|
|
{"matching_mode": "strict", "url": u} for u in cfg["redirect_uris"]
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
# superuser_full_list=true is REQUIRED on the LIST: the applications list
|
||
|
|
# applies access-policy filtering to the results array (these apps are bound
|
||
|
|
# to homelab-admins, and the bootstrap-token user akadmin is not a member),
|
||
|
|
# so without it the GET returns an empty results list even though the app
|
||
|
|
# exists -> fall through to POST -> 400 "already exists".
|
||
|
|
#
|
||
|
|
# We deliberately do NOT patch_existing here: the application DETAIL endpoint
|
||
|
|
# (PATCH /applications/{pk}/) enforces the same access policy and does NOT
|
||
|
|
# honor superuser_full_list, so PATCH-by-pk returns 404 for akadmin once the
|
||
|
|
# homelab-admins binding exists. That 404 aborted the loop before later
|
||
|
|
# providers got their grant_types. slug/provider/launch_url are set at
|
||
|
|
# creation and are stable (provider is get_or_create'd by name, stable pk),
|
||
|
|
# so find-or-create is sufficient.
|
||
|
|
application = get_or_create(
|
||
|
|
"/api/v3/core/applications/", "/api/v3/core/applications/",
|
||
|
|
f"slug={name}&superuser_full_list=true",
|
||
|
|
{
|
||
|
|
"name": cfg["display_name"],
|
||
|
|
"slug": name,
|
||
|
|
"provider": provider["pk"],
|
||
|
|
"meta_launch_url": cfg["launch_url"],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
app_pks_for_binding.append((name, application["pk"]))
|
||
|
|
print(f" {name}: provider pk={provider['pk']} application pk={application['pk']}")
|
||
|
|
|
||
# -----------------------------------------------------------------------------
|
|||
|
|
# Separate from the SERVICES loop above: this is a PUBLIC client (PKCE, no
|
||
|
|
# client_secret) for `kubectl` OIDC login, not a confidential-client app
|
||
|
|
# login. Foundation for k8s/infra/rbac/ - kube-apiserver's --oidc-* flags
|
||
|
|
# (controlplane.tftpl) validate tokens issued against this provider.
|
||
|
|
# Redirect URI matches kubelogin's (int128/kubelogin) documented default;
|
||
|
|
# adjust here if a different kubectl OIDC plugin/port is actually used.
|
||
|
|
print("Ensuring public OAuth2 client 'kubernetes' for kubectl OIDC login...")
|
||
|
|
k8s_provider = get_or_create(
|
||
|
|
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
|
||
|
|
"name=kubernetes",
|
||
|
|
{
|
||
|
|
"name": "kubernetes",
|
||
|
|
"client_id": "kubernetes",
|
||
|
|
"client_type": "public",
|
||
|
|
"authorization_flow": AUTHORIZATION_FLOW_PK,
|
||
|
|
"invalidation_flow": INVALIDATION_FLOW_PK,
|
||
|
|
"signing_key": SIGNING_KEY_PK,
|
||
|
|
"property_mappings": SCOPE_PKS,
|
||
|
|
"sub_mode": "hashed_user_id",
|
||
|
|
"include_claims_in_id_token": True,
|
||
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
||
|
|
"redirect_uris": [
|
||
|
|
{"matching_mode": "strict", "url": "http://localhost:8000"},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
patch_existing={
|
||
|
|
"property_mappings": SCOPE_PKS,
|
||
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
||
|
|
"redirect_uris": [
|
||
|
|
{"matching_mode": "strict", "url": "http://localhost:8000"},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
k8s_application = get_or_create(
|
||
|
|
"/api/v3/core/applications/", "/api/v3/core/applications/",
|
||
|
|
"slug=kubernetes&superuser_full_list=true",
|
||
|
|
{
|
||
|
|
"name": "Kubernetes",
|
||
|
|
"slug": "kubernetes",
|
||
|
|
"provider": k8s_provider["pk"],
|
||
|
|
"meta_launch_url": "https://authentik.riotpiao.com",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
app_pks_for_binding.append(("kubernetes", k8s_application["pk"]))
|
||
|
|
print(f" kubernetes: provider pk={k8s_provider['pk']} application pk={k8s_application['pk']}")
|
||
|
|
|
||
# -----------------------------------------------------------------------------
|
|||
# Headless authentication flow for service accounts (password grant).
|
|||
|
|
# Default flow has MFA/interactive stages that break password grant.
|
||
|
|
# This flow: identification -> password -> login (no MFA, no consent prompts).
|
||
|
|
print("\n[SERVICE ACCOUNT FLOW] Ensuring headless authentication flow...")
|
||
|
|
|
||
|
|
sa_flow = get_or_create(
|
||
|
|
"/api/v3/flows/instances/", "/api/v3/flows/instances/",
|
||
|
|
"slug=service-account-authentication",
|
||
|
|
{
|
||
|
|
"slug": "service-account-authentication",
|
||
|
|
"name": "Service Account Authentication (Headless)",
|
||
|
|
"title": "Service Account Login",
|
||
|
|
"designation": "authentication",
|
||
|
|
"policy_engine_mode": "any",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
SA_AUTHENTICATION_FLOW_PK = sa_flow["pk"]
|
||
|
|
print(f" flow pk={SA_AUTHENTICATION_FLOW_PK}")
|
||
|
|
|
||
|
|
# Bind stages to the flow (identification -> password -> login)
|
||
|
|
# Get default stages (reuse existing ones)
|
||
|
|
status, ident_stages = api("GET", "/api/v3/stages/identification/")
|
||
|
|
status, pass_stages = api("GET", "/api/v3/stages/password/")
|
||
|
|
status, login_stages = api("GET", "/api/v3/stages/user_login/")
|
||
|
|
|
||
|
|
if ident_stages.get("results") and pass_stages.get("results") and login_stages.get("results"):
|
||
|
|
ident_pk = ident_stages["results"][0]["pk"]
|
||
|
|
pass_pk = pass_stages["results"][0]["pk"]
|
||
|
|
login_pk = login_stages["results"][0]["pk"]
|
||
|
|
|
||
|
|
# Check if bindings already exist
|
||
|
|
status, existing_bindings = api("GET", f"/api/v3/flows/bindings/?target={SA_AUTHENTICATION_FLOW_PK}")
|
||
|
|
if not existing_bindings.get("results"):
|
||
|
|
# Create bindings
|
||
|
|
api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": ident_pk, "order": 10})
|
||
|
|
api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": pass_pk, "order": 20})
|
||
|
|
api("POST", "/api/v3/flows/bindings/", {"target": SA_AUTHENTICATION_FLOW_PK, "stage": login_pk, "order": 30})
|
||
|
|
print(" bound stages: identification -> password -> login")
|
||
|
|
else:
|
||
|
|
print(f" stages already bound ({len(existing_bindings['results'])} bindings)")
|
||
|
|
else:
|
||
|
|
print(" WARNING: Could not find default stages to bind")
|
||
|
|
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
# Service accounts for programmatic API access (password grant).
|
||
# These are Authentik users with type=service_account, not OAuth applications.
|
|||
# They authenticate via password grant to get JWTs with user claims.
|
|||
print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...")
|
|||
|
|
|
||
# Roles define what APIs a service can access - stored in user attributes,
|
|||
|
|
# output as "roles" claim in JWT. Gateway checks roles, not groups.
|
||
|
|
# Format: "<api>:<action>" e.g. "llm:inference", "memory:write"
|
||
SERVICE_ACCOUNTS = {
|
|||
|
|
"portfolio-agent": {
|
||
"roles": ["llm:inference", "memory:read", "s3:read", "sqs:read", "temporal:admin"],
|
|||
"attributes": {
|
|||
|
|
"memory_projects": ["homelab", "portfolio"],
|
||
|
|
"memory_visibility": "public",
|
||
|
|
},
|
||
|
|
"secret_ns": "portfolio",
|
||
|
|
"secret_name": "portfolio-agent-oidc",
|
||
|
|
},
|
||
|
|
"memory-agent": {
|
||
"roles": ["llm:inference", "memory:read", "memory:write", "s3:read", "s3:write", "sqs:read", "sqs:write"],
|
|||
"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}")
|
||
|
|
|
||
# Merge roles into attributes
|
|||
|
|
sa_attributes = {**sa_cfg["attributes"], "roles": sa_cfg["roles"]}
|
||
|
|
|
||
if res.get("results"):
|
|||
|
|
sa_user = res["results"][0]
|
||
# Update attributes (roles stored here, not in groups)
|
|||
status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", {
|
|||
"attributes": sa_attributes,
|
|||
"is_active": True,
|
|||
|
|
})
|
||
|
|
if status not in (200, 201):
|
||
|
|
die(f"PATCH service account {sa_name} -> {status} {sa_user}")
|
||
print(f" {sa_name}: updated (roles: {sa_cfg['roles']})")
|
|||
else:
|
|||
|
|
# Create new service account user
|
||
|
|
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_attributes,
|
|||
})
|
|||
|
|
if status not in (200, 201):
|
||
|
|
die(f"POST service account {sa_name} -> {status} {sa_user}")
|
||
print(f" {sa_name}: created (roles: {sa_cfg['roles']})")
|
|||
|
|
|
||
|
|
print(f" {sa_name}: roles={sa_cfg['roles']}")
|
||
|
|||
# Create OAuth provider for this service account
|
|||
|
|
# Supports both password grant (user claims) and client_credentials (fallback)
|
||
|
|
# Password grant requires authentication_flow and app_password token
|
||
sa_client_secret = existing_secret or gen_secret(40)
|
|||
sa_grant_types = ["password", "client_credentials", "refresh_token"]
|
|||
sa_provider = get_or_create(
|
|||
|
|
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
|
||
|
|
f"name={sa_name}",
|
||
|
|
{
|
||
|
|
"name": sa_name,
|
||
|
|
"client_id": sa_name,
|
||
|
|
"client_secret": sa_client_secret,
|
||
|
|
"client_type": "confidential",
|
||
|
|
"authorization_flow": AUTHORIZATION_FLOW_PK,
|
||
"authentication_flow": SA_AUTHENTICATION_FLOW_PK, # Headless flow for password grant
|
|||
"invalidation_flow": INVALIDATION_FLOW_PK,
|
|||
|
|
"signing_key": SIGNING_KEY_PK,
|
||
"property_mappings": SCOPE_PKS + [ROLES_MAPPING_PK, MEMORY_MAPPING_PK],
|
|||
"sub_mode": "hashed_user_id",
|
|||
|
|
"include_claims_in_id_token": True,
|
||
"grant_types": sa_grant_types,
|
|||
|
|
"redirect_uris": [],
|
||
},
|
|||
|
|
patch_existing={
|
||
"property_mappings": SCOPE_PKS + [ROLES_MAPPING_PK, MEMORY_MAPPING_PK],
|
|||
"grant_types": sa_grant_types,
|
|||
|
|
"authentication_flow": SA_AUTHENTICATION_FLOW_PK,
|
||
},
|
|||
|
|
)
|
||
|
|
|
||
|
|
# Create application for the service account
|
||
|
|
sa_application = get_or_create(
|
||
|
|
"/api/v3/core/applications/", "/api/v3/core/applications/",
|
||
|
|
f"slug={sa_name}&superuser_full_list=true",
|
||
|
|
{
|
||
|
|
"name": f"Service Account: {sa_name}",
|
||
|
|
"slug": sa_name,
|
||
|
|
"provider": sa_provider["pk"],
|
||
|
|
"meta_launch_url": "",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
# Service account apps have NO policy bindings - client_secret is the access control.
|
|||
|
|
# Clean up any existing bindings (from old provisioning logic).
|
||
|
|
status, existing_bindings = api("GET", f"/api/v3/policies/bindings/?target={sa_application['pk']}")
|
||
|
|
if status == 200:
|
||
|
|
for binding in existing_bindings.get("results", []):
|
||
|
|
api("DELETE", f"/api/v3/policies/bindings/{binding['pk']}/")
|
||
|
|
print(f" {sa_name}: removed stale binding {binding.get('group_obj', {}).get('name', binding['pk'])}")
|
||
|
|
|
||
|
|
print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}")
|
||
|
|
|
||
|
|
# Create App Password token for password grant (optional, client_credentials also works)
|
||
# Authentik's password grant checks against Token with intent=app_password, not user password
|
|||
|
|
# Note: API doesn't allow setting key at creation, must use set_key endpoint after
|
||
|
|
existing_app_password = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "APP_PASSWORD")
|
||
|
|
if not existing_app_password:
|
||
|
|
token_identifier = f"{sa_name}-app-password"
|
||
|
|
|
||
|
|
# Check if token exists
|
||
|
|
status, existing_tokens = api("GET", f"/api/v3/core/tokens/?identifier={token_identifier}")
|
||
|
|
if status == 200 and existing_tokens.get("results"):
|
||
|
|
# Token exists, just set a new key
|
||
|
|
pass
|
||
|
|
else:
|
||
|
|
# Create the token first
|
||
|
|
status, token_resp = api("POST", "/api/v3/core/tokens/", {
|
||
|
|
"identifier": token_identifier,
|
||
|
|
"user": sa_user["pk"],
|
||
|
|
"intent": "app_password",
|
||
|
|
"expiring": False,
|
||
|
|
"description": f"App password for {sa_name} OAuth password grant",
|
||
|
|
})
|
||
|
|
if status not in (200, 201):
|
||
|
|
print(f" WARNING: Failed to create app password token for {sa_name}: {token_resp}")
|
||
|
|
|
||
|
|
# Set the key via set_key endpoint (works on existing or newly created token)
|
||
|
|
app_password_key = gen_secret(40)
|
||
|
|
status, _ = api("POST", f"/api/v3/core/tokens/{token_identifier}/set_key/", {
|
||
|
|
"key": app_password_key,
|
||
})
|
|||
if status not in (200, 204):
|
|||
|
|
print(f" WARNING: Failed to set app password key for {sa_name}")
|
||
|
|
app_password_key = None
|
||
|
|
else:
|
||
|
|
print(f" {sa_name}: created/updated app password token")
|
||
|
|
else:
|
||
|
|
app_password_key = existing_app_password
|
||
|
|
print(f" {sa_name}: reusing existing app password")
|
||
|
|
|
||
|
|
# Store credentials in k8s Secret
|
||
|
|
# Supports both password grant (APP_PASSWORD) and client_credentials (CLIENT_SECRET)
|
||
|
|
secret_data = {
|
||
|
|
"CLIENT_ID": sa_name,
|
||
|
|
"CLIENT_SECRET": sa_client_secret,
|
||
|
|
"USERNAME": sa_name,
|
||
|
|
"TOKEN_URL": "https://authentik.riotpiao.com/application/o/token/",
|
||
|
|
"ISSUER": f"https://authentik.riotpiao.com/application/o/{sa_name}/",
|
||
|
|
}
|
||
|
|
if app_password_key:
|
||
|
|
secret_data["APP_PASSWORD"] = app_password_key
|
||
|
|
|
||
|
|
if not existing_secret or (app_password_key and not existing_app_password):
|
||
|
|
kubectl_create_secret(sa_cfg["secret_ns"], sa_cfg["secret_name"], secret_data)
|
||
|
|
print(f" {sa_name}: stored credentials -> {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}")
|
||
else:
|
|||
|
|
print(f" {sa_name}: reusing existing credentials from {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}")
|
||
|
|
|
||
|
|
print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}")
|
||
|
|
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
print("\n[5/5] Binding homelab-admins to every application (guaranteed access for rock)...")
|
||
for name, app_pk in app_pks_for_binding:
|
|||
|
|
get_or_create(
|
||
|
|
"/api/v3/policies/bindings/", "/api/v3/policies/bindings/",
|
||
|
|
f"target={app_pk}&group={homelab_admins['pk']}",
|
||
|
|
{
|
||
|
|
"target": app_pk,
|
||
|
|
"group": homelab_admins["pk"],
|
||
|
|
"order": 0,
|
||
|
|
"enabled": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
print(f" {name}: homelab-admins bound")
|
||
|
|
|
||
# Per-service admin groups are app-scoped (unlike homelab-admins' blanket
|
|||
|
|
# binding above) - only grants visibility/access to that one application.
|
||
# portainer/kmsvc/temporal have no Authentik Application (no OIDC
|
|||
# login integration), so their groups exist for the "permissions" claim /
|
|||
|
|
# future k8s RBAC only - nothing to bind here.
|
||
|
|
SERVICE_GROUP_TO_APP_SLUG = {
|
||
|
|
"grafana-admins": "grafana",
|
||
|
|
"minio-admins": "minio",
|
||
|
|
"forgejo-admins": "forgejo",
|
||
|
|
"homarr-admins": "homarr",
|
||
|
|
"paperless-admins": "paperless",
|
||
"immich-admins": "immich",
|
|||
"llm-admins": "local-llm",
|
|||
"llm-users": "local-llm",
|
|||
|
|
"poimen-memory-admins": "poimen-memory",
|
||
|
|
"memory-users": "poimen-memory",
|
||
|
|
"memory-writers": "poimen-memory",
|
||
}
|
|||
|
|
for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items():
|
||
|
|
app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None)
|
||
|
|
if not app_pk:
|
||
|
|
continue
|
||
|
|
group_pk = service_admin_groups[group_name]["pk"]
|
||
|
|
get_or_create(
|
||
|
|
"/api/v3/policies/bindings/", "/api/v3/policies/bindings/",
|
||
|
|
f"target={app_pk}&group={group_pk}",
|
||
|
|
{
|
||
|
|
"target": app_pk,
|
||
|
|
"group": group_pk,
|
||
|
|
"order": 0,
|
||
|
|
"enabled": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
print(f" {app_slug}: {group_name} bound")
|
||
|
|
|
||
# JWT configuration for local-llm
|
|||
|
|
print("\n[JWT] Fetching Authentik signing key for local-llm...")
|
||
|
|
status, signing_key_res = api("GET", f"/api/v3/crypto/certificatekeypairs/{SIGNING_KEY_PK}/")
|
||
|
|
if status == 200:
|
||
|
|
jwt_cert = signing_key_res.get("certificate", "")
|
||
|
|
print(f" Public certificate available for JWT validation (base64-encoded below)\n")
|
||
|
|
import base64
|
||
|
|
cert_b64 = base64.b64encode(jwt_cert.encode()).decode()
|
||
|
|
print(f"Save this to local-llm config for JWT token validation:")
|
||
|
|
print(f" AUTHENTIK_JWT_CERT={cert_b64}")
|
||
|
|
print(f"\nJWT issuer URL: https://authentik.riotpiao.com/application/o/local-llm/")
|
||
|
|
print(f"Local-LLM credentials are stored in: kubectl -n llm-serving get secret local-llm-jwt")
|
||
|
|
|
||
print("\nDone. Summary:")
|
|||
print(" groups: homelab-admins (superuser) + " + ", ".join(SERVICE_ADMIN_GROUP_NAMES))
|
|||
|
|
print(" user: rock -> homelab-admins + all service admin groups")
|
||
print(f" apps: {', '.join(n for n, _ in app_pks_for_binding)}")
|
|||
|
|
if rock_password:
|
||
|
|
print(" NOTE: rock's password was generated this run - see")
|
||
|
|
print(" kubectl -n iam get secret rock-credentials -o jsonpath='{.data.password}' | base64 -d")
|