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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user