From cb1219bf96e493b6e257b7c70c27d5b046406920 Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 24 Oct 2026 02:15:36 -0700 Subject: [PATCH 1/4] feat(iam): wire Zitadel into namespace iam under ArgoCD Coexistence wiring for the Authentik replacement: ZITADEL gets its own CNPG cluster, SOPS masterkey, Helm values and ArgoCD Application in namespace iam. Authentik keeps serving; no consumer is moved and no Authentik object changes. - k8s/infra/databases/zitadel-db.yaml: CNPG Postgres 16.2, 3 instances - k8s/infra/iam/zitadel-values.yaml: chart 10.4.0 pinned, DSN from the CNPG secret, masterkey from a Secret, metrics ServiceMonitor, second-sync caveat - k8s/argocd/apps/30-security.yaml: Application named zitadel, wave 3, automated sync deliberately absent for the first sync (chart PreSync hook re-run risk) - k8s/argocd/secrets/zitadel-masterkey.enc.yaml: 32-byte masterkey, SOPS - k8s/infra/monitoring/alerts/svc-zitadel-rules.yaml: target-down, login-UI, crash-loop and PreSync-job-failed rules - terraform/files/coredns/Corefile: in-cluster rewrite for the zitadel host Not applied. The cluster is mid-incident: vault-0 and authentik-server are 0/1, authentik-db-2 has 14 restarts, prometheus-0 is 1/2 CrashLoopBackOff and kube-state-metrics has 1925 restarts. Two manual steps still gate the first sync: the Cloudflare Tunnel public hostname plus a proxied CNAME, and terraform apply + make apply-cp for the CoreDNS rewrite (Talos-owned). The offline masterkey copy is at ~/zitadel-masterkey.offline (mode 600). Move it to sealed storage and delete it before the first sync. Losing it makes every encrypted ZITADEL value unreadable. --- k8s/argocd/apps/30-security.yaml | 49 +++++++ k8s/argocd/secrets/secret-generator.yaml | 1 + k8s/argocd/secrets/zitadel-masterkey.enc.yaml | 23 +++ k8s/infra/databases/kustomization.yaml | 1 + k8s/infra/databases/zitadel-db.yaml | 42 ++++++ k8s/infra/iam/zitadel-values.yaml | 134 ++++++++++++++++++ .../monitoring/alerts/svc-zitadel-rules.yaml | 68 +++++++++ k8s/infra/monitoring/kustomization.yaml | 1 + terraform/files/coredns/Corefile | 1 + 9 files changed, 320 insertions(+) create mode 100644 k8s/argocd/secrets/zitadel-masterkey.enc.yaml create mode 100644 k8s/infra/databases/zitadel-db.yaml create mode 100644 k8s/infra/iam/zitadel-values.yaml create mode 100644 k8s/infra/monitoring/alerts/svc-zitadel-rules.yaml diff --git a/k8s/argocd/apps/30-security.yaml b/k8s/argocd/apps/30-security.yaml index a955315..2ec6f46 100644 --- a/k8s/argocd/apps/30-security.yaml +++ b/k8s/argocd/apps/30-security.yaml @@ -57,6 +57,55 @@ spec: 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): +# ~/workplace/homelab-doc/ZITADEL_MIGRATION.html +# +# Wave 3 ordering: sops-secrets (wave 0) creates the `zitadel-masterkey` Secret, +# databases (wave 2) creates the `zitadel-db` CNPG cluster and its +# `zitadel-db-app` DSN secret. Both must exist before the init job starts. +# +# automated sync is deliberately absent for the first sync — same reason as +# forgejo. The chart ships `pre-install,pre-upgrade` Helm hooks (zitadel-init, +# zitadel-setup), which ArgoCD maps to PreSync and re-runs on every sync, manual +# or automated. A human reads the first sync's hook output before any repeat can +# run. The init job runs `init zitadel` only (CNPG owns the database and the app +# user), and the setup job creates the first org and the `iam-admin` machine key +# that Terraform consumes. Read the SECOND-SYNC HAZARD block at the top of +# k8s/infra/iam/zitadel-values.yaml before enabling automated sync. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: zitadel + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io + annotations: + argocd.argoproj.io/sync-wave: "3" +spec: + project: homelab + revisionHistoryLimit: 3 + sources: + - repoURL: https://charts.zitadel.com + chart: zitadel + # Pinned: the chart trails the upstream app release, and the verified + # masterkey/passwap facts were read at v4.19.x. A floating tag would + # change the app version without a diff anyone reviews. + targetRevision: 10.4.0 + helm: + valueFiles: + - $values/k8s/infra/iam/zitadel-values.yaml + - repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git + targetRevision: main + ref: values + destination: + server: https://kubernetes.default.svc + namespace: iam + syncPolicy: + syncOptions: + - CreateNamespace=true +--- # Raw IAM manifests: key-rotation cronjob + authentik migration job. apiVersion: argoproj.io/v1alpha1 kind: Application diff --git a/k8s/argocd/secrets/secret-generator.yaml b/k8s/argocd/secrets/secret-generator.yaml index 92a4eaa..29d958c 100644 --- a/k8s/argocd/secrets/secret-generator.yaml +++ b/k8s/argocd/secrets/secret-generator.yaml @@ -38,3 +38,4 @@ files: - forgejo-registry-token.enc.yaml - pi-sandbox-secrets.enc.yaml - etcd-defrag-creds.enc.yaml + - zitadel-masterkey.enc.yaml diff --git a/k8s/argocd/secrets/zitadel-masterkey.enc.yaml b/k8s/argocd/secrets/zitadel-masterkey.enc.yaml new file mode 100644 index 0000000..3337ceb --- /dev/null +++ b/k8s/argocd/secrets/zitadel-masterkey.enc.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: zitadel-masterkey + namespace: iam +type: Opaque +stringData: + masterkey: ENC[AES256_GCM,data:dGp0wSCUJORP6W5PHLKYaQEqrZsgiAwU1GqcuvuZV+g=,iv:Wwt6E/XXwsoVEQxe4gG1m5oJLOcNUXngftADQvZQepw=,tag:1Vget5gs+t455U9zLavdwg==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBld0tUWHNWem13cDlxRG14 + QWRlTjA5SzZnS2pBd2M0WWkzQWxKZW9HZWdJCk00QWJJcWZFM1ZTSUlReUlGZG4z + RFBSUWRlVUx2NGN2OFAwazBYRTVVNkEKLS0tIHpSSW9IWHhNa0d3SmEydXU4akVj + MGJkN2U4WmtLbVFMZm55U1hEZElZMlUKKV9twnC9GC7MSmNLxEBHbPl4RN5Hsd+z + NViCKxUoQAB8apn2XVHZcDnyfKNfr1P3aSa3IIHyVY3prgo1OFDpdw== + -----END AGE ENCRYPTED FILE----- + recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + encrypted_regex: ^(data|stringData)$ + lastmodified: "2026-10-24T08:57:27Z" + mac: ENC[AES256_GCM,data:hXDAddVmnlf/yinY2c2Q8yfsjqi6n9cRbEbwykqYScd6mDPfdF7BriTiu9K+CV/IvIT4zcMTZbhBeQ/vTCAh0TZDp1lOVglwtNH/imgagyzPmcCvbCIkZKwF5vwWQIGHcDI6pk4/zVQkqt7zKTf8+Qj0pNh5UrAjVeEFE7cuJkQ=,iv:oIQvROFGbiESmMTthHOJiNM+Gy5BCfM5XtDWbszOPmc=,tag:AaYrJrwXuKD7w0p+TTa7FA==,type:str] + version: 3.13.2 diff --git a/k8s/infra/databases/kustomization.yaml b/k8s/infra/databases/kustomization.yaml index ff75276..a566c32 100644 --- a/k8s/infra/databases/kustomization.yaml +++ b/k8s/infra/databases/kustomization.yaml @@ -7,5 +7,6 @@ kind: Kustomization resources: - namespaces.yaml - authentik-db.yaml + - zitadel-db.yaml - temporal-db.yaml - paperless-db.yaml diff --git a/k8s/infra/databases/zitadel-db.yaml b/k8s/infra/databases/zitadel-db.yaml new file mode 100644 index 0000000..bb3f48f --- /dev/null +++ b/k8s/infra/databases/zitadel-db.yaml @@ -0,0 +1,42 @@ +# Dedicated CNPG Postgres for ZITADEL (GitOps, wave 2 — before the zitadel +# chart at wave 3). Same shape as authentik-db.yaml: CNPG generates the secret +# `zitadel-db-app` and the service `zitadel-db-rw` in ns iam, and +# k8s/infra/iam/zitadel-values.yaml reads the DSN from that secret locally (no +# cross-namespace secret copy). +# +# ZITADEL requires PostgreSQL 14+; the repo convention image is 16.2. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: zitadel-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: zitadel + 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 authentik-db and 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/iam/zitadel-values.yaml b/k8s/infra/iam/zitadel-values.yaml new file mode 100644 index 0000000..b3e3dbc --- /dev/null +++ b/k8s/infra/iam/zitadel-values.yaml @@ -0,0 +1,134 @@ +# k8s/infra/iam/zitadel-values.yaml +# ZITADEL — Authentik replacement. Coexistence is deliberate: Authentik keeps +# running, read-only, in the same `iam` namespace until the last consumer moves. +# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git). +# +# Chart: zitadel/zitadel 10.4.0, app v4.19.2. The plan cites v4.19.4; the chart +# has not published it yet. Pin the chart, never `*`, until the v4.19.4 chart +# exists — a silent app bump invalidates the verified masterkey/passwap facts. +# +# URL: https://zitadel.riotpiao.com. TLS terminates at ingress-nginx, which +# serves the wildcard riotpiao-com-tls as its default-ssl-certificate, so +# ExternalSecure=true and TLS.Enabled=false (no per-rule tls: block needed). +# +# Database: CNPG cluster zitadel-db (wave 2). The DSN comes from CNPG's +# generated `zitadel-db-app` secret. When ZITADEL_DATABASE_POSTGRES_DSN is set, +# ZITADEL ignores the discrete Database.Postgres.* fields, so neither a +# password nor a host is written into this file. +# +# Masterkey: Secret `zitadel-masterkey` (k8s/argocd/secrets/ +# zitadel-masterkey.enc.yaml), key `masterkey`, exactly 32 bytes. It wraps the +# rows in system.encryption_keys, which encrypt every client secret and private +# key. Losing it makes those values unreadable: keep the offline sealed copy. +# +# Ingress uses the chart's own objects (controller: nginx adds the gRPC backend +# annotation ZITADEL needs for its HTTP/2 API). Do not hand-write a second +# Ingress for this host. + +zitadel: + configmapConfig: + ExternalSecure: true + ExternalDomain: zitadel.riotpiao.com + TLS: + Enabled: false + + # Rendered into a Secret by the chart; read by pods and init jobs alike. + masterkeySecretName: zitadel-masterkey + + resources: + requests: { memory: "512Mi", cpu: "250m" } + limits: { memory: "2Gi", cpu: "1" } + + # FirstInstance defaults stay for the FIRST sync: the chart creates org + # `zitadel` plus the `iam-admin` machine user and its machine-key Secret. + # Terraform consumes that key to declare projects, roles, OIDC apps and the + # groups action. + # + # SECOND-SYNC HAZARD — read before enabling automated sync. + # The chart marks eight objects as `pre-install,pre-upgrade` hooks (two + # ServiceAccounts, two ConfigMaps, Role, RoleBinding, zitadel-init, + # zitadel-setup), and one cleanup Job as `post-delete`. ArgoCD maps the first + # eight to PreSync with `before-hook-creation`: it deletes and re-creates them + # on EVERY sync, not only on a chart change. + # - zitadel-init (`init zitadel`) — schema step + # - zitadel-setup (`setup`) — first instance + iam-admin machine key + # On an initialized instance the setup step can fail, which aborts the sync + # before the Deployment is applied. If it succeeds instead, its + # machinekeyWriter container re-applies the iam-admin key Secret that + # Terraform reads, which silently rotates a credential in use. + # After the first sync reports healthy: + # 1. set configmapConfig.FirstInstance.Skip: true here — permanently: the + # setup steps are schema-only once the instance exists, and Skip alone + # stops the machinekeyWriter rotation + # 2. set initJob.enabled: false below + # 3. commit, sync once, then enable automated+selfHeal on the Application + # For a later schema upgrade, re-enable initJob.enabled only — never set + # FirstInstance.Skip back to false. + # ArgoCD never prunes a hook Job, so a Job that failed before step 2 stays and + # keeps ZitadelSetupJobFailed firing until a human deletes it. + +# DSN mode (chart renders this through the zitadel.dbEnv helper). CNPG's `uri` +# key is a postgresql:// URL, which ZITADEL accepts as a DSN. +env: + - name: ZITADEL_DATABASE_POSTGRES_DSN + valueFrom: + secretKeyRef: + name: zitadel-db-app + key: uri + +# Bundled Bitnami Postgres is disabled: the CNPG cluster above is the system of +# record. Enabling it would create a second, unmanaged database. +postgresql: + enabled: false + +# CNPG already created the database, the `app` user and its grants, so the init +# job runs only the ZITADEL-internal step. Running the full default command +# would try to create a database and a superuser it does not own. +initJob: + command: "zitadel" + activeDeadlineSeconds: 600 + +# Two API replicas: the read path survives one node drain, and both share the +# same masterkey and database. The login UI stays at one replica — a session +# cookie is not shared, so a second pod buys nothing. +replicaCount: 2 +revisionHistoryLimit: 3 + +ingress: + enabled: true + className: nginx + controller: nginx + +login: + enabled: true + replicaCount: 1 + ingress: + enabled: true + className: nginx + +# Chart-owned ServiceMonitor. The cluster's Prometheus runs with +# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not +# required for discovery. Endpoint is /debug/metrics on port http2-server. +metrics: + enabled: true + serviceMonitor: + enabled: true + scrapeInterval: 30s + scrapeTimeout: 10s + +# The 3-CP / 2-worker cluster mixes control-plane taints with worker capacity. +# Without the toleration a rolling upgrade can leave both replicas Pending. +tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: zitadel diff --git a/k8s/infra/monitoring/alerts/svc-zitadel-rules.yaml b/k8s/infra/monitoring/alerts/svc-zitadel-rules.yaml new file mode 100644 index 0000000..ba1fa01 --- /dev/null +++ b/k8s/infra/monitoring/alerts/svc-zitadel-rules.yaml @@ -0,0 +1,68 @@ +# ZITADEL alert parity with the Authentik rules it will replace. +# +# Only two signals are ported at this point. The metrics endpoint +# (/debug/metrics, scraped by the chart's own ServiceMonitor in ns iam) exposes +# no request or error counters that this cluster has verified, so no error-rate +# rule is written here. Writing one against a guessed series name would produce +# a permanently-firing or permanently-silent alert — both worse than none. +# AuthentikOutpostDown is deliberately not ported: it monitors an outpost that +# carries zero providers and is deleted with the migration, not migrated. +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: zitadel-rules + namespace: iam +spec: + groups: + - name: zitadel.rules + interval: 30s + rules: + - alert: ZitadelDown + # absent() is required: equality alone cannot fire when the series is + # gone (ServiceMonitor deleted, Service selector changed, pods scaled + # to zero). Those are exactly the outages a target-down rule exists for. + expr: absent(up{job="zitadel"}) or up{job="zitadel"} == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "ZITADEL API target is down" + description: "Prometheus cannot scrape the ZITADEL API for 10 minutes. Token issuance and every OIDC login depend on it. Check the zitadel pods and the zitadel-db cluster in namespace iam. Value: {{ $value }}" + + - alert: ZitadelLoginUIDown + expr: absent(kube_deployment_status_replicas_available{namespace="iam",deployment="zitadel-login"}) or kube_deployment_status_replicas_available{namespace="iam",deployment="zitadel-login"} == 0 + for: 10m + labels: + severity: warning + annotations: + summary: "ZITADEL login UI has no available replica" + description: "The login UI serves /ui/v2/login. With zero available replicas a browser login completes at the IdP but cannot render. Value: {{ $value }}" + + - alert: ZitadelRestarting + # pod!~"zitadel-db-.*" matters: the CNPG database pods share the + # `zitadel` name prefix, and a database restart must not page as an + # IdP crash loop. + expr: absent(kube_pod_container_status_restarts_total{namespace="iam",pod=~"zitadel.*",pod!~"zitadel-db-.*"}) or increase(kube_pod_container_status_restarts_total{namespace="iam",pod=~"zitadel.*",pod!~"zitadel-db-.*"}[1h]) > 3 + for: 5m + labels: + severity: warning + annotations: + summary: "ZITADEL pod restarts more than 3 times per hour" + description: "A crash loop on the IdP is a cluster-wide login outage. Check the pod logs and the masterkey/database secret references. Value: {{ $value }}" + + # The chart's zitadel-init and zitadel-setup Jobs are PreSync hooks with + # restartPolicy: Never, so their restart counter stays 0. Without this + # rule a failed schema init or first-instance setup reaches nobody but + # the person who runs `kubectl -n iam get job`. + # No absent() guard here on purpose: the chart ships only + # `before-hook-creation`, so a successful hook Job is replaced at the + # next PreSync. Once the jobs are retired, absent() would fire on a + # healthy cluster. + - alert: ZitadelSetupJobFailed + expr: kube_job_status_failed{namespace="iam",job_name=~"zitadel-(init|setup).*"} > 0 + for: 5m + labels: + severity: critical + annotations: + summary: "ZITADEL init/setup job failed" + description: "A PreSync job failed, so ArgoCD aborted the sync before the Deployment was applied. Read the job pod logs, then delete the Job object to let the next sync retry. Value: {{ $value }}" diff --git a/k8s/infra/monitoring/kustomization.yaml b/k8s/infra/monitoring/kustomization.yaml index 754994d..e77d36b 100644 --- a/k8s/infra/monitoring/kustomization.yaml +++ b/k8s/infra/monitoring/kustomization.yaml @@ -8,6 +8,7 @@ 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 - alerts/svc-minio-rules.yaml diff --git a/terraform/files/coredns/Corefile b/terraform/files/coredns/Corefile index dbdcb7c..1043df3 100644 --- a/terraform/files/coredns/Corefile +++ b/terraform/files/coredns/Corefile @@ -23,6 +23,7 @@ # 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 rewrite name minio-api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local -- 2.54.0 From f81ff0e2fcb327371a151ce542a70bcf586248e5 Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 24 Oct 2026 02:26:43 -0700 Subject: [PATCH 2/4] feat(iam): declare Zitadel identity objects in Terraform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds terraform/zitadel: the provisioning half the ArgoCD wiring left open. It declares 1 project, 19 project roles, 12 OIDC applications, 5 machine users plus keys, one API application for the gateway, project grants, per-agent claim metadata, and a disabled groups-claim action. Facts established while writing it: - Client IDs cannot be preserved. ZITADEL v4.19.2 CreateOIDCApplicationRequest has no client_id field, and provider 3.8.7 rejects a settable application id (terraform validate reports "Invalid or unknown key"). Plan §3.2 assumed a client_id-preserving re-point; every consumer now also changes client ID. - Role keys equal the existing group names, so the groups claim can be byte-identical and no RBAC file changes. v4.19.2 allows role keys of 1-200 characters with no character-set restriction. - Machine users get JWT profile keys, not passwords. Per-agent claims move to user metadata and reach tokens only through the groups-claim action. - The action stays gated: enable_groups_claim_action defaults to false because the action runtime API is unverified. Two validations stop a null script from reaching file(). Checks: terraform fmt -check -recursive clean, terraform validate 0 errors, plan reaches only the expected missing-credentials error, structural gate 0 error / 0 warning, and one reviewer round: BLOCK on a plan-time fileexists(null) and on the missing api-gw machine path, both fixed, re-reviewed to SIGN-OFF. State is local and git-ignored; it carries generated client secrets and machine keys. Move it to MinIO before a second operator applies. Not applied: no Zitadel instance is running yet. --- .gitignore | 5 ++ terraform/zitadel/actions.tf | 58 ++++++++++++++ terraform/zitadel/api_apps.tf | 18 +++++ terraform/zitadel/clients.tf | 136 ++++++++++++++++++++++++++++++++ terraform/zitadel/machines.tf | 139 +++++++++++++++++++++++++++++++++ terraform/zitadel/outputs.tf | 46 +++++++++++ terraform/zitadel/project.tf | 62 +++++++++++++++ terraform/zitadel/providers.tf | 53 +++++++++++++ terraform/zitadel/variables.tf | 103 ++++++++++++++++++++++++ 9 files changed, 620 insertions(+) create mode 100644 terraform/zitadel/actions.tf create mode 100644 terraform/zitadel/api_apps.tf create mode 100644 terraform/zitadel/clients.tf create mode 100644 terraform/zitadel/machines.tf create mode 100644 terraform/zitadel/outputs.tf create mode 100644 terraform/zitadel/project.tf create mode 100644 terraform/zitadel/providers.tf create mode 100644 terraform/zitadel/variables.tf diff --git a/.gitignore b/.gitignore index ffc2a33..7afa6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,11 @@ k8s/storage/test/test terraform/.terraform/ terraform/*.tfstate terraform/*.tfstate.* +# The Zitadel root module keeps its own state (see terraform/zitadel/providers.tf). +# That state carries generated client secrets and machine keys. +terraform/zitadel/*.tfstate +terraform/zitadel/*.tfstate.* +terraform/zitadel/*.tfvars terraform.tfvars.local skills-lock.json secrets-plaintext.yaml diff --git a/terraform/zitadel/actions.tf b/terraform/zitadel/actions.tf new file mode 100644 index 0000000..6cc4560 --- /dev/null +++ b/terraform/zitadel/actions.tf @@ -0,0 +1,58 @@ +# The groups claim — the single load-bearing decision of the migration. +# +# ZITADEL does not issue a `groups` claim. Authentik did, and these consumers +# read it: kube-apiserver RBAC (`--oidc-groups-claim=groups` plus 11 RoleBindings +# on oidc:), MinIO (`MINIO_IDENTITY_OPENID_CLAIM_NAME=groups`), Grafana +# role mapping, and every service that filters on a group name. +# +# Plan §1.3 Option A keeps those consumers unchanged by synthesizing the claim. +# Option B refactors all of them. This module takes responsibility only for the +# ZITADEL side of Option A: the action resource and its trigger. +# +# The script body is NOT shipped by default. The go/no-go gate (§1.3, §1.5 task +# 1.3) is: prove on the live instance that the action runtime can read project +# roles plus user metadata and write both `groups` and the per-agent claims, +# for a human token AND a machine-user token. Until that proof exists, enabling +# this resource would move every consumer onto an unverified claim source: +# +# terraform apply -var enable_groups_claim_action=true \ +# -var groups_claim_script=./groups-claim.js +# +# After it applies, verify with a real token exchange before moving a consumer: +# +# curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \ +# -d grant_type=refresh_token -d refresh_token= \ +# -d client_id=kubernetes | jq -R 'split(".")[1] | @base64d | fromjson' +# +# The decoded access token must contain `"groups":["homelab-admins", ...]`. + +resource "zitadel_action" "groups_claim" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + name = "homelab-groups-claim" + script = file(var.groups_claim_script) + timeout = "5s" + allowed_to_fail = false +} + +# Both triggers are needed. The access-token trigger fills the JWT that +# Kubernetes and MinIO read. The userinfo trigger fills the response that +# Grafana, paperless and Forgejo read. +resource "zitadel_trigger_actions" "groups_claim_access_token" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" + trigger_type = "TRIGGER_TYPE_PRE_ACCESS_TOKEN_CREATION" + action_ids = [zitadel_action.groups_claim[0].id] +} + +resource "zitadel_trigger_actions" "groups_claim_userinfo" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" + trigger_type = "TRIGGER_TYPE_PRE_USERINFO_CREATION" + action_ids = [zitadel_action.groups_claim[0].id] +} diff --git a/terraform/zitadel/api_apps.tf b/terraform/zitadel/api_apps.tf new file mode 100644 index 0000000..6825663 --- /dev/null +++ b/terraform/zitadel/api_apps.tf @@ -0,0 +1,18 @@ +# API applications — the machine-token surface. +# +# ZITADEL keeps two different objects apart: an OIDC application is for a +# browser flow, and an API application is what a client_credentials / JWT-profile +# caller presents. Authentik modelled both with one provider, so the plan §1.1 +# row 1 ("OIDC app + machine user") needs one of each here. +# +# The gateway signs its token request with the machine key from machines.tf, so +# the auth method is PRIVATE_KEY_JWT. No secret is stored for it. + +resource "zitadel_application_api" "machine" { + for_each = { for name, spec in local.machine_users : name => spec if contains(keys(var.api_app_consumers), name) } + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + name = "${each.key}-machine" + auth_method_type = "API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT" +} diff --git a/terraform/zitadel/clients.tf b/terraform/zitadel/clients.tf new file mode 100644 index 0000000..ce07659 --- /dev/null +++ b/terraform/zitadel/clients.tf @@ -0,0 +1,136 @@ +# OIDC applications — one per consumer that logs a browser in. +# +# Client IDs: the plan §3.2 wants every client_id byte-identical after the move, +# so that a consumer change is only the issuer URL plus the secret. +# +# THAT GOAL IS NOT ACHIEVABLE HERE, verified twice on 2026-10-24: +# - ZITADEL v4.19.2 CreateOIDCApplicationRequest has no client_id field, and +# CreateOIDCApplicationResponse documents the value as "generated" +# (proto/zitadel/app/v2beta/app_service.proto:540-650). +# - zitadel provider 3.8.7 exposes no settable application id: `id` on +# `zitadel_application_v2` is not a valid argument (terraform validate +# rejects it), and `client_id` on `zitadel_application_oidc` is computed. +# Consequence: every consumer takes a NEW client_id as well as a new secret. +# Budget the extra config change per consumer — this is not the one-line +# re-point the plan assumed. +# +# The only way to keep the old value is to create each app through the v2 REST +# API with an explicit body id, then import it: +# POST /v2/projects//apps +# {"id":"","name":"","oidcRequest":{...}} +# Not implemented here: it would put app creation outside Terraform state. +# +# Secrets are generated by ZITADEL, never written here. `terraform output -json +# client_ids` and `client_secrets` feed the SOPS re-point (§3.2, §6.9). Do not +# commit the tfstate: it carries those values. + +locals { + oidc_clients = { + # Gateway. Browser sessions for the console, plus a machine path + # (machines.tf) for callers that need client_credentials. + "api-gw" = { + redirect_uris = ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "argocd" = { + redirect_uris = ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "forgejo" = { + redirect_uris = ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "grafana" = { + redirect_uris = ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "homarr" = { + redirect_uris = ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "immich" = { + redirect_uris = ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"] + app_type = "NATIVE" + auth_method = "BASIC" + dev_mode = false + } + "kubernetes" = { + redirect_uris = ["http://localhost:8000", "http://localhost:18000"] + app_type = "WEB" + auth_method = "NONE" + dev_mode = true + } + "minio" = { + redirect_uris = ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "paperless" = { + redirect_uris = ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "poimen" = { + redirect_uris = ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "queue-sqs" = { + redirect_uris = ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "vault" = { + redirect_uris = ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + } +} + +resource "zitadel_application_v2" "oidc" { + for_each = local.oidc_clients + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + name = each.key + + oidc { + redirect_uris = each.value.redirect_uris + response_types = ["OIDC_RESPONSE_TYPE_CODE"] + grant_types = [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN", + ] + app_type = "OIDC_APP_TYPE_${each.value.app_type}" + auth_method_type = "OIDC_AUTH_METHOD_TYPE_${each.value.auth_method}" + version = "OIDC_VERSION_1_0" + + # JWT access tokens: Kubernetes RBAC, MinIO policy mapping and the Go + # gateway all read claims, and an opaque bearer token carries none. + access_token_type = "OIDC_TOKEN_TYPE_JWT" + + # Assert roles without the client having to request the scope. Every + # consumer here maps roles or groups to local permissions. + access_token_role_assertion = true + id_token_role_assertion = true + id_token_userinfo_assertion = true + + dev_mode = each.value.dev_mode + } +} diff --git a/terraform/zitadel/machines.tf b/terraform/zitadel/machines.tf new file mode 100644 index 0000000..e376798 --- /dev/null +++ b/terraform/zitadel/machines.tf @@ -0,0 +1,139 @@ +# Machine users and their project grants (plan §1.4, §5.3). +# +# Authentik gave each agent an app password or a client_credentials grant built +# on a user password. ZITADEL has no user password on a machine user: it issues +# a JWT profile key (`KEY_TYPE_JSON`) that the SDK signs its token request with. +# That removes the password path entirely, which is the point — see §5.3. +# +# Per-agent claim values are copied from `SERVICE_ACCOUNTS` in +# scripts/iam/provision-rbac.py. They live in user metadata as JSON strings, +# because the groups-claim action (actions.tf) is the only thing that turns them +# into token claims. Without that action a consumer sees roles but not +# `minio_buckets`, `authorized_models`, `sqs_queues`, `memory_projects`, +# `memory_visibility` or `paperless_doctypes`. +# +# Keys are generated per machine user and land in `terraform output -json +# machine_keys` (sensitive). Feed each one to the owning workload as a mounted +# file, never as an environment variable dumps-able through `ps`. + +locals { + machine_users = { + "memory-agent" = { + description = "Memory service agent" + project_roles = ["poimen-admins"] + authenticated_by = "poimen memory (Rust)" + metadata = { + roles = jsonencode(["llm:inference", "memory:read", "memory:write", "notification:write", "sessions:read", "sessions:write"]) + memory_projects = jsonencode(["*"]) + memory_visibility = "private" + authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"]) + } + } + "paperless-ai-agent" = { + description = "Paperless AI plugin (auto-tagging, entity extraction)" + project_roles = ["poimen-devs"] + authenticated_by = "paperless-ai" + metadata = { + roles = jsonencode(["llm:inference", "memory:write", "paperless:admin", "notification:write"]) + minio_buckets = jsonencode(["paperless"]) + paperless_doctypes = jsonencode(["*"]) + memory_projects = jsonencode(["*"]) + authorized_models = jsonencode(["reasoning", "qwen2.5:3b"]) + } + } + "portfolio-agent" = { + description = "Portfolio service agent" + project_roles = ["poimen-prod-readonly"] + authenticated_by = "portfolio service" + metadata = { + roles = jsonencode(["llm:inference", "memory:read"]) + memory_projects = jsonencode(["homelab", "portfolio"]) + memory_visibility = "public" + authorized_models = jsonencode(["ornith:35b"]) + minio_buckets = jsonencode(["backups"]) + } + } + "temporal-worker-agent" = { + description = "Temporal workflow worker" + project_roles = ["poimen-admins"] + authenticated_by = "Temporal worker" + metadata = { + roles = jsonencode(["llm:inference", "workflow:execute", "memory:read", "memory:write", "queue:send", "notification:write"]) + memory_projects = jsonencode(["*"]) + sqs_queues = jsonencode(["*"]) + authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"]) + } + } + # The gateway is the one consumer that used client_credentials on a machine + # token, so it needs both the OIDC app (clients.tf) and a machine user + # (here) plus its API application (api_apps.tf). Its capability check reads + # the `roles` claim, which arrives as metadata. + "api-gw" = { + description = "Go LLM gateway machine identity" + project_roles = [] + authenticated_by = "api-gw" + metadata = { + roles = jsonencode(["llm:inference"]) + } + } + # pi-worker has no anchor in git (plan §1.1: "not in git") and no group in + # the Authentik provisioning script. It gets a machine user and no project + # role until the owner names the consumer configuration that holds its old + # credentials. Creating a role grant blind would widen access, not preserve it. + "pi-worker" = { + description = "pi sandbox worker (no git anchor for its consumer config)" + project_roles = [] + authenticated_by = "pi sandbox worker" + metadata = { + roles = jsonencode([]) + } + } + } + + machine_metadata = merge([ + for user, spec in local.machine_users : { + for key, value in spec.metadata : "${user}/${key}" => { + user = user + key = key + value = value + } + } + ]...) +} + +resource "zitadel_machine_user" "agent" { + for_each = local.machine_users + + org_id = data.zitadel_org.default.id + user_name = each.key + name = each.key + description = "${each.value.description} — consumer: ${each.value.authenticated_by}" + with_secret = false +} + +resource "zitadel_machine_key" "agent" { + for_each = local.machine_users + + org_id = data.zitadel_org.default.id + user_id = zitadel_machine_user.agent[each.key].id + key_type = "KEY_TYPE_JSON" + expiration_date = var.machine_key_expiry +} + +resource "zitadel_project_member" "agent" { + for_each = { for user, spec in local.machine_users : user => spec if length(spec.project_roles) > 0 } + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + user_id = zitadel_machine_user.agent[each.key].id + roles = each.value.project_roles +} + +resource "zitadel_user_metadata" "agent_claim" { + for_each = local.machine_metadata + + org_id = data.zitadel_org.default.id + user_id = zitadel_machine_user.agent[each.value.user].id + key = each.value.key + value = each.value.value +} diff --git a/terraform/zitadel/outputs.tf b/terraform/zitadel/outputs.tf new file mode 100644 index 0000000..3ee5ad3 --- /dev/null +++ b/terraform/zitadel/outputs.tf @@ -0,0 +1,46 @@ +# Outputs feed two things: the SOPS re-point of consumer secrets (§3.2, §6.9) +# and the machine-key handoff to each agent workload (§5.3). Every value that can +# authenticate a client is marked sensitive, so `terraform output` prints a +# placeholder until you ask for it explicitly. + +output "org_id" { + description = "Organization that owns the project, roles and applications." + value = data.zitadel_org.default.id +} + +output "project_id" { + description = "Project ID, which ZITADEL puts in the access-token `aud` claim." + value = zitadel_project_v2.homelab.id +} + +output "client_ids" { + description = "client_id per consumer. Compare against the Authentik values: any difference means the consumer config must change too." + value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_id } +} + +output "client_secrets" { + description = "Generated client secret per consumer. Write these into the existing SOPS files; never into git." + value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_secret } + sensitive = true +} + +output "api_app_client_ids" { + description = "client_id of each API application (machine-token surface)." + value = { for name, app in zitadel_application_api.machine : name => app.client_id } +} + +output "machine_user_ids" { + description = "ZITADEL user ID per machine user." + value = { for name, user in zitadel_machine_user.agent : name => user.id } +} + +output "machine_keys" { + description = "JWT profile key JSON per machine user. Mount as a file; do not pass through the environment." + value = { for name, key in zitadel_machine_key.agent : name => key.key_details } + sensitive = true +} + +output "groups_claim_enabled" { + description = "Whether the groups-claim action and its triggers exist. False means Option A is not yet in place and no consumer may move." + value = var.enable_groups_claim_action +} diff --git a/terraform/zitadel/project.tf b/terraform/zitadel/project.tf new file mode 100644 index 0000000..8ec6d67 --- /dev/null +++ b/terraform/zitadel/project.tf @@ -0,0 +1,62 @@ +# Project, roles and default-org lookup. +# +# ZITADEL has no group object. The plan §1.3 maps every Authentik group to a +# project role, and the groups-claim action (actions.tf) reconstructs the +# `groups` claim from those role keys. Role keys therefore EQUAL the existing +# Authentik group names, including the hyphens, so that the resulting claim is +# byte-identical and no consumer or RBAC file changes. The v4.19.2 API allows +# role keys of 1-200 characters with no character-set restriction (proto +# zitadel/project/v2/project_service.proto, AddProjectRole.role_key). + +data "zitadel_org" "default" { + id = var.org_id +} + +locals { + # Single source: the 19 groups of scripts/iam/provision-rbac.py GROUPS. + # Descriptions are carried over so the console reads the same as before. + roles = { + "homelab-admins" = "Cluster administrators with full access" + "minio-admins" = "MinIO administrators" + "minio-photos" = "Photos bucket (Immich) access" + "minio-documents" = "Documents bucket (Paperless) access" + "minio-backups" = "Backups bucket read-only access" + "poimen-admins" = "Poimen memory administrators" + "poimen-devs" = "Dev and staging projects access" + "poimen-prod-readonly" = "Production projects read-only access" + "paperless-admins" = "Paperless administrators" + "paperless-finance" = "Finance documents" + "paperless-legal" = "Legal documents" + "paperless-hr" = "HR documents" + "grafana-admins" = "Grafana administrators" + "grafana-editors" = "Grafana dashboard editors" + "grafana-viewers" = "Grafana dashboard viewers" + "sqs-users" = "SQS/Temporal queue read access" + "sqs-writers" = "SQS/Temporal queue read/write access" + "s3-users" = "S3 read access" + "s3-writers" = "S3 read/write access" + } +} + +resource "zitadel_project_v2" "homelab" { + org_id = data.zitadel_org.default.id + name = var.project_name + project_id = var.project_id + project_role_check = false + + # Roles must reach the tokens. Every consumer authorizes on them: + # kube-apiserver RBAC reads `groups`, MinIO reads bucket policies, Grafana + # reads grafana_org_role. Role assertion on the ID token is what most OIDC + # clients inspect. + project_role_assertion = true +} + +resource "zitadel_project_role" "groups" { + for_each = local.roles + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + role_key = each.key + display_name = each.key + group = "homelab" +} diff --git a/terraform/zitadel/providers.tf b/terraform/zitadel/providers.tf new file mode 100644 index 0000000..59ef519 --- /dev/null +++ b/terraform/zitadel/providers.tf @@ -0,0 +1,53 @@ +# terraform/zitadel — identity configuration for the ZITADEL instance +# +# Scope: this root module declares the objects INSIDE ZITADEL (org, project, +# roles, OIDC applications, machine users, grants). It does NOT deploy ZITADEL +# itself — that is ArgoCD's job (`k8s/argocd/apps/30-security.yaml`, values in +# `k8s/infra/iam/zitadel-values.yaml`). +# +# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git). +# This file implements §1.3 (roles), §1.4 (machine users), §2.4 (grants) and +# §6.2 (Terraform provider route). The claim-injection action of §1.3 lives in +# `actions.tf` and stays disabled until the live go/no-go gate proves it. +# +# Authentication to ZITADEL uses the `iam-admin` machine key that the chart's +# setup job writes to the `iam-admin` Secret in namespace iam: +# +# kubectl -n iam get secret iam-admin -o jsonpath='{.data.iam-admin\.json}' \ +# | base64 -d > ~/.config/zitadel/iam-admin.json +# chmod 600 ~/.config/zitadel/iam-admin.json +# +# export ZITADEL_JWT_PROFILE_FILE=$HOME/.config/zitadel/iam-admin.json +# terraform -chdir=terraform/zitadel init +# terraform -chdir=terraform/zitadel plan +# +# State is LOCAL and git-ignored (`terraform/*.tfstate` in .gitignore). That is +# a deliberate first step, not the final answer: §8 risk 8 of the plan names +# state drift as a blast-radius item. Move the state to MinIO before a second +# operator ever runs `apply`. No secret is written to state except the generated +# client secrets and machine keys, which are marked sensitive — they still land +# in the state file, so treat that file as a secret. +terraform { + required_version = ">= 1.6.0" + + required_providers { + zitadel = { + source = "zitadel/zitadel" + version = "3.8.7" + } + } +} + +provider "zitadel" { + domain = var.zitadel_domain + port = var.zitadel_port + + # JWT profile of the iam-admin machine user. Preferred over a PAT: it is + # short-lived per call and never sits in the environment as a bearer token. + # Set the path through ZITADEL_JWT_PROFILE_FILE. + jwt_profile_file = var.jwt_profile_file + + # The instance serves plain HTTP behind ingress-nginx, which terminates TLS. + # Turn this on only if the provider must call the pod directly. + insecure = var.zitadel_insecure +} diff --git a/terraform/zitadel/variables.tf b/terraform/zitadel/variables.tf new file mode 100644 index 0000000..c5d3834 --- /dev/null +++ b/terraform/zitadel/variables.tf @@ -0,0 +1,103 @@ +variable "zitadel_domain" { + description = "External domain of the ZITADEL instance, without scheme." + type = string + default = "zitadel.riotpiao.com" +} + +variable "zitadel_port" { + description = "Port for the ZITADEL API. 443 behind ingress, 8080 for a direct pod port-forward." + type = string + default = "443" +} + +variable "zitadel_insecure" { + description = "Call the API over plain HTTP (set true only when port-forwarding the pod)." + type = bool + default = false +} + +variable "jwt_profile_file" { + description = "Path to the iam-admin machine key JSON extracted from the iam-admin Secret." + type = string + default = null +} + +variable "org_id" { + description = <<-EOT + ID of the ZITADEL organization that holds the project. Read it from the + console: https://zitadel.riotpiao.com -> Organization settings -> Resource + ID. The chart's setup job creates the default org; this module never creates + one, because creating a second org would split the role and grant space. + EOT + type = string +} + +variable "project_id" { + description = <<-EOT + Stable project ID. ZITADEL puts the project ID in the access token `aud` + claim, so pinning it keeps audience checks readable across a rebuild. + Every consumer that validates `aud` against a client ID must be re-pointed + to this value — see the plan §5.2. + EOT + type = string + default = "homelab" +} + +variable "project_name" { + description = "Display name of the project that carries every role and application." + type = string + default = "homelab" +} + +variable "machine_key_expiry" { + description = "Expiry of every machine key, RFC3339. Matches the chart's default iam-admin key window; rotate by changing this value and re-applying." + type = string + default = "2029-01-01T00:00:00Z" +} + +variable "api_app_consumers" { + description = <<-EOT + Machine users that also need an API application (the client_credentials + replacement). Only the gateway does: every other agent signs a JWT with its + machine key and does not need a client_credentials grant. + EOT + type = map(string) + default = { api-gw = "Go LLM gateway" } +} + +variable "enable_groups_claim_action" { + description = <<-EOT + Create the Actions v1 script and its trigger that synthesizes the `groups` + claim. Keep this false until the live go/no-go gate passes: the claim is + load-bearing for kube-apiserver RBAC, MinIO policies, Grafana role mapping + and every service that reads `groups`. Turning it on with an unverified + script can break every consumer at once. + EOT + type = bool + default = false +} + +variable "groups_claim_script" { + description = <<-EOT + Path to the JavaScript body for the groups-claim action, read only when + enable_groups_claim_action is true. The body is deliberately not shipped + blind: the ZITADEL v1 Actions runtime API must be confirmed against the live + instance before a script is committed, because a wrong call silently emits + no claim instead of failing loudly. + EOT + type = string + default = null + + validation { + # try() is required: HCL does not short-circuit `||`, so fileexists(null) + # would fail the whole plan even with the action disabled. + condition = try(fileexists(var.groups_claim_script), true) + error_message = "groups_claim_script must name an existing file when it is set." + } + + # Enabling the action without a script would reach file(null) inside actions.tf. + validation { + condition = !var.enable_groups_claim_action || var.groups_claim_script != null + error_message = "enable_groups_claim_action requires groups_claim_script." + } +} -- 2.54.0 From 2f6235173192343effebf895007be8c038a2209f Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 24 Oct 2026 03:02:15 -0700 Subject: [PATCH 3/4] feat(iam): one script to provision Zitadel end to end scripts/iam/zitadel-provision.py replaces the manual sequence that provision-rbac.py did for Authentik. Phases: keys, apply, secrets, users, verify. `all` runs keys -> apply -> secrets -> verify; users stays manual because the password hash must come from a human, one user at a time. - keys: extract the chart's iam-admin machine key and PAT to ~/.config/zitadel at 0600, outside the repo. - apply: terraform apply terraform/zitadel with the machine key. - secrets: write generated client secrets into the SOPS files, write client ids into the plain manifests that carry them, write machine keys per agent. Requires an exactly-one-match regex before it edits a manifest, prints the diff without --apply-config, and is a no-op when the value already matches. - users: import a human user with the Django pbkdf2_sha256 hash converted to the passwap MCF form ZITADEL accepts. - verify: discovery endpoint, client count, and it refuses to clear cutover while the groups-claim action is disabled. Design points that came out of review: - Multi-document SOPS files (minio) are rewritten whole, because `sops set` cannot address one document. sops reads the creation rule from the filename, so the temp file is passed with --filename-override; without it sops finds no matching rule and refuses. Ciphertext is staged in the destination directory and installed with os.replace, so the mode is preserved and EXDEV is impossible. SIGTERM/SIGINT trigger cleanup of the decrypted temp file. - homarr is listed as manual: the live workload mounts Secret homarr-oidc, which no SOPS file in git defines, and the tracked homarr-auth-oidc.enc.yaml has no consumer. Writing the new secret into the orphan file would report success while the dashboard kept the old one. - Secrets never enter argv: curl reads its config from stdin, so the PAT and the password hash do not appear in ps. .gitignore: allow this one script through the scripts/iam/*.py ignore rule (it embeds no credential), and ignore authentik-m2m-tokens.env, which holds live M2M tokens and was one `git add -A` away from being committed. Checks: py_compile clean, --help lists every phase, convert_hash verified on a synthetic hash, the 3-document SOPS rewrite tested on a real copy (3 documents survive, value set, second run unchanged, mode 644 kept, no temp left), all three client-id regexes match exactly once, dry-run leaves the manifests untouched. Reviewer round: BLOCK on the sops creation rule, the orphan homarr Secret, the missing client-id writes and argv exposure; all fixed and re-reviewed to SIGN-OFF. Not run against a live ZITADEL: no instance exists yet. --- .gitignore | 7 + scripts/iam/zitadel-provision.py | 448 +++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100755 scripts/iam/zitadel-provision.py diff --git a/.gitignore b/.gitignore index 7afa6fe..b25f730 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,10 @@ k8s/**/*-secret.yaml # IAM provisioning scripts contain credential references — never commit scripts/iam/*.py +# Exception: this one reads every credential from the cluster at run time and +# embeds none. Keep it reviewable in git. +!scripts/iam/zitadel-provision.py + +# Runtime credential export from the old Authentik provisioning run. Holds live +# M2M tokens; never commit it. +/authentik-m2m-tokens.env diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py new file mode 100755 index 0000000..73fa96f --- /dev/null +++ b/scripts/iam/zitadel-provision.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Provision ZITADEL end to end: machine key, Terraform, SOPS secrets, users. + +One command replaces the manual sequence the Authentik script +(scripts/iam/provision-rbac.py) used to do in a single pass. Phases run in a +fixed order and every phase is safe to re-run: + + keys extract the iam-admin machine key and PAT from namespace iam + apply terraform apply terraform/zitadel (project, roles, apps, machines) + secrets write generated client ids/secrets back into the SOPS files + users import one human user, converting the Django password hash + verify check discovery, client count and the groups-claim gate + +`all` runs keys, apply, secrets, verify. It never runs users: the password hash +must be supplied by a human, one user at a time. + +Nothing here talks to Authentik. Authentik keeps serving until a consumer is +moved deliberately. + +Usage: + scripts/iam/zitadel-provision.py keys + scripts/iam/zitadel-provision.py apply --org-id + scripts/iam/zitadel-provision.py secrets --dry-run + scripts/iam/zitadel-provision.py secrets --apply-config + scripts/iam/zitadel-provision.py users --django-hash '' + scripts/iam/zitadel-provision.py verify +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import signal +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[2] +TF_DIR = REPO / "terraform" / "zitadel" +SOPS_DIR = REPO / "k8s" / "argocd" / "secrets" +SOPS_CONFIG = REPO / ".sops.yaml" +CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zitadel")) +NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam") +BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com") + +_TEMP_FILES: set[str] = set() + + +def _cleanup_temps(*_a) -> None: + for path in list(_TEMP_FILES): + try: + os.unlink(path) + except OSError: + pass + _TEMP_FILES.discard(path) + + +for _sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(_sig, lambda s, f: (_cleanup_temps(), sys.exit(130))) + + +def die(msg: str) -> None: + _cleanup_temps() + print(f"FATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd: list[str], **kw) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=False, text=True, capture_output=True, **kw) + + +def need(binary: str) -> None: + if subprocess.run(["sh", "-c", f"command -v {binary}"], capture_output=True).returncode != 0: + die(f"{binary} is not on PATH") + + +def write_private(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.fchmod(fd, 0o600) # open() mode applies at creation only + with os.fdopen(fd, "wb") as fh: + fh.write(data) + finally: + pass + + +def secret_value(name: str, key: str) -> str: + cp = run(["kubectl", "-n", NAMESPACE, "get", "secret", name, "-o", f"jsonpath={{{key}}}"]) + if cp.returncode != 0: + die(f"cannot read secret {NAMESPACE}/{name}: {cp.stderr.strip()}") + raw = cp.stdout.strip() + if not raw: + die(f"secret {NAMESPACE}/{name} has no key {key}") + return base64.b64decode(raw).decode() + + +def curl_json(method: str, url: str, token: str | None = None, body: dict | None = None, + timeout: int = 20) -> tuple[int, str]: + """Call the API without putting a secret in the process argv. + + `curl --config -` reads the options from stdin, so neither the bearer token + nor the password hash of a user import shows up in `ps`. + """ + def esc(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + cfg = [f'url = "{esc(url)}"', f'request = "{method}"', "silent", "show-error", + f"max-time = {timeout}", 'write-out = "\\n%{http_code}"', + 'header = "Content-Type: application/json"'] + if token: + cfg.append(f'header = "Authorization: Bearer {esc(token)}"') + if body is not None: + cfg.append(f'data = "{esc(json.dumps(body))}"') + cp = run(["curl", "--config", "-"], input="\n".join(cfg) + "\n") + if cp.returncode != 0: + die(f"{method} {url} failed: {cp.stderr.strip()}") + payload, _, code = cp.stdout.rpartition("\n") + return int(code.strip() or 0), payload + + +# ── phase: keys ──────────────────────────────────────────────────────────── + +def phase_keys(_args: argparse.Namespace) -> int: + write_private(CONF_DIR / "iam-admin.json", secret_value("iam-admin", ".data.iam-admin\\.json").encode()) + write_private(CONF_DIR / "iam-admin.pat", secret_value("iam-admin-pat", ".data.pat").strip().encode()) + print(f"machine key -> {CONF_DIR / 'iam-admin.json'} (0600)") + print(f"PAT -> {CONF_DIR / 'iam-admin.pat'} (0600)") + print("both files stay outside the repo; never commit them") + return 0 + + +# ── phase: apply ─────────────────────────────────────────────────────────── + +def phase_apply(args: argparse.Namespace) -> int: + need("terraform") + if not args.org_id: + die("apply requires --org-id (ZITADEL console -> Organization settings -> Resource ID)") + key = CONF_DIR / "iam-admin.json" + if not key.exists(): + die(f"{key} missing — run the keys phase first") + env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key)) + + for cmd in (["init", "-input=false"], ["fmt", "-check", "-recursive"]): + cp = run(["terraform", f"-chdir={TF_DIR}", *cmd], env=env) + if cp.returncode != 0: + die(f"terraform {' '.join(cmd)} failed: {cp.stderr.strip() or cp.stdout.strip()}") + + cp = run(["terraform", f"-chdir={TF_DIR}", "apply", "-input=false", "-auto-approve", + f"-var=org_id={args.org_id}"], env=env) + sys.stdout.write(cp.stdout) + sys.stderr.write(cp.stderr) + if cp.returncode != 0: + die("terraform apply failed — no SOPS value was written. Objects created before the " + "failure stay in ZITADEL; re-run apply once the cause is fixed") + return 0 + + +# ── phase: secrets ───────────────────────────────────────────────────────── + +# client -> [(sops file, Secret name inside the file, key, kind)] +# Only secrets with a git-tracked SOPS file appear here. Every other client is +# printed as manual: guessing a path writes a credential into the wrong Secret. +SECRET_MAP = { + "grafana": [("grafana-oidc-secrets.enc.yaml", "grafana-oidc", + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "secret")], + "minio": [("minio-secrets.enc.yaml", "minio-oidc", + "MINIO_IDENTITY_OPENID_CLIENT_SECRET", "secret")], + "paperless-ai-agent": [("paperless-ai-secrets.enc.yaml", "paperless-ai-config", + "LLM_AUTH_CLIENT_SECRET", "secret")], +} + +# Clients whose client_id lives in a plain (unencrypted) manifest. They need the +# id update as much as the secret: ZITADEL generates a new client_id for every +# application, so a secret-only re-point yields invalid_client. +CLIENT_ID_MAP = { + "grafana": ("k8s/infra/logging/grafana-values.yaml", + r"(?m)^(\s*client_id:\s*)\S+\s*$", r"\g<1>{value}"), + "minio": ("k8s/infra/minio/minio-tenant.yaml", + r"(?m)^(\s*value:\s*)\"minio\"\s*$", r'\g<1>"{value}"'), + "paperless-ai-agent": ("k8s/apps/paperless/paperless-ai.yaml", + r"client_id=paperless-ai-agent", "client_id={value}"), +} + +# Secrets that a live workload mounts but no SOPS file in git defines. The phase +# cannot re-point them safely, so it names them. +MANUAL_ONLY = { + "homarr": "Secret homarr-oidc (keys client-id, client-secret) has no SOPS file in git — " + "adopt it first, then add it to SECRET_MAP. The repo also carries an orphan " + "homarr-auth-oidc.enc.yaml that nothing consumes.", +} + + +def terraform_outputs() -> dict: + need("terraform") + key = CONF_DIR / "iam-admin.json" + if not key.exists(): + die(f"{key} missing — run the keys phase first") + env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key)) + cp = run(["terraform", f"-chdir={TF_DIR}", "output", "-json"], env=env) + if cp.returncode != 0: + die(f"terraform output failed: {cp.stderr.strip()}") + return json.loads(cp.stdout or "{}") + + +def sops_env() -> dict: + env = dict(os.environ) + env.setdefault("SOPS_AGE_KEY_FILE", str(Path.home() / ".sops" / "key.txt")) + return env + + +def write_secret(rel_path: str, secret_name: str, key: str, value: str, + dry_run: bool) -> None: + path = SOPS_DIR / rel_path + if not path.exists(): + die(f"{path} does not exist — the mapping and the repo disagree") + env = sops_env() + dec = run(["sops", "--config", str(SOPS_CONFIG), "decrypt", str(path)], env=env) + if dec.returncode != 0: + die(f"cannot decrypt {rel_path}: {dec.stderr.strip()}") + docs = [d for d in yaml.safe_load_all(dec.stdout) if d] + matching = [d for d in docs if (d.get("metadata") or {}).get("name") == secret_name] + if len(matching) != 1: + die(f"{rel_path}: expected exactly one document named {secret_name}, found {len(matching)}") + + current = (matching[0].get("stringData") or {}).get(key) + if current == value: + print(f" unchanged {rel_path}:{secret_name}:{key}") + return + if dry_run: + print(f" would set {rel_path}:{secret_name}:{key} (len {len(value)})") + return + + if len(docs) == 1: + cp = run(["sops", "--config", str(SOPS_CONFIG), "set", "--value-stdin", "--idempotent", + str(path), f'["stringData"]["{key}"]'], env=env, input=json.dumps(value)) + if cp.returncode != 0: + die(f"sops set failed on {rel_path}: {cp.stderr.strip()}") + print(f" wrote {rel_path}:{secret_name}:{key}") + return + + # Multi-document file. `sops set` cannot address one document, so the whole + # file is rewritten. sops encrypt reads the creation rule from the FILENAME, + # so the temp path is overridden with the repo-relative path; without that + # override sops finds no matching rule and refuses. + matching[0].setdefault("stringData", {})[key] = value + plain = "---\n".join(yaml.safe_dump(d, sort_keys=False) for d in docs) + fd, tmp = tempfile.mkstemp(suffix=".yaml", dir=tempfile.gettempdir()) + _TEMP_FILES.add(tmp) + try: + with os.fdopen(fd, "w") as fh: + fh.write(plain) + enc = run(["sops", "--config", str(SOPS_CONFIG), "encrypt", + "--filename-override", str(path), tmp], env=env) + if enc.returncode != 0: + die(f"sops encrypt failed for {rel_path}: {enc.stderr.strip()}") + mode = stat.S_IMODE(path.stat().st_mode) + fd2, staged = tempfile.mkstemp(suffix=".enc.yaml", dir=str(path.parent)) + try: + with os.fdopen(fd2, "w") as fh: + fh.write(enc.stdout) + os.chmod(staged, mode) + os.replace(staged, path) # same directory: atomic, never EXDEV + finally: + if os.path.exists(staged): + os.unlink(staged) + print(f" wrote {rel_path}:{secret_name}:{key} (multi-document rewrite)") + finally: + if os.path.exists(tmp): + os.unlink(tmp) + _TEMP_FILES.discard(tmp) + + +def update_client_id(client: str, value: str, dry_run: bool, apply_config: bool) -> None: + spec = CLIENT_ID_MAP.get(client) + if not spec: + return + rel_path, pattern, repl = spec + path = REPO / rel_path + if not path.exists(): + die(f"{path} does not exist — the client-id mapping is stale") + text = path.read_text() + matches = list(re.finditer(pattern, text)) + if len(matches) != 1: + die(f"{rel_path}: expected 1 match for the {client} client id, found {len(matches)}") + new_line = matches[0].expand(repl.format(value=value)) + if matches[0].group(0) == new_line: + print(f" unchanged {rel_path}: {client} client id") + return + if dry_run or not apply_config: + print(f" would change {rel_path}: {matches[0].group(0).strip()} -> {new_line.strip()}") + if not apply_config: + print(" re-run with --apply-config to write this plaintext manifest edit") + return + path.write_text(text[:matches[0].start()] + new_line + text[matches[0].end():]) + print(f" wrote {rel_path}: {client} client id") + + +def phase_secrets(args: argparse.Namespace) -> int: + need("sops") + out = terraform_outputs() + ids = out.get("client_ids", {}).get("value", {}) + secrets = out.get("client_secrets", {}).get("value", {}) + machine_keys = out.get("machine_keys", {}).get("value", {}) + + if not ids and not machine_keys: + die("terraform has no ZITADEL outputs yet — run the apply phase first") + + print("[1/3] client secrets into SOPS") + for client, entries in SECRET_MAP.items(): + for rel_path, secret_name, key, kind in entries: + value = ids.get(client) if kind == "id" else secrets.get(client) + if value: + write_secret(rel_path, secret_name, key, value, args.dry_run) + + print("[2/3] client ids in plain manifests") + for client in list(CLIENT_ID_MAP) + list(SECRET_MAP): + if ids.get(client): + update_client_id(client, ids[client], args.dry_run, args.apply_config) + + print("[3/3] machine keys") + key_dir = CONF_DIR / "machine-keys" + for name, key_json in machine_keys.items(): + if args.dry_run: + print(f" would write {key_dir / (name + '.json')}") + else: + write_private(key_dir / f"{name}.json", key_json.encode()) + print(f" wrote {key_dir / (name + '.json')} (0600)") + + unhandled = sorted(set(ids) - set(SECRET_MAP) - set(CLIENT_ID_MAP) - set(MANUAL_ONLY)) + if MANUAL_ONLY or unhandled: + print("\n MANUAL — no safe write path exists for these clients:") + for client, why in MANUAL_ONLY.items(): + print(f" {client}: {why}") + for client in unhandled: + print(f" {client}: set its client_id and secret in its own manifest or Secret file") + return 0 + + +# ── phase: users ─────────────────────────────────────────────────────────── + +HASH_RE = re.compile(r"^pbkdf2_sha256\$(?P\d+)\$(?P[^$]+)\$(?P[^$]+)$") + + +def convert_hash(django_hash: str) -> str: + """Django pbkdf2_sha256 -> passwap MCF $pbkdf2-sha256$... + + ZITADEL imports one MCF string via hashedPassword.hash (max 200 chars) and + pins passwap, whose pbkdf2 format is $pbkdf2-sha256$$$ + with alt-base64 (standard base64, + -> ., no padding). Rounds, salt and hash + bytes are unchanged, so the password still verifies. + """ + m = HASH_RE.match(django_hash.strip()) + if not m: + die("unsupported hash format — only pbkdf2_sha256 is convertible") + alt = m.group("hash").rstrip("=").replace("+", ".") + mcf = f"$pbkdf2-sha256${m.group('rounds')}${m.group('salt')}${alt}" + if len(mcf) > 200: + die(f"converted hash is {len(mcf)} chars, ZITADEL accepts 200") + return mcf + + +def phase_users(args: argparse.Namespace) -> int: + pat_file = CONF_DIR / "iam-admin.pat" + if not pat_file.exists(): + die(f"{pat_file} missing — run the keys phase first") + if not args.django_hash: + die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik") + body = { + "username": args.username, + "profile": {"givenName": args.given_name, "familyName": args.family_name}, + "email": {"email": args.email, "isVerified": True}, + "hashedPassword": {"hash": convert_hash(args.django_hash)}, + "passwordChangeRequired": False, + } + code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", + token=pat_file.read_text().strip(), body=body) + if code in (200, 201): + print(f" {args.username}: created") + return 0 + if code == 409: + print(f" {args.username}: already exists — the password was NOT updated") + return 1 + die(f"create {args.username} -> {code} {payload[:300]}") + + +# ── phase: verify ────────────────────────────────────────────────────────── + +def phase_verify(_args: argparse.Namespace) -> int: + code, _ = curl_json("GET", f"{BASE_URL}/.well-known/openid-configuration", timeout=10) + print(f"discovery : {code}") + if code != 200: + die("discovery endpoint is not 200 — the instance or the ingress is not ready") + + out = terraform_outputs() + ids = out.get("client_ids", {}).get("value", {}) + print(f"client apps : {len(ids)}") + enabled = bool(out.get("groups_claim_enabled", {}).get("value")) + print(f"groups action : {enabled}") + if not enabled: + print("BLOCKED for cutover: the groups claim action is disabled. Every consumer") + print("that reads `groups` (kube-apiserver, MinIO, Grafana) sees no groups.") + print("Enable it only after the live gate passes, with the script path set.") + return 0 + + +PHASES = {"keys": phase_keys, "apply": phase_apply, "secrets": phase_secrets, + "users": phase_users, "verify": phase_verify} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Provision ZITADEL end to end.") + parser.add_argument("phase", choices=[*PHASES, "all"]) + parser.add_argument("--org-id", help="ZITADEL org id (required for apply)") + parser.add_argument("--dry-run", action="store_true", help="print actions, write nothing") + parser.add_argument("--apply-config", action="store_true", + help="also rewrite client ids inside plain manifests") + parser.add_argument("--username", default="rock") + parser.add_argument("--email", default="rock@riotpiao.com") + parser.add_argument("--given-name", default="Rock") + parser.add_argument("--family-name", default="Liang") + parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik") + args = parser.parse_args() + + need("kubectl") + if args.phase == "all": + if not args.org_id: + die("all requires --org-id") + for name in ("keys", "apply"): + rc = PHASES[name](args) + if rc: + return rc + rc = phase_secrets(args) + if rc: + return rc + return phase_verify(args) + return PHASES[args.phase](args) + + +if __name__ == "__main__": + sys.exit(main()) -- 2.54.0 From 979a413a5bc3bbb5a9c7e3ff6afcaa5b3bcbdd93 Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 24 Oct 2026 03:21:17 -0700 Subject: [PATCH 4/4] feat(iam): read the Zitadel bootstrap token from .env ZITADEL_BOOTSTRAP_TOKEN and ZITADEL_URL now live in .env and .env.example, the same shape the Authentik variables use. The line is present and empty: the PAT does not exist until the chart's setup job creates the iam-admin machine user on the first sync, so a value cannot be minted ahead of time. The provisioning script loads .env itself instead of relying on the caller to source it, and resolves the token in a fixed order: ZITADEL_BOOTSTRAP_TOKEN, then the cached file under ~/.config/zitadel, then the cluster Secret. The .env value wins so a human can paste a rotated token without touching the cluster; the cluster Secret remains the only source that always reflects a rotation. .env stays git-ignored. It already holds the live Authentik secrets, and this change does not move them. Not exercised against a live instance: the kube-apiserver on talos-cp-1 is in CrashLoopBackOff, so kubectl only works against cp-2 or cp-3 right now. --- .env.example | 11 +++++++ scripts/iam/zitadel-provision.py | 50 ++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 433188e..48e344c 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,17 @@ AUTHENTIK_BOOTSTRAP_TOKEN= # openssl rand -base64 24 AUTHENTIK_PG_PASSWORD= +# ── ZITADEL (Authentik replacement) ───────────────────────────────────────────── +# Instance URL, same hostname as the OIDC issuer. +ZITADEL_URL="https://zitadel.riotpiao.com" + +# PAT of the 'iam-admin' machine user, created by the chart's setup job on the +# first sync. Leave empty until then. Read it with: +# kubectl -n iam get secret iam-admin-pat -o jsonpath='{.data.pat}' | base64 -d +# scripts/iam/zitadel-provision.py reads this variable first, then the cluster +# Secret. The masterkey and the client secrets never live in this file. +ZITADEL_BOOTSTRAP_TOKEN= + # ── MinIO ────────────────────────────────────────────────────────────────────── MINIO_ROOT_USER= MINIO_ROOT_PASSWORD= diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index 73fa96f..58a2488 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -102,6 +102,46 @@ def secret_value(name: str, key: str) -> str: return base64.b64decode(raw).decode() +def load_env_file(path: Path = REPO / ".env") -> None: + """Load KEY=VALUE lines from .env without overriding the real environment. + + The file is git-ignored and holds the bootstrap token a human pastes in. + Sourcing it in the shell would work too, but then every phase depends on the + caller remembering to do it. + """ + if not path.exists(): + return + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def bootstrap_token() -> str | None: + """Resolve the iam-admin PAT: environment, then cached file, then cluster. + + Order matters. ZITADEL_BOOTSTRAP_TOKEN in .env is filled by a human after the + first sync and wins. The cached file under ~/.config/zitadel keeps a run + working when the API server is unreachable. The cluster Secret is the last + resort and the only source that always reflects a rotation. + """ + token = os.environ.get("ZITADEL_BOOTSTRAP_TOKEN", "").strip() + if token: + return token + cached = CONF_DIR / "iam-admin.pat" + if cached.exists(): + return cached.read_text().strip() + try: + return secret_value("iam-admin-pat", ".data.pat").strip() + except SystemExit: + return None + + def curl_json(method: str, url: str, token: str | None = None, body: dict | None = None, timeout: int = 20) -> tuple[int, str]: """Call the API without putting a secret in the process argv. @@ -368,9 +408,9 @@ def convert_hash(django_hash: str) -> str: def phase_users(args: argparse.Namespace) -> int: - pat_file = CONF_DIR / "iam-admin.pat" - if not pat_file.exists(): - die(f"{pat_file} missing — run the keys phase first") + token = bootstrap_token() + if not token: + die("no bootstrap token — set ZITADEL_BOOTSTRAP_TOKEN in .env, or run the keys phase") if not args.django_hash: die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik") body = { @@ -380,8 +420,7 @@ def phase_users(args: argparse.Namespace) -> int: "hashedPassword": {"hash": convert_hash(args.django_hash)}, "passwordChangeRequired": False, } - code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", - token=pat_file.read_text().strip(), body=body) + code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", token=token, body=body) if code in (200, 201): print(f" {args.username}: created") return 0 @@ -429,6 +468,7 @@ def main() -> int: parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik") args = parser.parse_args() + load_env_file() need("kubectl") if args.phase == "all": if not args.org_id: -- 2.54.0