Compare commits

...
Author SHA1 Message Date
rock 6ee22401fd fix(sops): drop the orphan forgejo-registry-pat file
ksops failed the whole sops-secrets application on this file: it declares
Secret forgejo-registry-pat in namespace forgejo, and no such namespace exists.
The failure blocks every other file in the generator list, including
zitadel-masterkey.

The same Secret name is already generated in namespace api by
forgejo-registry-pat-api.enc.yaml, and that is the copy the gateway reads. The
forgejo namespace copy is stale: Forgejo runs in namespace cicd, and namespace
forgejo was never created.

Remove the file and its generator entry. ArgoCD never created the Secret, so
pruning has nothing to remove.
2026-10-24 03:46:29 -07:00
rock e9817283e6 Merge pull request 'feat(iam): wire Zitadel as the Authentik replacement' (#63) from harness/zitadel-iam-wiring into main 2026-10-24 10:41:17 +00:00
rock 6e9586aa15 Merge pull request 'chore(argocd): delete the azhi-riotpiao Application' (#64) from chore/remove-azhi-riotpiao-app into main 2026-10-24 10:41:09 +00:00
rock 979a413a5b 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.
2026-10-24 03:38:21 -07:00
rock 2f62351731 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.
2026-10-24 03:38:21 -07:00
rock f81ff0e2fc feat(iam): declare Zitadel identity objects in Terraform
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.
2026-10-24 03:38:21 -07:00
rock cb1219bf96 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.
2026-10-24 03:38:21 -07:00
rock 8e9ac37583 chore(argocd): delete the azhi-riotpiao Application
The Application pointed at github.com/ToyoGlobal/azhi-infra, branch
feat/azi-riotpiao-cnpg, path k8s/overlays/azi-riotpiao. It never reconciled:
status stayed Unknown because the source repository could not be read, and the
destination namespace azi-riotpiao was never created. ArgoCD reported it as an
OutOfSync managed resource of homelab-root with nothing behind it.

Also drops the ToyoGlobal URL from the AppProject allowlist. No manifest from
that repository is referenced any more, so the allowlist entry is dead weight.

Not touched here, because it is not in git: Secret azhi-infra-github in
namespace argocd holds the read-only GitHub credential for that repository.
Delete it separately once no other Application needs the repo.

The live Application object was deleted by hand. Until this commit reaches
main, the next homelab-root sync recreates it, because main still declares it.
2026-10-24 03:35:52 -07:00
rock 3c851b16c9 Merge branch 'fix/certmanager-dns01'
Restores wildcard renewal: pin the ACME DNS-01 self-check to the public
resolvers and drop the per-host TLS blocks that competed with the wildcard.
2026-10-24 02:39:38 -07:00
23 changed files with 1446 additions and 66 deletions
+11
View File
@@ -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=
+12
View File
@@ -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
@@ -69,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
+49
View File
@@ -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
-36
View File
@@ -1,36 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: azhi-riotpiao
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: '8'
spec:
project: homelab
source:
# Private GitHub. The read-only credential is Secret azhi-infra-github in
# namespace argocd, created out-of-band.
repoURL: https://github.com/ToyoGlobal/azhi-infra.git
# The repository's default branch. It is not `main`: this repo's work
# happens on the staging slice branch, and `main` is behind it.
targetRevision: feat/azi-riotpiao-cnpg
# One directory, because ArgoCD reads a single path per source. That
# directory is a root kustomization over foundation, postgres and
# azhi-server.
path: k8s/overlays/azi-riotpiao
destination:
server: https://kubernetes.default.svc
namespace: azi-riotpiao
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
# The two Secrets this namespace needs are credentials, so they are created
# by hand and NOT by this Application:
# acr-pull — the private Alibaba ACR pull credential
# azhi-riotpiao-app-env — DB credentials, JWT secrets, AES key
# A missing imagePullSecret fails the pod outright and a missing app-env
# Secret leaves it in CreateContainerConfigError, which is the intended
# loud failure rather than a pod that runs against unknown values.
-6
View File
@@ -16,12 +16,6 @@ spec:
- https://github.com/Riotpiaole/Poimen-memory.git
- https://github.com/Riotpiaole/Poimen-workflows.git
- https://github.com/Riotpiaole/poimen*.git
# ToyoGlobal (GitHub, private). The azhi-infra Application reads its
# manifests from here, so the URL must be listed or ArgoCD rejects the
# Application with "repository not permitted". A read-only credential for
# this repo lives in Secret azhi-infra-github in namespace argocd; it is
# created out-of-band and is not committed, because it is a credential.
- https://github.com/ToyoGlobal/azhi-infra.git
# In-cluster Forgejo repos — explicit allowlist (no wildcard)
- https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
- https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend.git
@@ -1,23 +0,0 @@
apiVersion: ENC[AES256_GCM,data:ECE=,iv:bISz4HovH++X7DW1Qj8Cw0L6+EvPB0+68hh7tfyW5C0=,tag:w+SjD7MsfeIuSf62n+Zl7Q==,type:str]
kind: ENC[AES256_GCM,data:AxXaV3Mb,iv:xfJb354Rrjz3zLctW2i6hl40yh9EsfIqKxDvmn8jqnU=,tag:1xQ/RR3KhPLKCTWCYjebJg==,type:str]
metadata:
name: ENC[AES256_GCM,data:gdQ4OoMYKUPEbsUeA5OI4i1xnh8=,iv:IA2kaCyqQmkYpLolvcTF4aleh+yd/ImXJMhRMvpGCgo=,tag:8qyYYw4EhBKKPzEmpepAeQ==,type:str]
namespace: ENC[AES256_GCM,data:/mOSyXWmRg==,iv:cpeHUTlMqlJzJttGtuR3DoiMtvVqFmDS0/5Tl7K7c2M=,tag:q4GxWgWn2wQJxJHqnq4WQA==,type:str]
type: ENC[AES256_GCM,data:92OcrsuV,iv:Z1XBy6iZ6unGrK4/SSdDa58pbPL3022gU/f1AOM4uvc=,tag:cd4wWRq/ohm6BJ/Eo6HOIw==,type:str]
stringData:
REGISTRY_PAT: ENC[AES256_GCM,data:zDKruOXhzsIcFZTTp4r4r9rwHih9uf1cCp06KZ6eIXSIZDvR6aQY4A==,iv:5Vch5Z7xn1KkRxrgOs2p/M58nd6WhtcuUhoZDCi8LSY=,tag:14SAZIWGOxJMqLBtOorjYQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIaG5QVUYyNjhvaENPcUFy
K3RSSVM2N2hTeFhNK0M5YWZmODhzZU1HaHlFCmNGZWdFcFEvanVwcXpGSmVvRHVx
aG45c2lBY1RuSTYwbDZTVk1QeHNWOTQKLS0tIDg4TnVMNjNtaU1VQk5zQjUvU2hM
R245ZVdqc0ZWWVhJb3dOZ3lpU3JjUlUKawSg09ZPq8FKx5tvOVZZ+K4yh7eTQsUp
be8mWUpS0+eEmNqh35BwU3HrETMQFA6a1kjVp30JOMtqa5rbYlzF7w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-23T23:14:53Z"
mac: ENC[AES256_GCM,data:yzi6woUaUCi6w9pG/eKnU7k/VfZgoXg/tW8p9joG8p7iabLWMdlXlx23m/CItw/NE0zeGWA5iZPPFZXOit2vN36VzK3kQNoFW6QbhvYLZi+78C/RWIdcpZkxB/tPIRG0vq8Q1SA+rwGjG/0xeAFh+R7k+YBTMd2XWBH3P7EI5T4=,iv:kf6MDaAVrtDvPIEjHMMLxXDSDRC3I1GpsPeJFUYppiw=,tag:NukVbGLa9EoMYmRsa4nBtA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+1 -1
View File
@@ -11,7 +11,6 @@ files:
- agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml
- cloudflare-secrets.enc.yaml
- forgejo-registry-pat.enc.yaml
- forgejo-registry-pat-api.enc.yaml
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
@@ -38,3 +37,4 @@ files:
- forgejo-registry-token.enc.yaml
- pi-sandbox-secrets.enc.yaml
- etcd-defrag-creds.enc.yaml
- 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
+1
View File
@@ -7,5 +7,6 @@ kind: Kustomization
resources:
- namespaces.yaml
- authentik-db.yaml
- zitadel-db.yaml
- temporal-db.yaml
- paperless-db.yaml
+42
View File
@@ -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
+134
View File
@@ -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
@@ -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 }}"
+1
View File
@@ -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
+488
View File
@@ -0,0 +1,488 @@
#!/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 <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 '<pbkdf2_sha256$...>'
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 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.
`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<rounds>\d+)\$(?P<salt>[^$]+)\$(?P<hash>[^$]+)$")
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$<rounds>$<salt>$<hash>
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:
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 = {
"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=token, 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="[email protected]")
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()
load_env_file()
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())
+1
View File
@@ -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
+58
View File
@@ -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:<group>), 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=<t> \
# -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]
}
+18
View File
@@ -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"
}
+136
View File
@@ -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/<project_id>/apps
# {"id":"<old-client-id>","name":"<old-client-id>","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
}
}
+139
View File
@@ -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
}
+46
View File
@@ -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
}
+62
View File
@@ -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"
}
+53
View File
@@ -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
}
+103
View File
@@ -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."
}
}