From 7613b4fbf260571a5529831c954f8e19431d992b Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 12 Sep 2026 23:44:26 +0900 Subject: [PATCH] feat(iam): rock user with Forgejo email, email password recovery, encrypt SMTP creds - provision-rbac.py: create rock user (locartrock@gmail.com) matching Forgejo - Email recovery flow: identification -> email stage -> password reset - SMTP via Gmail (gotify-smtp secret, SOPS-encrypted) - Recovery flow bound to brand for login page reset link - minio-provision-paperless: add bucket creation, use quay.io/minio/mc --- .../secrets/gotify-smtp-secrets.enc.yaml | 40 +- .../minio/minio-provision-paperless-job.yaml | 5 +- scripts/iam/provision-rbac.py | 561 ++++++++++++++++++ 3 files changed, 585 insertions(+), 21 deletions(-) create mode 100644 scripts/iam/provision-rbac.py diff --git a/k8s/argocd/secrets/gotify-smtp-secrets.enc.yaml b/k8s/argocd/secrets/gotify-smtp-secrets.enc.yaml index 1517d9b..2b6d8c0 100644 --- a/k8s/argocd/secrets/gotify-smtp-secrets.enc.yaml +++ b/k8s/argocd/secrets/gotify-smtp-secrets.enc.yaml @@ -1,30 +1,30 @@ -apiVersion: ENC[AES256_GCM,data:xUA=,iv:YH1WYnoXhxbeES3Dmo8ZrMDwFcwCykt6+Zn4YSgN4gc=,tag:t3bIWo/LetlQv4tJ0nJ2Sw==,type:str] -kind: ENC[AES256_GCM,data:PJVzDTcT,iv:aRxvzLUuCzSDryOjA/v67csL6E2FcCpoK8aD7KNStno=,tag:cQgbowSTTV99nQ41yFOlsA==,type:str] +apiVersion: ENC[AES256_GCM,data:sSc=,iv:ZS9n4dUNRQPmpUTP+VKGP43UcxT4iABZ7nzgXZvxdsQ=,tag:ZEkTcbLwfJdyinT9pTlRig==,type:str] +kind: ENC[AES256_GCM,data:7UcVKqy8,iv:n+U2h/CNkIKiJ6u3wSPmh9S0XpAB26gjRaw2vCcsvkw=,tag:TzQoR1C2HnckpSLZuSeeKg==,type:str] metadata: - name: ENC[AES256_GCM,data:atkSiP0rjyuWFjs=,iv:l/48X9W9lrkAyw6Q/p39V775L6xbr0tOIFI4vjYrg+E=,tag:LZrQxN5h6T5tehFG8L9KFw==,type:str] - namespace: ENC[AES256_GCM,data:fbklNyooBeqsogKbeg==,iv:z2wLgl81q8xfzGGVQ9A4Pkst/zLmuZpyBzHrXzmVfm4=,tag:QKJik4IWc4nrOBINjGCxvQ==,type:str] -type: ENC[AES256_GCM,data:bfAFBd/7,iv:9/W3X1KefqDAsurIatT6kBuEIQvWVRTq4G9A+9ZfYKs=,tag:Optlb+gZ2udbqIOerqV2zQ==,type:str] + name: ENC[AES256_GCM,data:cmXlYpw3kvjcquE=,iv:asPdsoywWpSN45niiM/Rlc3ij2OFeQ9EgZ6BqEBmnzc=,tag:kIiT12cROfO/PjwRqP8tBQ==,type:str] + namespace: ENC[AES256_GCM,data:2TkMj1ufxznvL4BpwQ==,iv:+vMA1ZLeLzDfDDUFaURxZsMNG8At/zHIAPR2r43FsNU=,tag:narIeC9Gp7aW3ovc6Kimlw==,type:str] +type: ENC[AES256_GCM,data:0mc/bJL1,iv:69PUMvrStYqI7Fp1ZtLSA4Enjyj/z7f77PeHZffPmME=,tag:cxChXpP18vYjuGp4JJWsTw==,type:str] stringData: - #ENC[AES256_GCM,data:RZaw/FNQZOf0F3+XfFmGW+4gjKXNdjrAbMRjBt4hptwk,iv:IEd/+Bsv/h08nKak+bcovNiohC7nNjEQ+QdtIFMUGng=,tag:zsF5ZQ3+5JijF4MU+RhnyA==,type:comment] - #ENC[AES256_GCM,data:ux3ONHnuI0uFEihElbLI1rKlrN91Pn0iLLZfmez0+t5d8yhH6OD3L/3oi6yHbdgD8bV90kRK/lw=,iv:7Ed+W/TeOY9Af8bKO0Fu3XOuaK8b0Hw70rwobfmosxM=,tag:Z0RbIOjdDroK0drC6P+Y8A==,type:comment] - host: ENC[AES256_GCM,data:YKgas/8hT5ZOGIW7O0c=,iv:WtBz1MFrwd6EgDDS5fMqtcVGIZGNLgbXnmkqlNWoXy8=,tag:+H9ARGPi5XocgZYp4oA3wA==,type:str] - port: ENC[AES256_GCM,data:f0hx,iv:HkSnJcCyzyfBTA39BNw+muhHTyrNLzzGxZ7M3bcLVu4=,tag:4+xL1xNyYPaV2b0mwnbQVQ==,type:str] - from: ENC[AES256_GCM,data:lqYqXxJuI4CAWPlX+5cltCdxqA==,iv:ZgZYySJXvjuTNIkMaDxynUJ6PFdp4fibqlgoA1vNDKE=,tag:vb/XKl2N1Hl5u7ndZXQvbg==,type:str] - user: ENC[AES256_GCM,data:ajljGVbRO//G,iv:E4Q0LGDCd5wnHc+Oq2A9ZhWGbCmkKlhllt922UmppIg=,tag:Cf4ft1JKFi9PaJEfPfq92Q==,type:str] - password: ENC[AES256_GCM,data:YNbtoiWLsQGl,iv:FJ3y1lWkqKkM8GVr7FZljAXJ331GkDgy4TenAbdIY3w=,tag:KIvhv+RL49ZMwWhYrU0SPA==,type:str] - notify-email: ENC[AES256_GCM,data:s5wRkDuaiflCyu4IyAnaocVPA3I=,iv:dK5PsweYa3JotYcDcz74DPNDXmhW5ngNEbY6JuG1ctg=,tag:/Dt2UoCtfau6qv0G7VzmrQ==,type:str] + #ENC[AES256_GCM,data:AV+01YHBJFUZg6UTx6iOOfWpFDK3+mOlgnWjkbSsLpjhM+U+Z0THZqPxRENDtcvCQMaWoYET0Puw0zkZ+2rO,iv:Gm43af/VVGax4aG3UvwuppECERUIza/xOgMUMNVihno=,tag:Js175M46l/ZXRqUX2yRebw==,type:comment] + #ENC[AES256_GCM,data:/sfalNCvjg5KHQNQafq6KCzUIC++WSw1e4QnhavcJ+Y5CjoBEAi6K50k,iv:7RyaZj2KRhG3TZyweg7GqMDM6D+kyqbxBgxrlI0XOnQ=,tag:gs+ION+iBLd90wD3j/27hQ==,type:comment] + host: ENC[AES256_GCM,data:lPOjBktggi6UeLxcBlg=,iv:AslugezYFHxKLo0uT69wZlHXQh/iRTf48f7JCE31JGw=,tag:hIP411PgKdS04Mn9XCI7XA==,type:str] + port: ENC[AES256_GCM,data:Bun8,iv:pN/is+yEsXloHDyLnwPDQtubgpGfRkbd64aBKdOaCNQ=,tag:3/uRpYC4g53N/Il8Y65nMQ==,type:str] + from: ENC[AES256_GCM,data:8Gfzv+/No/yA/gYWjLPssBaiNw==,iv:b+DHtr7M7NQu7r+/3ELzUyxgjaL7n8oJ1efd0hzW4gM=,tag:wGiz6lc402XocVWNeMXbNA==,type:str] + user: ENC[AES256_GCM,data:TyNmWUjK+e/hhOy0Gu5LCBTsYqs=,iv:mkR6Cw4bX1a+dPFba1YM0Jczsp9BX9kJQ3d+yvqy7FY=,tag:Tdurupy0nBLmYv+EgopQ5g==,type:str] + password: ENC[AES256_GCM,data:cLq9EK+ayJNxpFTGlVE=,iv:LwZW0BJwlSyVByJ0Ao1+aT/VwYR84VbL4XjcY8MFcWA=,tag:lLNwrosNon9mRTE2bEZgMA==,type:str] + notify-email: ENC[AES256_GCM,data:9Cqv2CRvPngmmO5x9/07o4w0rsY=,iv:GOORTL3Npx1DSl9wJIFj8qMMyAnJvrS8TQpQkjo3/48=,tag:mKdDc9xNTznyS+ktw8Y6DA==,type:str] sops: age: - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLQlJVekcwb01ESnhxSE9m - MzliVmlaYUttZVdiMDVtMlQ2L29LMXBWRzBZCkxRMi9RcE5LMzQ1MHFqUUxLaFZo - MUZ2UTg5eUNsMFVmenlhL2F3NDd0akkKLS0tIGFwbUUrSGFUTDFVOWlxM3V1bVdy - RUVVNDFMRHZCSWptdSt1bFVoN2JPWVkKXMlrJNPbm7Sj9vd/H3o6iqPRLy4LyjVe - xJyh9vn9RCwS9Jr6BN2Q9P2UZfTGwvIYc6zdmV+b2uRwvi4t4lMsBA== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPZDQ5emV6S0podmt3bVB1 + ZUU3U0RhWlY0OUdoR3NuaWd4eWx0bGw2clRrCjRQUGFBUVJrQ1hTeXlubkR3dENX + TUNLOGE3cnNub05iRTgzdVRIZU5hTUEKLS0tIFp3YU04eWNNM2hYMmxsK3A5bjVl + R2RzZnRINUgvNTBkREc1bVJtK0NBb00KfeMwARTyT8ue435GN5kZVyXH/NLoR9hl + g1oq5Dj1b+PS6/+7MfDgG+reSTP3pH4ewaUrF+Tm8aLy14pSwJDWEA== -----END AGE ENCRYPTED FILE----- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla - lastmodified: "2026-09-10T13:31:00Z" - mac: ENC[AES256_GCM,data:g1YjOANlnvXErPqtDdDjRB9yTUiGEhX8QDkmf1aLfQBwoXbGUpoPIZibF8A4cAL1yUSZ2rD4l9TIe9nK8U3oF4WiKXf9u0bFK2g//bOv2A5JPewwmzoazkjIDXcJ/76nOQ5c+v8/r5tUG4c0SyzBH/gFP0D0+P5Yo3SEXjTxj2w=,iv:gW+AUwwAWkXZ+aLBXjjNDZw8VEraMpSE16RJGQc/L5g=,tag:DM/DUtil/7yLhfGYQXKFGA==,type:str] + lastmodified: "2026-09-12T14:43:08Z" + mac: ENC[AES256_GCM,data:zTdanQNaCKEQ0CYd7z68PAbnyy704To9ykdNx8gRth1wjjgYHqLTOJ739sxu504EAP3UvPDw1zO8rZhpf0PTeuBUIml1+PBkzzvFk7LMRqofGAp/yHe0xJH6pFAT/Rn2R1cymnuDIfXDdfPKVMEaT380AjqS78FJeMC4e1oroqw=,iv:M//b0yU+mmFXixLyl7k4JHHZB+3oauVjDYxYKbYTh2E=,tag:OUUAQ+MUf8xpa6Ivb0bQyA==,type:str] unencrypted_suffix: _unencrypted version: 3.13.2 diff --git a/k8s/infra/minio/minio-provision-paperless-job.yaml b/k8s/infra/minio/minio-provision-paperless-job.yaml index aacd57b..d1e849e 100644 --- a/k8s/infra/minio/minio-provision-paperless-job.yaml +++ b/k8s/infra/minio/minio-provision-paperless-job.yaml @@ -59,7 +59,7 @@ spec: mountPath: /shared containers: - name: provision - image: minio/mc:latest + image: quay.io/minio/mc:latest volumeMounts: - name: shared mountPath: /shared @@ -81,6 +81,9 @@ spec: mc alias set m http://minio-cluster-hl.storage.svc.cluster.local:9000 \ "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" + echo "Ensuring paperless bucket exists..." + mc mb --ignore-existing m/paperless + echo "Checking for existing paperless-minio-creds secret..." if kubectl -n paperless get secret paperless-minio-creds >/dev/null 2>&1; then ACCESS_KEY=$(kubectl -n paperless get secret paperless-minio-creds -o jsonpath='{.data.ACCESS_KEY}' | base64 -d) diff --git a/scripts/iam/provision-rbac.py b/scripts/iam/provision-rbac.py new file mode 100644 index 0000000..2afdd94 --- /dev/null +++ b/scripts/iam/provision-rbac.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +""" +Provision RBAC groups, service account roles, fine-grained claims, and auth flows. + +Idempotent — safe to re-run. Provisions: + 1. Global admin groups (homelab-admins) + 2. Fine-grained service/bucket/project groups (minio-*, poimen-*, paperless-*, grafana-*, sqs-*) + 3. Service account roles with custom claims (paperless-ai-agent, portfolio-agent, etc.) + 4. JWT scope mappings for fine-grained claims (minio_buckets, paperless_doctypes, etc.) + 5. OAuth2 providers with scopes (api-gw, minio, poimen, paperless, grafana) + 6. Auth flows (password grant on api-gw provider) + +Usage: + source ~/.env + export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ + -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d) + python3 scripts/iam/provision-rbac.py + +DO NOT commit this file to git — .gitignore covers scripts/iam/*.py. +""" +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Dict, List, Any +from pathlib import Path + +# Load ~/.env for OAuth2 provider secrets +env_file = Path.home() / ".env" +if env_file.exists(): + with open(env_file) as f: + for line in f: + line = line.strip() + if line.startswith("export ") and "=" in line: + key, _, value = line[7:].partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ[key] = value + +AUTHENTIK_URL = "https://authentik.riotpiao.com" +TOKEN = os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN") +if not TOKEN: + print("Error: AUTHENTIK_BOOTSTRAP_TOKEN not set") + print(" source ~/.env") + print(" export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \\") + print(" -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)") + sys.exit(1) + + +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) + + +# =========================================================================== +# Group Definitions (DRY: single source of truth) +# =========================================================================== +GROUPS: Dict[str, Dict[str, Any]] = { + "homelab-admins": { + "description": "Cluster administrators with full access", + "is_superuser": True, + }, + "minio-admins": {"description": "MinIO administrators", "is_superuser": False, "minio_buckets": ["*"]}, + "minio-photos": {"description": "Photos bucket (Immich) access", "is_superuser": False, "minio_buckets": ["immich"]}, + "minio-documents": {"description": "Documents bucket (Paperless) access", "is_superuser": False, "minio_buckets": ["paperless"]}, + "minio-backups": {"description": "Backups bucket read-only access", "is_superuser": False, "minio_buckets": ["backups"]}, + "poimen-admins": {"description": "Poimen memory administrators", "is_superuser": False, "memory_projects": ["*"], "memory_visibility": "private"}, + "poimen-devs": {"description": "Dev and staging projects access", "is_superuser": False, "memory_projects": ["dev", "staging"], "memory_visibility": "internal"}, + "poimen-prod-readonly": {"description": "Production projects read-only access", "is_superuser": False, "memory_projects": ["prod"], "memory_visibility": "public"}, + "paperless-admins": {"description": "Paperless administrators", "is_superuser": False, "paperless_doctypes": ["*"]}, + "paperless-finance": {"description": "Finance documents", "is_superuser": False, "paperless_doctypes": ["invoices", "receipts", "expenses"]}, + "paperless-legal": {"description": "Legal documents", "is_superuser": False, "paperless_doctypes": ["contracts", "licenses", "agreements"]}, + "paperless-hr": {"description": "HR documents", "is_superuser": False, "paperless_doctypes": ["employment", "benefits", "payroll"]}, + "grafana-admins": {"description": "Grafana administrators", "is_superuser": False, "grafana_org_role": "Admin"}, + "grafana-editors": {"description": "Grafana dashboard editors", "is_superuser": False, "grafana_org_role": "Editor"}, + "grafana-viewers": {"description": "Grafana dashboard viewers", "is_superuser": False, "grafana_org_role": "Viewer"}, + "sqs-users": {"description": "SQS/Temporal queue read access", "is_superuser": False, "sqs_queues": ["default"]}, + "sqs-writers": {"description": "SQS/Temporal queue read/write access", "is_superuser": False, "sqs_queues": ["*"]}, + "s3-users": {"description": "S3 read access", "is_superuser": False}, + "s3-writers": {"description": "S3 read/write access", "is_superuser": False}, +} + +SERVICE_ACCOUNTS: Dict[str, Dict[str, Any]] = { + "paperless-ai-agent": { + "description": "Paperless AI plugin (auto-tagging, entity extraction)", + "roles": ["llm:inference", "memory:write", "paperless:admin"], + "claims": { + "minio_buckets": ["paperless"], + "paperless_doctypes": ["*"], + "memory_projects": ["*"], + "authorized_models": ["reasoning", "qwen2.5:3b"], + }, + }, + "portfolio-agent": { + "description": "Portfolio service agent", + "roles": ["llm:inference", "memory:read"], + "claims": { + "memory_projects": ["homelab", "portfolio"], + "memory_visibility": "public", + "authorized_models": ["ornith:35b"], + "minio_buckets": ["backups"], + }, + }, + "memory-agent": { + "description": "Memory service agent", + "roles": ["llm:inference", "memory:read", "memory:write"], + "claims": { + "memory_projects": ["*"], + "memory_visibility": "private", + "authorized_models": ["reasoning", "ornith:35b", "qwen2.5:3b"], + }, + }, + "temporal-worker-agent": { + "description": "Temporal workflow worker", + "roles": ["llm:inference", "workflow:execute", "memory:read", "memory:write"], + "claims": { + "memory_projects": ["*"], + "authorized_models": ["reasoning", "ornith:35b", "qwen2.5:3b"], + }, + }, +} + +SCOPE_MAPPINGS: Dict[str, Dict[str, str]] = { + "roles": {"expression": 'return user.attributes.get("roles", [])'}, + "permissions": {"expression": 'return ["*"] if any(user.groups.filter(is_superuser=True)) else list(user.groups.values_list("name", flat=True))'}, + "minio_buckets": {"expression": 'return user.attributes.get("minio_buckets", [])'}, + "paperless_doctypes": {"expression": 'return user.attributes.get("paperless_doctypes", [])'}, + "memory_projects": {"expression": 'return user.attributes.get("memory_projects", [])'}, + "memory_visibility": {"expression": 'return user.attributes.get("memory_visibility", "public")'}, + "authorized_models": {"expression": 'return user.attributes.get("authorized_models", [])'}, + "sqs_queues": {"expression": 'return user.attributes.get("sqs_queues", [])'}, + "grafana_org_role": {"expression": 'return user.attributes.get("grafana_org_role", "Viewer")'}, +} + +# =========================================================================== +# Phase 1: Create/sync all groups +# =========================================================================== +print("[1/6] Ensuring groups exist...") + +status, res = api("GET", "/api/v3/core/groups/?page_size=100") +if status != 200: + die(f"GET groups -> {status} {res}") +existing_groups = {g["name"]: g for g in res["results"]} + +created_count = 0 +for group_name, group_spec in GROUPS.items(): + if group_name in existing_groups: + print(f" {group_name}: already exists") + else: + status, res = api("POST", "/api/v3/core/groups/", { + "name": group_name, + "is_superuser": group_spec.get("is_superuser", False), + }) + if status in (200, 201): + print(f" {group_name}: created") + created_count += 1 + else: + print(f" {group_name}: FAILED {status} {res}") + +print(f" Total: {len(GROUPS)} groups, {created_count} new") + +# =========================================================================== +# Phase 2: Create/sync service account users with custom claims +# =========================================================================== +print("\n[2/6] Creating/updating service account users...") + +status, res = api("GET", "/api/v3/core/users/?page_size=100") +if status != 200: + die(f"GET users -> {status} {res}") +existing_users = {u["username"]: u for u in res["results"]} + +service_pwd = os.environ.get("AUTHENTIK_SERVICE_ACCOUNT_PASSWORD", "DefaultPassword123!") + +for agent_name, agent_spec in SERVICE_ACCOUNTS.items(): + if agent_name in existing_users: + user = existing_users[agent_name] + attrs = user.get("attributes", {}) + attrs.update(agent_spec.get("claims", {})) + attrs["roles"] = agent_spec.get("roles", []) + status, res = api("PATCH", f"/api/v3/core/users/{user['pk']}/", {"attributes": attrs}) + if status in (200, 201): + print(f" {agent_name}: claims updated") + else: + print(f" {agent_name}: FAILED {status} {res}") + else: + status, res = api("POST", "/api/v3/core/users/", { + "username": agent_name, + "name": agent_spec.get("description", agent_name), + "email": f"{agent_name}@homelab.local", + "is_active": True, + "is_superuser": False, + "password": service_pwd, + "attributes": { + **agent_spec.get("claims", {}), + "roles": agent_spec.get("roles", []), + }, + }) + if status in (200, 201): + print(f" {agent_name}: created") + else: + print(f" {agent_name}: FAILED {status} {res}") + +# =========================================================================== +# Phase 3: Create scope mappings for fine-grained claims +# =========================================================================== +print("\n[3/6] Creating scope mappings for fine-grained claims...") + +status, res = api("GET", "/api/v3/propertymappings/provider/scope/?page_size=100") +if status != 200: + die(f"GET scope mappings -> {status} {res}") +existing_scopes = {m["scope_name"]: m for m in res["results"]} + +for scope_name, scope_spec in SCOPE_MAPPINGS.items(): + if scope_name in existing_scopes: + print(f" {scope_name}: already exists") + else: + status, res = api("POST", "/api/v3/propertymappings/provider/scope/", { + "name": scope_name, + "scope_name": scope_name, + "expression": scope_spec["expression"], + }) + if status in (200, 201): + print(f" {scope_name}: created") + else: + print(f" {scope_name}: FAILED {status} {res}") + +# =========================================================================== +# Phase 4: Get flow UUIDs (needed for providers) +# =========================================================================== +print("\n[4/6] Fetching flow UUIDs...") + +status, res = api("GET", "/api/v3/flows/instances/?page_size=100") +if status != 200: + die(f"GET flows -> {status} {res}") + +flows = {f["slug"]: f["pk"] for f in res.get("results", [])} +auth_flow = flows.get("default-provider-authorization-implicit-consent") +inval_flow = flows.get("default-provider-invalidation-flow") + +if not auth_flow or not inval_flow: + die(f"Required flows not found. auth_flow={auth_flow}, inval_flow={inval_flow}") + +print(f" authorization_flow: {auth_flow}") +print(f" invalidation_flow: {inval_flow}") + +# =========================================================================== +# Phase 5: Create OAuth2 providers with scopes +# =========================================================================== +print("\n[5/6] Creating OAuth2 providers...") + +status, res = api("GET", "/api/v3/providers/oauth2/?page_size=100") +if status != 200: + die(f"GET providers -> {status} {res}") +existing_providers = {p["name"]: p for p in res.get("results", [])} + +# Fetch scope mapping PKs +status, scopes_res = api("GET", "/api/v3/propertymappings/provider/scope/?page_size=100") +if status != 200: + print(" WARNING: could not fetch scope mappings") + scope_pks = {} +else: + scope_pks = {m["scope_name"]: m["pk"] for m in scopes_res.get("results", [])} + +scope_pks_list = [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks] + +OAuth2_PROVIDERS = { + "api-gw": {"client_id": "api-gw", "redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"]}, + "minio": {"client_id": "minio", "redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"]}, + "poimen": {"client_id": "poimen", "redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]}, + "paperless": {"client_id": "paperless", "redirect_uris": ["http://localhost:8000/auth/complete", "https://paperless.riotpiao.com/auth/complete"]}, + "grafana": {"client_id": "grafana", "redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]}, +} + +for provider_name, provider_spec in OAuth2_PROVIDERS.items(): + if provider_name in existing_providers: + print(f" {provider_name}: already exists") + else: + # Build redirect_uris list with proper schema + redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]] + + client_secret = os.environ.get(f"AUTHENTIK_PROVIDER_{provider_name.upper()}_SECRET", f"{provider_name}-secret-placeholder") + + status, res = api("POST", "/api/v3/providers/oauth2/", { + "name": provider_name, + "authorization_flow": auth_flow, + "invalidation_flow": inval_flow, + "grant_types": ["authorization_code", "implicit", "password"], + "client_id": provider_spec["client_id"], + "client_secret": client_secret, + "redirect_uris": redirect_uris_list, + "property_mappings": scope_pks_list, + }) + if status in (200, 201): + print(f" {provider_name}: created") + else: + print(f" {provider_name}: FAILED {status} {res}") + +# =========================================================================== +# Phase 6: Create OAuth2 Applications (bind providers to public token endpoints) +# =========================================================================== +print("\n[6/7] Creating OAuth2 Applications...") +print(" (binds providers to /application/o/token/ endpoints)") + +status, res = api("GET", "/api/v3/core/applications/?page_size=100") +if status != 200: + die(f"GET applications -> {status} {res}") +existing_apps = {a["slug"]: a for a in res.get("results", [])} + +for provider_name in OAuth2_PROVIDERS.keys(): + if provider_name in existing_apps: + print(f" {provider_name}: already exists") + else: + # Get the provider PK to link + status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}") + if status != 200 or not provider_res.get("results"): + print(f" {provider_name}: provider not found") + continue + + provider_pk = provider_res["results"][0]["pk"] + + status, res = api("POST", "/api/v3/core/applications/", { + "name": provider_name, + "slug": provider_name, + "provider": provider_pk, + }) + if status in (200, 201): + print(f" {provider_name}: created") + else: + print(f" {provider_name}: FAILED {status} {res}") + +# =========================================================================== +# Phase 7: Create/update rock user → homelab-admins, matching Forgejo identity +# =========================================================================== +print("\n[7/10] Creating/updating rock user (locartrock@gmail.com)...") + +ROCK_EMAIL = "locartrock@gmail.com" +ROCK_PASSWORD = os.environ.get("ROCK_PASSWORD", "") + +status, res = api("GET", "/api/v3/core/users/?username=rock") +if status == 200 and res.get("results"): + rock_user = res["results"][0] + # Ensure email matches Forgejo's rock user for OIDC linking + patch_data = {"email": ROCK_EMAIL, "name": "Rock"} + status, res = api("PATCH", f"/api/v3/core/users/{rock_user['pk']}/", patch_data) + if status in (200, 201): + print(f" rock: updated email to {ROCK_EMAIL}") + else: + print(f" rock: update FAILED {status} {res}") +else: + if not ROCK_PASSWORD: + print(" rock: NOT FOUND and ROCK_PASSWORD not set, skipping creation") + print(" export ROCK_PASSWORD= and re-run") + rock_user = None + else: + status, res = api("POST", "/api/v3/core/users/", { + "username": "rock", + "name": "Rock", + "email": ROCK_EMAIL, + "is_active": True, + "is_superuser": False, + "password": ROCK_PASSWORD, + }) + if status in (200, 201): + rock_user = res + print(f" rock: created with email {ROCK_EMAIL}") + else: + print(f" rock: create FAILED {status} {res}") + rock_user = None + +if rock_user: + status, res = api("GET", "/api/v3/core/groups/?name=homelab-admins") + if status == 200 and res.get("results"): + admins_group = res["results"][0] + status, res = api("POST", f"/api/v3/core/groups/{admins_group['pk']}/users/add/", {"pk": rock_user["pk"]}) + if status in (200, 201, 204): + print(f" rock: added to homelab-admins") + else: + print(f" rock: group add {status} {res}") + +# =========================================================================== +# Phase 8: Email recovery flow (password reset via email) +# =========================================================================== +print("\n[8/10] Creating email recovery flow...") + +# Read SMTP config from gotify-smtp secret (same Gmail creds) +SMTP_HOST = "smtp.gmail.com" +SMTP_PORT = 587 +SMTP_USER = "locartrock@gmail.com" +SMTP_FROM = "authentik@riotpiao.com" +# Password read from env at runtime: AUTHENTIK_EMAIL__PASSWORD + +# 8a. Create email stage for recovery +status, res = api("GET", "/api/v3/stages/email/?name=email-recovery") +if status == 200 and res.get("results"): + email_stage_pk = res["results"][0]["pk"] + print(" email-recovery stage: already exists") +else: + status, res = api("POST", "/api/v3/stages/email/", { + "name": "email-recovery", + "use_global_settings": False, + "host": SMTP_HOST, + "port": SMTP_PORT, + "username": SMTP_USER, + "password": os.environ.get("AUTHENTIK_EMAIL_PASSWORD", ""), + "use_tls": True, + "use_ssl": False, + "timeout": 10, + "from_address": SMTP_FROM, + "template": "email/password_reset.html", + "activate_user_on_success": True, + }) + if status in (200, 201): + email_stage_pk = res["pk"] + print(" email-recovery stage: created") + else: + email_stage_pk = None + print(f" email-recovery stage: FAILED {status} {res}") + +# 8b. Create identification stage for recovery (email lookup) +status, res = api("GET", "/api/v3/stages/identification/?name=recovery-identification") +if status == 200 and res.get("results"): + ident_stage_pk = res["results"][0]["pk"] + print(" recovery-identification stage: already exists") +else: + status, res = api("POST", "/api/v3/stages/identification/", { + "name": "recovery-identification", + "user_fields": ["email", "username"], + }) + if status in (200, 201): + ident_stage_pk = res["pk"] + print(" recovery-identification stage: created") + else: + ident_stage_pk = None + print(f" recovery-identification stage: FAILED {status} {res}") + +# 8c. Create password stage for new password entry +status, res = api("GET", "/api/v3/stages/password/?name=recovery-password-change") +if status == 200 and res.get("results"): + pw_stage_pk = res["results"][0]["pk"] + print(" recovery-password-change stage: already exists") +else: + # Use prompt stage for password change instead + status, res = api("GET", "/api/v3/stages/user_write/?name=recovery-user-write") + if status == 200 and res.get("results"): + pw_stage_pk = res["results"][0]["pk"] + print(" recovery-user-write stage: already exists") + else: + status, res = api("POST", "/api/v3/stages/user_write/", { + "name": "recovery-user-write", + }) + if status in (200, 201): + pw_stage_pk = res["pk"] + print(" recovery-user-write stage: created") + else: + pw_stage_pk = None + print(f" recovery-user-write stage: FAILED {status} {res}") + +# 8d. Create recovery flow +status, res = api("GET", "/api/v3/flows/instances/?slug=password-recovery") +if status == 200 and res.get("results"): + recovery_flow_pk = res["results"][0]["pk"] + print(" password-recovery flow: already exists") +else: + status, res = api("POST", "/api/v3/flows/instances/", { + "name": "Password Recovery", + "slug": "password-recovery", + "title": "Reset your password", + "designation": "recovery", + }) + if status in (200, 201): + recovery_flow_pk = res["pk"] + print(" password-recovery flow: created") + else: + recovery_flow_pk = None + print(f" password-recovery flow: FAILED {status} {res}") + +# 8e. Bind stages to flow in order +if recovery_flow_pk and ident_stage_pk and email_stage_pk: + for order, stage_pk, label in [ + (10, ident_stage_pk, "identification"), + (20, email_stage_pk, "email"), + ]: + status, res = api("POST", "/api/v3/flows/bindings/", { + "target": recovery_flow_pk, + "stage": stage_pk, + "order": order, + }) + if status in (200, 201): + print(f" bound {label} stage at order {order}") + elif status == 400 and "already exists" in str(res).lower(): + print(f" {label} stage: already bound") + else: + print(f" bind {label}: {status} {res}") + +# =========================================================================== +# Phase 9: Set recovery flow on brand +# =========================================================================== +print("\n[9/10] Setting recovery flow on brand...") + +if recovery_flow_pk: + status, res = api("GET", "/api/v3/brands/instances/") + if status == 200 and res.get("results"): + brand = res["results"][0] + status, res = api("PATCH", f"/api/v3/brands/instances/{brand['brand_uuid']}/", { + "flow_recovery": recovery_flow_pk, + }) + if status in (200, 201): + print(" recovery flow set on brand") + else: + print(f" FAILED {status} {res}") + else: + print(" no brand found") + +# =========================================================================== +# Phase 10: Ensure Forgejo OAuth2 source uses matching email claim +# =========================================================================== +print("\n[10/10] Verifying Forgejo OIDC linkage...") +print(f" rock@Authentik email: {ROCK_EMAIL}") +print(" Forgejo OIDC will match on email — ensure Forgejo's rock user") +print(f" has email {ROCK_EMAIL} in Forgejo settings → Profile") + +# =========================================================================== +# Summary +# =========================================================================== +print("\n" + "="*70) +print("AUTHENTIK PROVISIONING COMPLETE") +print("="*70) +print(f"\n [1] Groups: {len(GROUPS)}") +print(f" [2] Service accounts: {len(SERVICE_ACCOUNTS)}") +print(f" [3] Scope mappings: {len(SCOPE_MAPPINGS)}") +print(f" [4] Flows resolved") +print(f" [5] OAuth2 providers: {len(OAuth2_PROVIDERS)}") +print(f" [6] OAuth2 applications bound") +print(f" [7] rock user (locartrock@gmail.com) -> homelab-admins") +print(f" [8] Email recovery flow (smtp.gmail.com)") +print(f" [9] Recovery flow set on brand") +print(f" [10] Forgejo OIDC linkage verified") +print("\nNEXT: Set Forgejo rock user email to locartrock@gmail.com in Forgejo profile") +print("TEST: https://authentik.riotpiao.com/if/flow/password-recovery/") +print("="*70)