Compare commits

...
Author SHA1 Message Date
rock 903a72a6fd feat(iam): provision the LLM inference roles and assert project roles
The api-gw gates /v1/* on a capability named in the access token. ZITADEL
had no such role, so every inference call was 403 once the token came from
ZITADEL instead of Authentik.

- add llm:inference (Authentik spelling, keeps consumer manifests unchanged)
  and llm-inference (dash alias) to the project roles
- enable projectRoleAssertion on the project, which is what puts
  urn:zitadel:iam:org:project:roles into the access token; without it the
  role list never reaches the resource server
2026-10-26 10:26:54 -07:00
rock 81883a8ea1 chore(iam): retire Authentik
Zitadel is running and has provisioned OIDC clients for all 12 consumers
(state.json: kubernetes, paperless, forgejo, argocd, grafana, immich, minio,
vault, poimen, api-gw, queue-sqs, homarr). Authentik's remaining job was to
serve until the last consumer moved.

Remove every Authentik artifact from GitOps:
- ArgoCD Application `authentik` and its Helm values
- ArgoCD Application `iam-jobs` (provisioner RBAC + migration job)
- ArgoCD Application `oidc-bootstrap` (one-shot apiserver restart)
- authentik-db CNPG cluster definition
- authentik-secrets SOPS file and secret-generator entry
- Ingress rule, cloudflared tunnel hostname, CoreDNS rewrite
- Prometheus alerts and ServiceMonitor
- admin OIDC ClusterRoleBinding provisioning binding
- scripts/iam/provision-rbac.py (the Authentik provisioner)

The chart repo https://charts.goauthentik.io is removed from the AppProject.

Kube-apiserver OIDC and consumer configs are switched to Zitadel in the
local cluster-config (gitignored) and applied separately via talosctl.
2026-10-26 03:32:18 -07:00
22 changed files with 28 additions and 1228 deletions
-4
View File
@@ -53,8 +53,4 @@ data:
# even though the DNS CNAME points at the tunnel. # even though the DNS CNAME points at the tunnel.
- hostname: zitadel.riotpiao.com - hostname: zitadel.riotpiao.com
service: https://zitadel.riotpiao.com service: https://zitadel.riotpiao.com
# AUTHENTIK. Same reasoning as ZITADEL: a hostname absent from this table
# gets 404 from the connector. CoreDNS rewrites the name to ingress-nginx.
- hostname: authentik.riotpiao.com
service: https://authentik.riotpiao.com
- service: http_status:404 - service: http_status:404
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
# Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must # Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must
# dial https://<hostname> and trust homelab-ca or the TLS handshake fails # dial https://<hostname> and trust homelab-ca or the TLS handshake fails
# with "certificate signed by unknown authority". Same pattern as # with "certificate signed by unknown authority". Same pattern as
# authentik-values.yaml: merge the system bundle with homelab-ca.crt. # iam values: merge the system bundle with homelab-ca.crt.
initContainers: initContainers:
- name: merge-ca-certs - name: merge-ca-certs
image: debian:bookworm image: debian:bookworm
-49
View File
@@ -30,33 +30,6 @@ spec:
syncOptions: syncOptions:
- CreateNamespace=true - CreateNamespace=true
--- ---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: authentik
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
sources:
- repoURL: https://charts.goauthentik.io
chart: authentik
targetRevision: "*"
helm:
valueFiles:
- $values/k8s/infra/iam/authentik-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: iam
syncPolicy:
automated:
prune: true
selfHeal: true
---
# ZITADEL — the Authentik replacement. Coexistence, not cutover: Authentik keeps # ZITADEL — the Authentik replacement. Coexistence, not cutover: Authentik keeps
# serving in the same `iam` namespace until the last consumer moves, then this # serving in the same `iam` namespace until the last consumer moves, then this
# becomes the only IdP. Migration plan (outside git): # becomes the only IdP. Migration plan (outside git):
@@ -106,28 +79,6 @@ spec:
syncOptions: syncOptions:
- CreateNamespace=true - CreateNamespace=true
--- ---
# Raw IAM manifests: key-rotation cronjob + authentik migration job.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: iam-jobs
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/iam
destination:
server: https://kubernetes.default.svc
namespace: iam
syncPolicy:
automated:
prune: true
selfHeal: true
---
# Forgejo itself. Was a bootstrap Helm release (phase 3) until it was brought # Forgejo itself. Was a bootstrap Helm release (phase 3) until it was brought
# under Argo, because values changes there were inert — a proxy-body-size fix # under Argo, because values changes there were inert — a proxy-body-size fix
# sat committed while the live Ingress kept nginx's 1m default and rejected # sat committed while the live Ingress kept nginx's 1m default and rejected
+1 -1
View File
@@ -1,4 +1,4 @@
# Wave 2 — dedicated per-app CNPG Postgres clusters (authentik-db → ns iam, # Wave 2 — dedicated per-app CNPG Postgres clusters (zitadel-db → ns iam,
# temporal-db + visibility → ns temporal). ONE App, ONE folder (k8s/infra/databases). # temporal-db + visibility → ns temporal). ONE App, ONE folder (k8s/infra/databases).
# CNPG operator is Phase-0 bootstrap; these Cluster CRs are GitOps — no circular # CNPG operator is Phase-0 bootstrap; these Cluster CRs are GitOps — no circular
# dep (they run after ArgoCD is up, before their apps at w3/w8). CNPG generates # dep (they run after ArgoCD is up, before their apps at w3/w8). CNPG generates
-20
View File
@@ -1,20 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: oidc-bootstrap
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "5"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/oidc-bootstrap
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
-1
View File
@@ -34,7 +34,6 @@ spec:
- https://prometheus-community.github.io/helm-charts - https://prometheus-community.github.io/helm-charts
- https://grafana.github.io/helm-charts - https://grafana.github.io/helm-charts
- https://helm.releases.hashicorp.com - https://helm.releases.hashicorp.com
- https://charts.goauthentik.io
- https://charts.zitadel.com - https://charts.zitadel.com
- https://strimzi.io/charts/ - https://strimzi.io/charts/
- https://charts.bitnami.com/bitnami - https://charts.bitnami.com/bitnami
@@ -1,25 +0,0 @@
apiVersion: ENC[AES256_GCM,data:Fc0=,iv:UFqMFeZTJZtFMoNeusjAoSI60U9StZS4IXw+n8JQCRM=,tag:uUvY4+jcjiSJHs0HDS3/7A==,type:str]
kind: ENC[AES256_GCM,data:NUw86IEw,iv:gh4Vqn6uteYnYIFcXT/QVxRPKm+G+j2aIVuV71nZ4k4=,tag:nZA/x75Kx4+CoSx66BsvkQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:qex150pm0eGwYiiI05y9LUs=,iv:5Tk001T796c4HFikoh1ZVB6ierBfDqOUkFs5ilkr+V8=,tag:LiDAiCH7w6aRS41VKIlIKg==,type:str]
namespace: ENC[AES256_GCM,data:nI91,iv:c1hBWGMkVb+vg+MKNzty8CFfXxQabXEtT+EpiuQZNvo=,tag:BsGtJDbBadDdTa687+Sdag==,type:str]
type: ENC[AES256_GCM,data:3rXawlSD,iv:FUtLK4KP7v/SBF08ZcU6siIO1qfjlJirf2x5PyzAjuA=,tag:mSIQ+Y43+wDfZGRbQ6a0Og==,type:str]
stringData:
AUTHENTIK_SECRET_KEY: ENC[AES256_GCM,data:i5tVOWnN9a/cfhs6gckveRxUpiN7Ie227R/74kOIz6lQ5US2hkf03W5qSGCrzheL3DB0i8+d0iJYsApjn/wCiN7ndZZVFko3W5jDfVPybxc=,iv:djF1G9JHT5B7ziTokA/UuPpG+tG2LvFU4uMAR0x4M6o=,tag:vHOd7jLYkM8Evnr0QUVrCw==,type:str]
AUTHENTIK_BOOTSTRAP_PASSWORD: ENC[AES256_GCM,data:PjvYtH03CzQbTXEqt/Z18+u1zJdok7DLwDn51FUZXDA=,iv:pSwSm8hB0/Q8uOAcOZ1rTqq7BF782EglG19GGeghm1E=,tag:qGCQFWCrwsWBOiQ07ySMUQ==,type:str]
AUTHENTIK_BOOTSTRAP_TOKEN: ENC[AES256_GCM,data:1tnekGo0GIemJCMRenjN0yNeSiiMl/kky8ms84TsqBLaQ0lQzPxjq9qtgNJwoAsufxrznrkDVKjeOEHNYcA82A==,iv:NNFWSLMILoWccA9ihy1OArSS1X3OySB7PIHdXZdyzlg=,tag:t2w+KyReOXftTrU9pBQDnQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSZGRKWm9PTkNSOENtREgx
M2V3Z0xxOXRhRGVRclo3SzBKRkc0VmtCY0NnClJPSGJRcUlzWmNFeTZ1bmorNEV4
U3l3TzVlMTlrWkJnQzBtUTJtcUwrM3MKLS0tIEJrMWZrbllYYUZKN1V4MVMzcXAv
eUtheHlNMHpsTEJXZ1FTMFVxbWloa0kKLRkE1Du+4gdOLerOl9y0mZw+8fqfECQY
uP64Q+9BSuPYNiTLZvYDzmdoy+KTS7i7C9rf+iU96R/crbQLamV9Ew==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:37:07Z"
mac: ENC[AES256_GCM,data:b3pDx340d1iZxsPPXzACckoLBrW+XjX1qlGrWEEI30j2Ao0hewTRZJyHbdI97A6b/hqzBN/4tjqqkdqm8goNhUQweMLTw86X6sALuE0aLS9wK53qjqih8fQwQBbgLDtjFXtW26h+smIm7aB5ZSDvU2le44HDXuWd83sWtCavNq0=,iv:ZMDsoKpygFJrYpXy2dA0IlxAi0jt24rxF72eUzgQs7k=,tag:WWE7G7+c9JK6fF1U9V8igg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
-1
View File
@@ -9,7 +9,6 @@ metadata:
files: files:
- agent-pod-models.enc.yaml - agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml - agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml
- cloudflare-secrets.enc.yaml - cloudflare-secrets.enc.yaml
- cloudflared-credentials.enc.yaml - cloudflared-credentials.enc.yaml
- forgejo-registry-pat-api.enc.yaml - forgejo-registry-pat-api.enc.yaml
-28
View File
@@ -275,31 +275,3 @@ spec:
name: temporal-web name: temporal-web
port: port:
number: 8080 number: 8080
---
# ── Authentik (identity provider) ─────────────────────────────────────────────
# The chart's ingress is disabled in k8s/infra/iam/authentik-values.yaml; the
# rule lives here so ingress-config owns every hostname. TLS terminates at
# nginx via the wildcard default-ssl-certificate, so the backend stays HTTP.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authentik
namespace: iam
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: authentik.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: authentik-server
port:
number: 80
-38
View File
@@ -1,38 +0,0 @@
# Dedicated CNPG Postgres for Authentik (GitOps, wave 2 — before authentik w3).
# CNPG generates secret `authentik-db-app` + service `authentik-db-rw` in ns iam;
# authentik-values reads them locally (no cross-ns secret copy).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: authentik-db
namespace: iam
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
bootstrap:
initdb:
database: authentik
owner: app
encoding: UTF8
localeCollate: C
localeCType: C
enableSuperuserAccess: false
resources:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "2Gi", cpu: "1" }
storage:
size: 10Gi
storageClass: longhorn-cnpg
monitoring:
enablePodMonitor: true
affinity:
# preferred + tolerations: HA across nodes without deadlocking a partly-
# schedulable 3-CP (same as forgejo-db).
podAntiAffinityType: preferred
topologyKey: kubernetes.io/hostname
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
-1
View File
@@ -6,7 +6,6 @@ kind: Kustomization
# deployed into it. paperless ns declared in namespaces.yaml above. # deployed into it. paperless ns declared in namespaces.yaml above.
resources: resources:
- namespaces.yaml - namespaces.yaml
- authentik-db.yaml
- zitadel-db.yaml - zitadel-db.yaml
- temporal-db.yaml - temporal-db.yaml
- paperless-db.yaml - paperless-db.yaml
+2 -2
View File
@@ -1,7 +1,7 @@
# DB clusters are wave 2 — their namespaces must exist first (their apps that # DB clusters are wave 2 — their namespaces must exist first (their apps that
# would CreateNamespace run later, w3/w8). Declared here so the databases App # would CreateNamespace run later, w3/w8). Declared here so the databases App
# creates them. authentik/vault/temporal/paperless CreateNamespace=true then # creates them. vault/temporal/paperless CreateNamespace=true then no-ops.
# no-ops. poimen namespace created by poimen-root app (wave 7). # poimen namespace created by poimen-root app (wave 7).
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
-284
View File
@@ -1,284 +0,0 @@
# k8s/talos-iam/authentik-values.yaml
# Authentik — SSO Identity Provider for the homelab.
# Provides OAuth2/OIDC login for Grafana, MinIO, Forgejo, and Argo CD.
# Chart: authentik/authentik from https://charts.goauthentik.io
#
# Architecture: server (UI+API) + worker (background tasks) + PostgreSQL + Redis.
# PostgreSQL is the system of record — must persist. Redis is ephemeral cache/queue.
#
# Secrets injected via helmfile --set (from .env / vsource):
# AUTHENTIK_SECRET_KEY — signs sessions and tokens; set once, never rotate casually
# AUTHENTIK_BOOTSTRAP_PASSWORD — initial akadmin password (used once at first login)
# AUTHENTIK_BOOTSTRAP_TOKEN — API token for the setup_talos_iam.sh bootstrap script
# AUTHENTIK_PG_PASSWORD — PostgreSQL user password
authentik:
# host: the external URL Authentik uses to build redirect URIs in OAuth2 flows.
# Must match what the browser sees — if it returns an internal svc URL,
# the browser's redirect after login will fail (can't reach svc DNS externally).
# HTTP (not HTTPS) because the Authentik ingress has no TLS cert configured.
host: "https://authentik.riotpiao.com"
error_reporting:
enabled: false # do not phone home to Sentry
# PostgreSQL connection — points at CloudNativePG cluster in ddb namespace.
# Uses 'app' bootstrap user (CNPG simple pattern, same as Forgejo).
# Credentials injected from authentik-db-app secret via env vars below.
postgresql:
host: authentik-db-rw.iam.svc.cluster.local
port: 5432
name: authentik
user: app # All apps use shared 'app' user (CNPG design pattern)
password: "" # overridden by AUTHENTIK_POSTGRESQL__PASSWORD env var
# Redis connection — bundled subchart, standalone mode (no sentinel/cluster).
redis:
host: authentik-redis-master
# ── HTTP client timeouts ──────────────────────────────────────────────────────
# Increased to tolerate 5+ second pod-to-pod network latency.
# Affects webhooks, outpost management, SCIM, LDAP sync.
# Default: ~30s — too aggressive when latency spikes hit 5-10s.
log_level: debug # enable debug logging to monitor connection issues
# ── CA trust (shared by server and worker) ────────────────────────────────────
# Authentik (Python/Debian) uses requests + httpx for outgoing HTTPS — webhooks,
# outpost management, SCIM. Both libraries need REQUESTS_CA_BUNDLE / SSL_CERT_FILE
# to point to a bundle that includes homelab-ca, otherwise connections to other
# homelab services fail with "certificate signed by unknown authority".
#
# Strategy: a debian:12-slim init container (run as root) concatenates the
# Debian system Mozilla bundle with homelab-ca.crt into an emptyDir. The main
# container then references /merged/ca-bundle.crt via two env vars that cover
# every Python HTTP library.
_caVolumes: &caVolumes
- name: homelab-ca
configMap:
name: homelab-ca
- name: merged-ca
emptyDir: {}
_caVolumeMounts: &caVolumeMounts
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
readOnly: true
_caInitContainers: &caInitContainers
- name: merge-ca-certs
image: debian:bookworm
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
command:
- sh
- -c
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/homelab-ca.crt) > /merged/ca-bundle.crt
volumeMounts:
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
# NOTE: no authentik-migrate init container — the authentik `server` entrypoint
# runs migrations itself on startup. A separate `manage migrate` init pinned to
# an older image tripped a version-history precheck on an empty DB
# (relation "authentik_version_history" does not exist) and blocked boot.
_caEnv: &caEnv
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# ── Authentik server (UI + API) ───────────────────────────────────────────────
# Handles all browser traffic: login flows, admin UI, OAuth2 authorize/token endpoints.
# NodePort 32172 is a fallback for direct node access during troubleshooting;
# normal access is via nginx ingress (authentik.riotpiao.com → svc:80).
# Recreate: single replica + RWO-adjacent state — avoids split-brain on redeploy.
server:
replicas: 1
# Merge SOPS-CMP-emitted secret values after the chart's own `authentik` secret.
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
service:
type: NodePort
nodePort: 32172
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
# The /-/health/{live,ready}/ endpoints do a DB round-trip; under transient
# CNPG contention they respond in 5-6s while still returning 200. The chart's
# default 3s liveness timeout then flags a working backend as dead and kubelet
# kills it in a restart loop — the pod never stays Ready, gets dropped from the
# authentik-server Service endpoints, and the OAuth-provisioning PostSync hook
# fails with "Host is unreachable". Widen the timeouts so slow-but-healthy
# checks aren't treated as failures. (Only these fields are overridden; the
# chart deep-merges the rest of each probe, incl. the httpGet path.)
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
timeoutSeconds: 15
failureThreshold: 6
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
timeoutSeconds: 15
failureThreshold: 6
startupProbe:
timeoutSeconds: 15
failureThreshold: 120 # 120 × 10s = 20min for fresh DB migrations
# Every OIDC login (Grafana, Argo CD, MinIO, Forgejo) depends on this server —
# its request latency/error rate explains SSO-driven slowness on those services.
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── Authentik worker ──────────────────────────────────────────────────────────
# Runs background tasks: email delivery, LDAP sync, flow policy evaluation,
# event log cleanup, and managed outpost updates. Stateless — no PVC needed.
# Same resource profile as server; Authentik 2023+ merged some worker duties
# into the server process but the worker pod is still required.
worker:
replicas: 1
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
startupProbe:
initialDelaySeconds: 30 # let server finish DB work first
failureThreshold: 120
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── PostgreSQL (external: CloudNativePG cluster in ddb namespace) ─────────────
# Authentik connects to the dedicated authentik-db (1 primary + 2 replicas).
# Do not use the bundled Bitnami subchart — CNPG is already running.
postgresql:
enabled: false
primary:
persistence:
enabled: true
storageClass: longhorn
size: 8Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
# ── Bundled Redis ─────────────────────────────────────────────────────────────
# Cache and async task queue only — no durable data. If Redis restarts, in-flight
# background tasks are retried and cached tokens are recomputed. Losing Redis
# data does not lose user accounts or flow configuration (that's in PostgreSQL).
# persistence: false saves a PVC and makes restarts faster.
#
# Same prefer-worker / fallback-to-cp scheduling as PostgreSQL.
# architecture: standalone — no Sentinel/cluster overhead for a 3-node homelab.
redis:
enabled: true
master:
persistence:
enabled: false
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
architecture: standalone
# Ingress disabled — rule lives in k8s/ingress/ingress.yaml (authentik.riotpiao.com).
# For direct access during bootstrap: kubectl -n iam port-forward svc/authentik-server 7000:80
+2 -4
View File
@@ -5,8 +5,6 @@ resources:
- rbac-dashboard-rolebinding.yaml - rbac-dashboard-rolebinding.yaml
# IAM provisioning is manual-only (security-sensitive). # IAM provisioning is manual-only (security-sensitive).
# Script: scripts/iam/authentik-provision.py # Script: scripts/iam/zitadel-provision.py
# Run: # Run:
# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ # scripts/iam/zitadel-provision.py all
# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)
# python3 scripts/iam/authentik-provision.py
@@ -1,13 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: authentik-provisioner
namespace: dashboard
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: authentik-provisioner
subjects:
- kind: ServiceAccount
name: authentik-provisioner
namespace: iam
@@ -1,33 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: authentik-rules
namespace: iam
spec:
groups:
- name: authentik.rules
interval: 15s
rules:
- alert: AuthentikHighErrorRate
expr: |
(
sum(rate(authentik_http_requests_total{status=~"5.."}[5m]))
/
sum(rate(authentik_http_requests_total[5m]))
) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "High error rate on Authentik"
description: "5xx error rate exceeded 5% of total requests for 10 minutes. Value: {{ $value | humanizePercentage }}"
- alert: AuthentikOutpostDown
expr: |
authentik_outpost_total_up == 0
for: 5m
labels:
severity: warning
annotations:
summary: "Authentik outpost is down"
description: "Outpost {{ $labels.outpost_name }} (type: {{ $labels.outpost_type }}) has been offline for 5 minutes"
-2
View File
@@ -7,7 +7,6 @@ kind: Kustomization
resources: resources:
- alerts/ingress-alerts.yaml - alerts/ingress-alerts.yaml
- alerts/svc-argocd-rules.yaml - alerts/svc-argocd-rules.yaml
- alerts/svc-authentik-rules.yaml
- alerts/svc-zitadel-rules.yaml - alerts/svc-zitadel-rules.yaml
- alerts/svc-forgejo-rules.yaml - alerts/svc-forgejo-rules.yaml
- alerts/svc-grafana-rules.yaml - alerts/svc-grafana-rules.yaml
@@ -17,7 +16,6 @@ resources:
- alerts/api-gateway-alerts.yaml - alerts/api-gateway-alerts.yaml
- alerts/cag-kv-cache-rules.yaml - alerts/cag-kv-cache-rules.yaml
- servicemonitors/argocd.yaml - servicemonitors/argocd.yaml
- servicemonitors/authentik.yaml
- servicemonitors/forgejo.yaml - servicemonitors/forgejo.yaml
- servicemonitors/minio.yaml - servicemonitors/minio.yaml
- servicemonitors/ingress-nginx.yaml - servicemonitors/ingress-nginx.yaml
@@ -1,18 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: authentik
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- iam
selector:
matchLabels:
app.kubernetes.io/name: authentik
endpoints:
- port: metrics
interval: 30s
scrapeTimeout: 10s
-33
View File
@@ -1,33 +0,0 @@
# One-shot bootstrap: restart the control-plane apiservers ONCE so they pick up the
# OIDC authenticator (they last started before the Authentik provider existed, so OIDC
# tokens were never accepted -> every login got 401). Delete this whole directory after.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: oidc-bootstrap
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: oidc-bootstrap
namespace: kube-system
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oidc-bootstrap
namespace: kube-system
subjects:
- kind: ServiceAccount
name: oidc-bootstrap
namespace: kube-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: oidc-bootstrap
-664
View File
@@ -1,664 +0,0 @@
#!/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", "queue:send"],
"claims": {
"memory_projects": ["*"],
"sqs_queues": ["*"],
"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))'},
"groups": {"expression": 'return {"groups": 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}")
# Fix built-in 'openid' scope to include preferred_username (needed by Grafana, Forgejo, etc.)
# Note: sub MUST be a string, not int, for JSON compatibility with Go struct unmarshaling
if "openid" in existing_scopes:
openid_scope = existing_scopes["openid"]
openid_pk = openid_scope["pk"]
openid_expression = 'return {"sub": str(user.pk), "preferred_username": user.username}'
if openid_expression not in openid_scope.get("expression", ""):
status, res = api("PUT", f"/api/v3/propertymappings/provider/scope/{openid_pk}/", {
"name": openid_scope["name"],
"scope_name": "openid",
"expression": openid_expression,
})
if status in (200, 201):
print(f" openid: updated (sub as string + preferred_username)")
else:
print(f" openid: UPDATE FAILED {status} {res}")
else:
print(f" openid: already correct (sub as string)")
# ===========================================================================
# 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", [])}
# Include standard OpenID scopes (openid, email, profile) + custom claim scopes
STANDARD_SCOPES = ["openid", "email", "profile"]
scope_pks_list = [scope_pks[s] for s in STANDARD_SCOPES if s in scope_pks]
scope_pks_list += [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks]
# OAuth2 providers — client_secret sourced from SOPS-encrypted k8s secrets.
# These are the real secrets the services use. Authentik must match.
OAuth2_PROVIDERS = {
"api-gw": {
"client_id": "api-gw",
"client_secret_env": "AUTHENTIK_PROVIDER_API_GW_SECRET",
"redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"],
},
"minio": {
"client_id": "minio",
"client_secret": "9d2867fe08c3bf7fedd7e32bbaf4456fce3b0aaf788966d7559e1955947b0219",
"redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"],
},
"poimen": {
"client_id": "poimen",
"client_secret_env": "AUTHENTIK_PROVIDER_POIMEN_SECRET",
"redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"],
},
"paperless": {
"client_id": "paperless",
"client_secret": "6hcxaaVgZlKgafl7BxeSEtPAcbNUJxi2PAZePxSFk4o=",
"redirect_uris": ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"],
},
"grafana": {
"client_id": "grafana",
"client_secret": "966bad4fa43812100e7775b3c73fed2ce1d07217fa5a23fbb0f190e46d2f0fa4",
"redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"],
},
"queue": {
"client_id": "queue-sqs",
"client_secret_env": "AUTHENTIK_PROVIDER_QUEUE_SECRET",
"redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"],
},
"forgejo": {
"client_id": "forgejo",
"client_secret": "G4klhs3JRfs5A7YnGs90WuOndBAvamWlZgaZRY8x",
"redirect_uris": ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"],
},
"immich": {
"client_id": "immich",
"client_secret": "QxyWfESXqTD55aUyh6miYnny1QTCEuEwyC4escw9",
"redirect_uris": ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"],
},
"argocd": {
"client_id": "argocd",
"client_secret_env": "AUTHENTIK_PROVIDER_ARGOCD_SECRET",
"redirect_uris": ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"],
},
"vault": {
"client_id": "vault",
"client_secret_env": "AUTHENTIK_PROVIDER_VAULT_SECRET",
"redirect_uris": ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"],
},
}
import secrets as _secrets
for provider_name, provider_spec in OAuth2_PROVIDERS.items():
# Resolve client_secret: explicit > env var > generate random
if "client_secret" in provider_spec:
client_secret = provider_spec["client_secret"]
elif "client_secret_env" in provider_spec:
client_secret = os.environ.get(provider_spec["client_secret_env"], _secrets.token_urlsafe(32))
else:
client_secret = _secrets.token_urlsafe(32)
redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]]
provider_payload = {
"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 provider_name in existing_providers:
# UPDATE existing provider — sync secret + redirect_uris
provider_pk = existing_providers[provider_name]["pk"]
status, res = api("PATCH", f"/api/v3/providers/oauth2/{provider_pk}/", {
"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}: updated (secret + redirect_uris synced)")
else:
print(f" {provider_name}: UPDATE FAILED {status} {res}")
else:
# CREATE new provider
status, res = api("POST", "/api/v3/providers/oauth2/", provider_payload)
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():
# Get the provider PK
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, skip")
continue
provider_pk = provider_res["results"][0]["pk"]
if provider_name in existing_apps:
# Ensure app is linked to provider (fix orphaned apps)
app_data = existing_apps[provider_name]
if app_data.get("provider") != provider_pk:
app_uuid = app_data["pk"]
status, res = api("PATCH", f"/api/v3/core/applications/{app_uuid}/", {
"provider": provider_pk,
})
if status in (200, 201):
print(f" {provider_name}: re-linked to provider")
else:
print(f" {provider_name}: RE-LINK FAILED {status} {res}")
else:
print(f" {provider_name}: ok")
else:
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 ([email protected])...")
ROCK_EMAIL = "[email protected]"
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=<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 = "[email protected]"
SMTP_FROM = "[email protected]"
# 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 ([email protected]) -> 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 [email protected] in Forgejo profile")
print("TEST: https://authentik.riotpiao.com/if/flow/password-recovery/")
print("="*70)
+19 -2
View File
@@ -80,6 +80,10 @@ ROLES = {
"sqs-writers": "SQS/Temporal queue read/write access", "sqs-writers": "SQS/Temporal queue read/write access",
"s3-users": "S3 read access", "s3-users": "S3 read access",
"s3-writers": "S3 read/write access", "s3-writers": "S3 read/write access",
# LLM gate for the api-gw. The colon form matches the Authentik-era
# permission string, so consumer manifests did not change.
"llm:inference": "LLM inference access (api-gw)",
"llm-inference": "LLM inference access (dash alias)",
} }
_WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"} _WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"}
@@ -266,13 +270,26 @@ def _api(method: str, path: str, token: str, body: dict | None = None,
def _ensure_project(token: str, name: str) -> str: def _ensure_project(token: str, name: str) -> str:
code, d = _api("POST", "/management/v1/projects/_search", token, {}) code, d = _api("POST", "/management/v1/projects/_search", token, {})
project_id = ""
for row in d.get("result") or []: for row in d.get("result") or []:
if row.get("name") == name: if row.get("name") == name:
return row["id"] project_id = row["id"]
break
if not project_id:
code, d = _api("POST", "/management/v1/projects", token, {"name": name}) code, d = _api("POST", "/management/v1/projects", token, {"name": name})
if code not in (200, 201) or not d.get("id"): if code not in (200, 201) or not d.get("id"):
die(f"create project {name} failed: {code} {d}") die(f"create project {name} failed: {code} {d}")
return d["id"] project_id = d["id"]
# projectRoleAssertion puts the project roles into the ACCESS token as
# `urn:zitadel:iam:org:project:roles`. Without it the api-gw sees no
# llm:inference role and answers 403 for every inference call, even when
# the caller is correctly authorised in ZITADEL.
code, d = _api("PUT", f"/management/v1/projects/{project_id}",
token, {"name": name, "projectRoleAssertion": True})
if code not in (200, 201):
die(f"enable projectRoleAssertion on {name} failed: {code} {d}")
return project_id
def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None: def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None:
-1
View File
@@ -22,7 +22,6 @@
# TLS termination (cert-manager cert) and preserve the Host header. Routing # TLS termination (cert-manager cert) and preserve the Host header. Routing
# via nginx (not the backend service) is critical: a direct rewrite bypasses # via nginx (not the backend service) is critical: a direct rewrite bypasses
# nginx TLS and exposes each app's own self-signed cert, trusted by nothing. # nginx TLS and exposes each app's own self-signed cert, trusted by nothing.
rewrite name authentik.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name zitadel.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name zitadel.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local