refactor(iam): retire terraform/zitadel, provision through the API

The Terraform root module duplicated a writer that the provisioning script could
be, and its state carried generated client secrets. Declare the objects in the
script instead: project, 19 roles, 12 OIDC apps, applied through the management
API with the iam-admin PAT. State is a 0600 JSON file under ~/.config/zitadel.

Also add zitadel to the Cloudflare tunnel hostname list, with the import command
and the note that the tunnel's Public Hostname entry is a separate dashboard
step: DNS alone yields a 404 from the tunnel.
This commit is contained in:
pi
2026-10-25 19:35:01 -07:00
parent be44a576d4
commit d4e9dbf350
13 changed files with 213 additions and 653 deletions
@@ -0,0 +1,22 @@
{
"nextId": 2,
"loops": [
{
"id": "1",
"prompt": "Check Longhorn and CNPG recovery progress in the homelab cluster and report terse. Run: kubectl -n longhorn-system get volumes.longhorn.io -o json (count by state/robustness), kubectl -n longhorn-system get replicas.longhorn.io -o json (count running/stopped/failed), kubectl get clusters.postgresql.cnpg.io -A (ready instances per cluster), kubectl -n cicd get pods,pvc | grep forgejo-db. Report: volumes healthy vs degraded, replicas running vs stopped, and each CNPG cluster ready count. If every volume is attached/healthy and every CNPG cluster shows 3/3 ready, say RECOVERED, list any leftover anomalies, and stop this loop with LoopDelete. Otherwise name what is still rebuilding and keep going.",
"trigger": {
"type": "cron",
"schedule": "*/5 * * * *"
},
"status": "expired",
"recurring": true,
"createdAt": 1792898370946,
"updatedAt": 1792901371403,
"expiresAt": 1793503170946,
"source": "tool",
"readOnly": true,
"maxFires": 6,
"fireCount": 6
}
]
}
+1 -1
View File
@@ -36,7 +36,7 @@ zitadel:
# `iam-admin` machine key all exist. Skip stops the setup hook from running
# the first-instance step again. Without it, every sync re-runs the
# machinekeyWriter, which silently rotates the iam-admin key Terraform
# reads and breaks the next `terraform apply` in terraform/zitadel.
# reads. ZITADEL objects are declared by scripts/iam/zitadel-provision.py.
# Never set this back to false.
FirstInstance:
Skip: true
+180 -34
View File
@@ -6,7 +6,7 @@ One command replaces the manual sequence the Authentik script
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)
apply create project, roles and OIDC apps through the ZITADEL API
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
@@ -43,13 +43,85 @@ 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"))
STATE_FILE = CONF_DIR / "state.json"
NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam")
BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com")
# ── desired ZITADEL state ──────────────────────────────────────────────────
# Declared here, applied through the API by the `apply` phase. This replaces the
# retired terraform/zitadel root module: same objects, no state file to drift and
# no second writer.
PROJECT_NAME = os.environ.get("ZITADEL_PROJECT_NAME", "homelab")
PROJECT_GROUP = "homelab"
# Role keys EQUAL the Authentik group names, so the `groups` claim keeps working
# for every consumer that maps on it (kube-apiserver RBAC, MinIO policies,
# Grafana roles).
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",
}
_WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"}
_NATIVE = {"app_type_id": "OIDC_APP_TYPE_NATIVE", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_NONE"}
_PKCE = {"app_type_id": "OIDC_APP_TYPE_USER_AGENT", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_NONE"}
OIDC_APPS = {
"api-gw": {**_WEB, "redirect_uris": [
"http://localhost:3000/callback", "https://api.riotpiao.com/callback"]},
"argocd": {**_WEB, "redirect_uris": [
"http://localhost:8080/auth/callback",
"https://argocd.riotpiao.com/auth/callback"]},
"forgejo": {**_WEB, "redirect_uris": [
"http://localhost:3000/user/oauth2/authentik/callback",
"https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]},
"grafana": {**_WEB, "redirect_uris": [
"http://localhost:3000/login/generic_oauth",
"https://grafana.riotpiao.com/login/generic_oauth"]},
"homarr": {**_WEB, "redirect_uris": [
"http://localhost:7575/auth/callback",
"https://homarr.riotpiao.com/auth/callback"]},
"immich": {**_WEB, "app_type_id": "OIDC_APP_TYPE_NATIVE", "redirect_uris": [
"app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login",
"https://img.riotpiao.com/user/oauth2/callback"]},
"kubernetes": {**_PKCE, "dev_mode": True, "redirect_uris": [
"http://localhost:8000", "http://localhost:18000"]},
"minio": {**_WEB, "redirect_uris": [
"http://localhost:9000/auth/sso/oauth2/code",
"https://minio.riotpiao.com/auth/sso/oauth2/code"]},
"paperless": {**_WEB, "redirect_uris": [
"http://localhost:8000/accounts/oidc/authentik/login/callback/",
"https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"]},
"poimen": {**_WEB, "redirect_uris": [
"http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]},
"queue-sqs": {**_WEB, "redirect_uris": [
"http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]},
"vault": {**_WEB, "redirect_uris": [
"http://localhost:8200/ui/vault/auth/oidc/oidc/callback",
"https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"]},
}
_TEMP_FILES: set[str] = set()
@@ -155,6 +227,13 @@ def curl_json(method: str, url: str, token: str | None = None, body: dict | None
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"']
# Reaching the instance by name needs either public DNS through the tunnel
# or a pinned address. ZITADEL picks the instance from the Host header, so a
# plain port-forward fails with "Instance not found". Set this to bypass DNS:
# ZITADEL_CURL_RESOLVE=zitadel.riotpiao.com:443:192.168.1.160
pinned = os.environ.get("ZITADEL_CURL_RESOLVE", "").strip()
if pinned:
cfg.append(f'resolve = "{esc(pinned)}"')
if token:
cfg.append(f'header = "Authorization: Bearer {esc(token)}"')
if body is not None:
@@ -179,27 +258,99 @@ def phase_keys(_args: argparse.Namespace) -> int:
# ── 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))
def _api(method: str, path: str, token: str, body: dict | None = None,
timeout: int = 30) -> tuple[int, dict]:
code, payload = curl_json(method, f"{BASE_URL}{path}", token=token, body=body, timeout=timeout)
try:
return code, (json.loads(payload) if payload.strip() else {})
except json.JSONDecodeError:
return code, {"_raw": payload}
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")
def _ensure_project(token: str, name: str) -> str:
code, d = _api("POST", "/management/v1/projects/_search", token, {})
for row in d.get("result") or []:
if row.get("name") == name:
return row["id"]
code, d = _api("POST", "/management/v1/projects", token, {"name": name})
if code not in (200, 201) or not d.get("id"):
die(f"create project {name} failed: {code} {d}")
return d["id"]
def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None:
code, d = _api("POST", f"/management/v1/projects/{project_id}/roles/_search", token, {})
have = {r.get("roleKey") for r in (d.get("result") or [])}
for key, display in roles.items():
if key in have:
continue
code, d = _api("POST", f"/management/v1/projects/{project_id}/roles", token,
{"roleKey": key, "displayName": display, "group": PROJECT_GROUP})
if code not in (200, 201):
die(f"create role {key} failed: {code} {d}")
def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict]) -> tuple[dict, dict]:
code, d = _api("POST", f"/management/v1/projects/{project_id}/apps/_search", token, {})
existing = {a.get("name"): a for a in (d.get("result") or [])}
ids: dict[str, str] = {}
secrets: dict[str, str] = {}
for name, spec in apps.items():
row = existing.get(name)
if row:
ids[name] = (row.get("oidcConfig") or {}).get("clientId", "")
app_id = row.get("id")
else:
body = {
"name": name,
"redirectUris": spec["redirect_uris"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
"OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": spec.get("app_type_id", "OIDC_APP_TYPE_WEB"),
"authMethodType": spec.get("auth_method_id", "OIDC_AUTH_METHOD_TYPE_BASIC"),
"devMode": bool(spec.get("dev_mode")),
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"version": "OIDC_VERSION_1_0",
"idTokenRoleAssertion": True,
}
code, d = _api("POST", f"/management/v1/projects/{project_id}/apps/oidc", token, body)
if code not in (200, 201) or not d.get("appId"):
die(f"create app {name} failed: {code} {d}")
app_id = d["appId"]
ids[name] = d.get("clientId", "")
if spec.get("auth_method_id") == "OIDC_AUTH_METHOD_TYPE_NONE":
continue
code, d = _api("POST",
f"/management/v1/projects/{project_id}/apps/{app_id}"
"/oidc_config/_generate_client_secret", token, {})
if code in (200, 201) and d.get("clientSecret"):
secrets[name] = d["clientSecret"]
return ids, secrets
def phase_apply(_args: argparse.Namespace) -> int:
"""Declare the ZITADEL objects through the API. No Terraform, no state drift."""
token = bootstrap_token()
if not token:
die("no iam-admin PAT — run the keys phase first")
project_id = _ensure_project(token, PROJECT_NAME)
print(f"project : {PROJECT_NAME} ({project_id})")
_ensure_roles(token, project_id, ROLES)
print(f"roles : {len(ROLES)} ensured")
ids, secrets = _ensure_oidc_apps(token, project_id, OIDC_APPS)
print(f"oidc apps : {len(ids)} ensured, {len(secrets)} secrets issued")
state = {
"project_id": {"value": project_id},
"client_ids": {"value": ids},
"client_secrets": {"value": secrets},
"machine_keys": {"value": {}},
"groups_claim_enabled": {"value": False},
}
write_private(STATE_FILE, json.dumps(state, indent=2, sort_keys=True).encode())
print(f"state -> {STATE_FILE} (0600)")
return 0
@@ -238,16 +389,11 @@ MANUAL_ONLY = {
}
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 state_outputs() -> dict:
"""Read the object map written by the apply phase."""
if not STATE_FILE.exists():
die(f"{STATE_FILE} missing — run the apply phase first")
return json.loads(STATE_FILE.read_text())
def sops_env() -> dict:
@@ -345,13 +491,13 @@ def update_client_id(client: str, value: str, dry_run: bool, apply_config: bool)
def phase_secrets(args: argparse.Namespace) -> int:
need("sops")
out = terraform_outputs()
out = state_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")
die("no ZITADEL objects recorded yet — run the apply phase first")
print("[1/3] client secrets into SOPS")
for client, entries in SECRET_MAP.items():
@@ -438,7 +584,7 @@ def phase_verify(_args: argparse.Namespace) -> int:
if code != 200:
die("discovery endpoint is not 200 — the instance or the ingress is not ready")
out = terraform_outputs()
out = state_outputs()
ids = out.get("client_ids", {}).get("value", {})
print(f"client apps : {len(ids)}")
enabled = bool(out.get("groups_claim_enabled", {}).get("value"))
+1 -1
View File
@@ -18,7 +18,7 @@
# terraform -chdir=terraform/edge init
# terraform -chdir=terraform/edge plan -var china_domain=example.cn ...
#
# State is local and git-ignored, same as terraform/zitadel. It carries no
# State is local and git-ignored. It carries no
# credential today, but it will once InstanceData or certificates are added, so
# treat it as a secret and move it to MinIO before a second operator applies.
terraform {
+9 -2
View File
@@ -8,8 +8,15 @@ cloudflare_zone_id = "REPLACE_WITH_ZONE_ID"
tunnel_id = "REPLACE_WITH_TUNNEL_UUID"
# Adopt existing records with `terraform import` before the first apply, or the
# create call collides with the hand-made record of the same name.
cloudflare_tunnel_hostnames = ["paperless", "img", "poimen"]
# create call collides with the hand-made record of the same name:
# terraform import 'cloudflare_dns_record.tunnel["zitadel"]' \
# <zone_id>/<record_id>
#
# A tunnel CNAME is only half of the path. The tunnel's Public Hostname list
# (Zero Trust -> Networks -> Tunnels -> Public Hostname) must also map the name
# to https://<name>.riotpiao.com, or Cloudflare answers 404 from the tunnel even
# though DNS resolves.
cloudflare_tunnel_hostnames = ["paperless", "img", "poimen", "zitadel"]
# Leave false until the tunnel config import is understood. True makes
# Terraform replace the entire ingress list with tunnel_ingress.
-58
View File
@@ -1,58 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,136 +0,0 @@
# 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
@@ -1,139 +0,0 @@
# 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
@@ -1,46 +0,0 @@
# 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
@@ -1,62 +0,0 @@
# 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
@@ -1,53 +0,0 @@
# 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
@@ -1,103 +0,0 @@
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."
}
}