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
|
# `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
|
# the first-instance step again. Without it, every sync re-runs the
|
||||||
# machinekeyWriter, which silently rotates the iam-admin key Terraform
|
# 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.
|
# Never set this back to false.
|
||||||
FirstInstance:
|
FirstInstance:
|
||||||
Skip: true
|
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:
|
fixed order and every phase is safe to re-run:
|
||||||
|
|
||||||
keys extract the iam-admin machine key and PAT from namespace iam
|
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
|
secrets write generated client ids/secrets back into the SOPS files
|
||||||
users import one human user, converting the Django password hash
|
users import one human user, converting the Django password hash
|
||||||
verify check discovery, client count and the groups-claim gate
|
verify check discovery, client count and the groups-claim gate
|
||||||
@@ -43,13 +43,85 @@ from pathlib import Path
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
TF_DIR = REPO / "terraform" / "zitadel"
|
|
||||||
SOPS_DIR = REPO / "k8s" / "argocd" / "secrets"
|
SOPS_DIR = REPO / "k8s" / "argocd" / "secrets"
|
||||||
SOPS_CONFIG = REPO / ".sops.yaml"
|
SOPS_CONFIG = REPO / ".sops.yaml"
|
||||||
CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zitadel"))
|
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")
|
NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam")
|
||||||
BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com")
|
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()
|
_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",
|
cfg = [f'url = "{esc(url)}"', f'request = "{method}"', "silent", "show-error",
|
||||||
f"max-time = {timeout}", 'write-out = "\\n%{http_code}"',
|
f"max-time = {timeout}", 'write-out = "\\n%{http_code}"',
|
||||||
'header = "Content-Type: application/json"']
|
'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:
|
if token:
|
||||||
cfg.append(f'header = "Authorization: Bearer {esc(token)}"')
|
cfg.append(f'header = "Authorization: Bearer {esc(token)}"')
|
||||||
if body is not None:
|
if body is not None:
|
||||||
@@ -179,27 +258,99 @@ def phase_keys(_args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# ── phase: apply ───────────────────────────────────────────────────────────
|
# ── phase: apply ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def phase_apply(args: argparse.Namespace) -> int:
|
def _api(method: str, path: str, token: str, body: dict | None = None,
|
||||||
need("terraform")
|
timeout: int = 30) -> tuple[int, dict]:
|
||||||
if not args.org_id:
|
code, payload = curl_json(method, f"{BASE_URL}{path}", token=token, body=body, timeout=timeout)
|
||||||
die("apply requires --org-id (ZITADEL console -> Organization settings -> Resource ID)")
|
try:
|
||||||
key = CONF_DIR / "iam-admin.json"
|
return code, (json.loads(payload) if payload.strip() else {})
|
||||||
if not key.exists():
|
except json.JSONDecodeError:
|
||||||
die(f"{key} missing — run the keys phase first")
|
return code, {"_raw": payload}
|
||||||
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
|
||||||
|
|
||||||
for cmd in (["init", "-input=false"], ["fmt", "-check", "-recursive"]):
|
|
||||||
cp = run(["terraform", f"-chdir={TF_DIR}", *cmd], env=env)
|
|
||||||
if cp.returncode != 0:
|
|
||||||
die(f"terraform {' '.join(cmd)} failed: {cp.stderr.strip() or cp.stdout.strip()}")
|
|
||||||
|
|
||||||
cp = run(["terraform", f"-chdir={TF_DIR}", "apply", "-input=false", "-auto-approve",
|
def _ensure_project(token: str, name: str) -> str:
|
||||||
f"-var=org_id={args.org_id}"], env=env)
|
code, d = _api("POST", "/management/v1/projects/_search", token, {})
|
||||||
sys.stdout.write(cp.stdout)
|
for row in d.get("result") or []:
|
||||||
sys.stderr.write(cp.stderr)
|
if row.get("name") == name:
|
||||||
if cp.returncode != 0:
|
return row["id"]
|
||||||
die("terraform apply failed — no SOPS value was written. Objects created before the "
|
code, d = _api("POST", "/management/v1/projects", token, {"name": name})
|
||||||
"failure stay in ZITADEL; re-run apply once the cause is fixed")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -238,16 +389,11 @@ MANUAL_ONLY = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def terraform_outputs() -> dict:
|
def state_outputs() -> dict:
|
||||||
need("terraform")
|
"""Read the object map written by the apply phase."""
|
||||||
key = CONF_DIR / "iam-admin.json"
|
if not STATE_FILE.exists():
|
||||||
if not key.exists():
|
die(f"{STATE_FILE} missing — run the apply phase first")
|
||||||
die(f"{key} missing — run the keys phase first")
|
return json.loads(STATE_FILE.read_text())
|
||||||
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
|
||||||
cp = run(["terraform", f"-chdir={TF_DIR}", "output", "-json"], env=env)
|
|
||||||
if cp.returncode != 0:
|
|
||||||
die(f"terraform output failed: {cp.stderr.strip()}")
|
|
||||||
return json.loads(cp.stdout or "{}")
|
|
||||||
|
|
||||||
|
|
||||||
def sops_env() -> dict:
|
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:
|
def phase_secrets(args: argparse.Namespace) -> int:
|
||||||
need("sops")
|
need("sops")
|
||||||
out = terraform_outputs()
|
out = state_outputs()
|
||||||
ids = out.get("client_ids", {}).get("value", {})
|
ids = out.get("client_ids", {}).get("value", {})
|
||||||
secrets = out.get("client_secrets", {}).get("value", {})
|
secrets = out.get("client_secrets", {}).get("value", {})
|
||||||
machine_keys = out.get("machine_keys", {}).get("value", {})
|
machine_keys = out.get("machine_keys", {}).get("value", {})
|
||||||
|
|
||||||
if not ids and not machine_keys:
|
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")
|
print("[1/3] client secrets into SOPS")
|
||||||
for client, entries in SECRET_MAP.items():
|
for client, entries in SECRET_MAP.items():
|
||||||
@@ -438,7 +584,7 @@ def phase_verify(_args: argparse.Namespace) -> int:
|
|||||||
if code != 200:
|
if code != 200:
|
||||||
die("discovery endpoint is not 200 — the instance or the ingress is not ready")
|
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", {})
|
ids = out.get("client_ids", {}).get("value", {})
|
||||||
print(f"client apps : {len(ids)}")
|
print(f"client apps : {len(ids)}")
|
||||||
enabled = bool(out.get("groups_claim_enabled", {}).get("value"))
|
enabled = bool(out.get("groups_claim_enabled", {}).get("value"))
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
# terraform -chdir=terraform/edge init
|
# terraform -chdir=terraform/edge init
|
||||||
# terraform -chdir=terraform/edge plan -var china_domain=example.cn ...
|
# 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
|
# 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.
|
# treat it as a secret and move it to MinIO before a second operator applies.
|
||||||
terraform {
|
terraform {
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ cloudflare_zone_id = "REPLACE_WITH_ZONE_ID"
|
|||||||
tunnel_id = "REPLACE_WITH_TUNNEL_UUID"
|
tunnel_id = "REPLACE_WITH_TUNNEL_UUID"
|
||||||
|
|
||||||
# Adopt existing records with `terraform import` before the first apply, or the
|
# Adopt existing records with `terraform import` before the first apply, or the
|
||||||
# create call collides with the hand-made record of the same name.
|
# create call collides with the hand-made record of the same name:
|
||||||
cloudflare_tunnel_hostnames = ["paperless", "img", "poimen"]
|
# 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
|
# Leave false until the tunnel config import is understood. True makes
|
||||||
# Terraform replace the entire ingress list with tunnel_ingress.
|
# 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