From 81883a8ea186a109af407cd0985fd2aaa3469a4d Mon Sep 17 00:00:00 2001 From: rock Date: Mon, 26 Oct 2026 03:32:18 -0700 Subject: [PATCH] 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. --- k8s/apps/cloudflared/configmap.yaml | 4 - k8s/apps/cloudflared/deployment.yaml | 2 +- k8s/argocd/apps/30-security.yaml | 49 -- k8s/argocd/apps/40-data.yaml | 2 +- k8s/argocd/apps/99-oidc-bootstrap.yaml | 20 - k8s/argocd/projects/homelab-project.yaml | 1 - k8s/argocd/secrets/authentik-secrets.enc.yaml | 25 - k8s/argocd/secrets/secret-generator.yaml | 1 - k8s/bootstrap/ingress/ingress.yaml | 28 - k8s/infra/databases/authentik-db.yaml | 38 - k8s/infra/databases/kustomization.yaml | 1 - k8s/infra/databases/namespaces.yaml | 4 +- k8s/infra/iam/authentik-values.yaml | 284 -------- k8s/infra/iam/kustomization.yaml | 6 +- k8s/infra/iam/rbac-dashboard-rolebinding.yaml | 13 - .../alerts/svc-authentik-rules.yaml | 33 - k8s/infra/monitoring/kustomization.yaml | 2 - .../monitoring/servicemonitors/authentik.yaml | 18 - k8s/infra/oidc-bootstrap/bootstrap.yaml | 33 - scripts/iam/provision-rbac.py | 664 ------------------ terraform/files/coredns/Corefile | 1 - 21 files changed, 6 insertions(+), 1223 deletions(-) delete mode 100644 k8s/argocd/apps/99-oidc-bootstrap.yaml delete mode 100644 k8s/argocd/secrets/authentik-secrets.enc.yaml delete mode 100644 k8s/infra/databases/authentik-db.yaml delete mode 100644 k8s/infra/iam/authentik-values.yaml delete mode 100644 k8s/infra/iam/rbac-dashboard-rolebinding.yaml delete mode 100644 k8s/infra/monitoring/alerts/svc-authentik-rules.yaml delete mode 100644 k8s/infra/monitoring/servicemonitors/authentik.yaml delete mode 100644 k8s/infra/oidc-bootstrap/bootstrap.yaml delete mode 100644 scripts/iam/provision-rbac.py diff --git a/k8s/apps/cloudflared/configmap.yaml b/k8s/apps/cloudflared/configmap.yaml index 2fba788..656469c 100644 --- a/k8s/apps/cloudflared/configmap.yaml +++ b/k8s/apps/cloudflared/configmap.yaml @@ -53,8 +53,4 @@ data: # even though the DNS CNAME points at the tunnel. - hostname: 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 diff --git a/k8s/apps/cloudflared/deployment.yaml b/k8s/apps/cloudflared/deployment.yaml index aa6f459..f658e6b 100644 --- a/k8s/apps/cloudflared/deployment.yaml +++ b/k8s/apps/cloudflared/deployment.yaml @@ -16,7 +16,7 @@ spec: # Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must # dial https:// and trust homelab-ca or the TLS handshake fails # 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: - name: merge-ca-certs image: debian:bookworm diff --git a/k8s/argocd/apps/30-security.yaml b/k8s/argocd/apps/30-security.yaml index 2ec6f46..04e3a7a 100644 --- a/k8s/argocd/apps/30-security.yaml +++ b/k8s/argocd/apps/30-security.yaml @@ -30,33 +30,6 @@ spec: syncOptions: - 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 # serving in the same `iam` namespace until the last consumer moves, then this # becomes the only IdP. Migration plan (outside git): @@ -106,28 +79,6 @@ spec: syncOptions: - 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 # 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 diff --git a/k8s/argocd/apps/40-data.yaml b/k8s/argocd/apps/40-data.yaml index 3baee56..25dbbb7 100644 --- a/k8s/argocd/apps/40-data.yaml +++ b/k8s/argocd/apps/40-data.yaml @@ -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). # 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 diff --git a/k8s/argocd/apps/99-oidc-bootstrap.yaml b/k8s/argocd/apps/99-oidc-bootstrap.yaml deleted file mode 100644 index 532d512..0000000 --- a/k8s/argocd/apps/99-oidc-bootstrap.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/k8s/argocd/projects/homelab-project.yaml b/k8s/argocd/projects/homelab-project.yaml index bdf1677..06241d5 100644 --- a/k8s/argocd/projects/homelab-project.yaml +++ b/k8s/argocd/projects/homelab-project.yaml @@ -34,7 +34,6 @@ spec: - https://prometheus-community.github.io/helm-charts - https://grafana.github.io/helm-charts - https://helm.releases.hashicorp.com - - https://charts.goauthentik.io - https://charts.zitadel.com - https://strimzi.io/charts/ - https://charts.bitnami.com/bitnami diff --git a/k8s/argocd/secrets/authentik-secrets.enc.yaml b/k8s/argocd/secrets/authentik-secrets.enc.yaml deleted file mode 100644 index 6436b1b..0000000 --- a/k8s/argocd/secrets/authentik-secrets.enc.yaml +++ /dev/null @@ -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 diff --git a/k8s/argocd/secrets/secret-generator.yaml b/k8s/argocd/secrets/secret-generator.yaml index eaa4191..7313d38 100644 --- a/k8s/argocd/secrets/secret-generator.yaml +++ b/k8s/argocd/secrets/secret-generator.yaml @@ -9,7 +9,6 @@ metadata: files: - agent-pod-models.enc.yaml - agent-pod-ssh-key.enc.yaml - - authentik-secrets.enc.yaml - cloudflare-secrets.enc.yaml - cloudflared-credentials.enc.yaml - forgejo-registry-pat-api.enc.yaml diff --git a/k8s/bootstrap/ingress/ingress.yaml b/k8s/bootstrap/ingress/ingress.yaml index de57a83..378c9d9 100644 --- a/k8s/bootstrap/ingress/ingress.yaml +++ b/k8s/bootstrap/ingress/ingress.yaml @@ -275,31 +275,3 @@ spec: name: temporal-web port: 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 diff --git a/k8s/infra/databases/authentik-db.yaml b/k8s/infra/databases/authentik-db.yaml deleted file mode 100644 index 9b7e54a..0000000 --- a/k8s/infra/databases/authentik-db.yaml +++ /dev/null @@ -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 diff --git a/k8s/infra/databases/kustomization.yaml b/k8s/infra/databases/kustomization.yaml index a566c32..5ad6318 100644 --- a/k8s/infra/databases/kustomization.yaml +++ b/k8s/infra/databases/kustomization.yaml @@ -6,7 +6,6 @@ kind: Kustomization # deployed into it. paperless ns declared in namespaces.yaml above. resources: - namespaces.yaml - - authentik-db.yaml - zitadel-db.yaml - temporal-db.yaml - paperless-db.yaml diff --git a/k8s/infra/databases/namespaces.yaml b/k8s/infra/databases/namespaces.yaml index c3d145b..3e6b139 100644 --- a/k8s/infra/databases/namespaces.yaml +++ b/k8s/infra/databases/namespaces.yaml @@ -1,7 +1,7 @@ # 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 -# creates them. authentik/vault/temporal/paperless CreateNamespace=true then -# no-ops. poimen namespace created by poimen-root app (wave 7). +# creates them. vault/temporal/paperless CreateNamespace=true then no-ops. +# poimen namespace created by poimen-root app (wave 7). apiVersion: v1 kind: Namespace metadata: diff --git a/k8s/infra/iam/authentik-values.yaml b/k8s/infra/iam/authentik-values.yaml deleted file mode 100644 index 663c92b..0000000 --- a/k8s/infra/iam/authentik-values.yaml +++ /dev/null @@ -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 diff --git a/k8s/infra/iam/kustomization.yaml b/k8s/infra/iam/kustomization.yaml index f0d119b..03a361d 100644 --- a/k8s/infra/iam/kustomization.yaml +++ b/k8s/infra/iam/kustomization.yaml @@ -5,8 +5,6 @@ resources: - rbac-dashboard-rolebinding.yaml # IAM provisioning is manual-only (security-sensitive). -# Script: scripts/iam/authentik-provision.py +# Script: scripts/iam/zitadel-provision.py # Run: -# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \ -# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d) -# python3 scripts/iam/authentik-provision.py +# scripts/iam/zitadel-provision.py all diff --git a/k8s/infra/iam/rbac-dashboard-rolebinding.yaml b/k8s/infra/iam/rbac-dashboard-rolebinding.yaml deleted file mode 100644 index cd1d211..0000000 --- a/k8s/infra/iam/rbac-dashboard-rolebinding.yaml +++ /dev/null @@ -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 diff --git a/k8s/infra/monitoring/alerts/svc-authentik-rules.yaml b/k8s/infra/monitoring/alerts/svc-authentik-rules.yaml deleted file mode 100644 index 3833e0d..0000000 --- a/k8s/infra/monitoring/alerts/svc-authentik-rules.yaml +++ /dev/null @@ -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" diff --git a/k8s/infra/monitoring/kustomization.yaml b/k8s/infra/monitoring/kustomization.yaml index e77d36b..bc0c0a3 100644 --- a/k8s/infra/monitoring/kustomization.yaml +++ b/k8s/infra/monitoring/kustomization.yaml @@ -7,7 +7,6 @@ kind: Kustomization resources: - alerts/ingress-alerts.yaml - alerts/svc-argocd-rules.yaml - - alerts/svc-authentik-rules.yaml - alerts/svc-zitadel-rules.yaml - alerts/svc-forgejo-rules.yaml - alerts/svc-grafana-rules.yaml @@ -17,7 +16,6 @@ resources: - alerts/api-gateway-alerts.yaml - alerts/cag-kv-cache-rules.yaml - servicemonitors/argocd.yaml - - servicemonitors/authentik.yaml - servicemonitors/forgejo.yaml - servicemonitors/minio.yaml - servicemonitors/ingress-nginx.yaml diff --git a/k8s/infra/monitoring/servicemonitors/authentik.yaml b/k8s/infra/monitoring/servicemonitors/authentik.yaml deleted file mode 100644 index d993276..0000000 --- a/k8s/infra/monitoring/servicemonitors/authentik.yaml +++ /dev/null @@ -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 diff --git a/k8s/infra/oidc-bootstrap/bootstrap.yaml b/k8s/infra/oidc-bootstrap/bootstrap.yaml deleted file mode 100644 index 5d3b41f..0000000 --- a/k8s/infra/oidc-bootstrap/bootstrap.yaml +++ /dev/null @@ -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 diff --git a/scripts/iam/provision-rbac.py b/scripts/iam/provision-rbac.py deleted file mode 100644 index 36194a1..0000000 --- a/scripts/iam/provision-rbac.py +++ /dev/null @@ -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 (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) diff --git a/terraform/files/coredns/Corefile b/terraform/files/coredns/Corefile index 1043df3..315447f 100644 --- a/terraform/files/coredns/Corefile +++ b/terraform/files/coredns/Corefile @@ -22,7 +22,6 @@ # TLS termination (cert-manager cert) and preserve the Host header. Routing # 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. - 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 grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local