diff --git a/.pi/loops/loops-01a16cb7-780d-72af-bac3-487935898ff3.json b/.pi/loops/loops-01a16cb7-780d-72af-bac3-487935898ff3.json new file mode 100644 index 0000000..b1bbff7 --- /dev/null +++ b/.pi/loops/loops-01a16cb7-780d-72af-bac3-487935898ff3.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/k8s/infra/iam/zitadel-values.yaml b/k8s/infra/iam/zitadel-values.yaml index c54e915..0fd50f7 100644 --- a/k8s/infra/iam/zitadel-values.yaml +++ b/k8s/infra/iam/zitadel-values.yaml @@ -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 diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index 58a2488..46f3aa0 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -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")) diff --git a/terraform/edge/providers.tf b/terraform/edge/providers.tf index 3c8cf0f..f898708 100644 --- a/terraform/edge/providers.tf +++ b/terraform/edge/providers.tf @@ -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 { diff --git a/terraform/edge/terraform.tfvars.example b/terraform/edge/terraform.tfvars.example index 18ee885..83923aa 100644 --- a/terraform/edge/terraform.tfvars.example +++ b/terraform/edge/terraform.tfvars.example @@ -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"]' \ +# / +# +# 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://.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. diff --git a/terraform/zitadel/actions.tf b/terraform/zitadel/actions.tf deleted file mode 100644 index 6cc4560..0000000 --- a/terraform/zitadel/actions.tf +++ /dev/null @@ -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:), MinIO (`MINIO_IDENTITY_OPENID_CLAIM_NAME=groups`), Grafana -# role mapping, and every service that filters on a group name. -# -# Plan §1.3 Option A keeps those consumers unchanged by synthesizing the claim. -# Option B refactors all of them. This module takes responsibility only for the -# ZITADEL side of Option A: the action resource and its trigger. -# -# The script body is NOT shipped by default. The go/no-go gate (§1.3, §1.5 task -# 1.3) is: prove on the live instance that the action runtime can read project -# roles plus user metadata and write both `groups` and the per-agent claims, -# for a human token AND a machine-user token. Until that proof exists, enabling -# this resource would move every consumer onto an unverified claim source: -# -# terraform apply -var enable_groups_claim_action=true \ -# -var groups_claim_script=./groups-claim.js -# -# After it applies, verify with a real token exchange before moving a consumer: -# -# curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \ -# -d grant_type=refresh_token -d refresh_token= \ -# -d client_id=kubernetes | jq -R 'split(".")[1] | @base64d | fromjson' -# -# The decoded access token must contain `"groups":["homelab-admins", ...]`. - -resource "zitadel_action" "groups_claim" { - count = var.enable_groups_claim_action ? 1 : 0 - - org_id = data.zitadel_org.default.id - name = "homelab-groups-claim" - script = file(var.groups_claim_script) - timeout = "5s" - allowed_to_fail = false -} - -# Both triggers are needed. The access-token trigger fills the JWT that -# Kubernetes and MinIO read. The userinfo trigger fills the response that -# Grafana, paperless and Forgejo read. -resource "zitadel_trigger_actions" "groups_claim_access_token" { - count = var.enable_groups_claim_action ? 1 : 0 - - org_id = data.zitadel_org.default.id - flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" - trigger_type = "TRIGGER_TYPE_PRE_ACCESS_TOKEN_CREATION" - action_ids = [zitadel_action.groups_claim[0].id] -} - -resource "zitadel_trigger_actions" "groups_claim_userinfo" { - count = var.enable_groups_claim_action ? 1 : 0 - - org_id = data.zitadel_org.default.id - flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" - trigger_type = "TRIGGER_TYPE_PRE_USERINFO_CREATION" - action_ids = [zitadel_action.groups_claim[0].id] -} diff --git a/terraform/zitadel/api_apps.tf b/terraform/zitadel/api_apps.tf deleted file mode 100644 index 6825663..0000000 --- a/terraform/zitadel/api_apps.tf +++ /dev/null @@ -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" -} diff --git a/terraform/zitadel/clients.tf b/terraform/zitadel/clients.tf deleted file mode 100644 index ce07659..0000000 --- a/terraform/zitadel/clients.tf +++ /dev/null @@ -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//apps -# {"id":"","name":"","oidcRequest":{...}} -# Not implemented here: it would put app creation outside Terraform state. -# -# Secrets are generated by ZITADEL, never written here. `terraform output -json -# client_ids` and `client_secrets` feed the SOPS re-point (§3.2, §6.9). Do not -# commit the tfstate: it carries those values. - -locals { - oidc_clients = { - # Gateway. Browser sessions for the console, plus a machine path - # (machines.tf) for callers that need client_credentials. - "api-gw" = { - redirect_uris = ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "argocd" = { - redirect_uris = ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "forgejo" = { - redirect_uris = ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "grafana" = { - redirect_uris = ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "homarr" = { - redirect_uris = ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "immich" = { - redirect_uris = ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"] - app_type = "NATIVE" - auth_method = "BASIC" - dev_mode = false - } - "kubernetes" = { - redirect_uris = ["http://localhost:8000", "http://localhost:18000"] - app_type = "WEB" - auth_method = "NONE" - dev_mode = true - } - "minio" = { - redirect_uris = ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "paperless" = { - redirect_uris = ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "poimen" = { - redirect_uris = ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "queue-sqs" = { - redirect_uris = ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - "vault" = { - redirect_uris = ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"] - app_type = "WEB" - auth_method = "BASIC" - dev_mode = false - } - } -} - -resource "zitadel_application_v2" "oidc" { - for_each = local.oidc_clients - - org_id = data.zitadel_org.default.id - project_id = zitadel_project_v2.homelab.id - name = each.key - - oidc { - redirect_uris = each.value.redirect_uris - response_types = ["OIDC_RESPONSE_TYPE_CODE"] - grant_types = [ - "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", - "OIDC_GRANT_TYPE_REFRESH_TOKEN", - ] - app_type = "OIDC_APP_TYPE_${each.value.app_type}" - auth_method_type = "OIDC_AUTH_METHOD_TYPE_${each.value.auth_method}" - version = "OIDC_VERSION_1_0" - - # JWT access tokens: Kubernetes RBAC, MinIO policy mapping and the Go - # gateway all read claims, and an opaque bearer token carries none. - access_token_type = "OIDC_TOKEN_TYPE_JWT" - - # Assert roles without the client having to request the scope. Every - # consumer here maps roles or groups to local permissions. - access_token_role_assertion = true - id_token_role_assertion = true - id_token_userinfo_assertion = true - - dev_mode = each.value.dev_mode - } -} diff --git a/terraform/zitadel/machines.tf b/terraform/zitadel/machines.tf deleted file mode 100644 index e376798..0000000 --- a/terraform/zitadel/machines.tf +++ /dev/null @@ -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 -} diff --git a/terraform/zitadel/outputs.tf b/terraform/zitadel/outputs.tf deleted file mode 100644 index 3ee5ad3..0000000 --- a/terraform/zitadel/outputs.tf +++ /dev/null @@ -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 -} diff --git a/terraform/zitadel/project.tf b/terraform/zitadel/project.tf deleted file mode 100644 index 8ec6d67..0000000 --- a/terraform/zitadel/project.tf +++ /dev/null @@ -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" -} diff --git a/terraform/zitadel/providers.tf b/terraform/zitadel/providers.tf deleted file mode 100644 index 59ef519..0000000 --- a/terraform/zitadel/providers.tf +++ /dev/null @@ -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 -} diff --git a/terraform/zitadel/variables.tf b/terraform/zitadel/variables.tf deleted file mode 100644 index c5d3834..0000000 --- a/terraform/zitadel/variables.tf +++ /dev/null @@ -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." - } -}