From 80af9b1fd0ac4978eb0904a784d4d703e571566c Mon Sep 17 00:00:00 2001 From: rock Date: Tue, 27 Oct 2026 00:11:50 -0700 Subject: [PATCH] feat(iam): provision and activate the SMTP provider ZITADEL sends verification codes and password resets by SMTP only, and nothing delivered until the instance had an *active* SMTP config. Two traps, both now handled here instead of by hand: - POST /admin/v1/smtp creates the config INACTIVE (state 3). An inactive config makes GET /admin/v1/smtp answer 404 "SMTP configuration not found", so the API reports success while every send is dropped. The _activate call is mandatory. - The target is the in-cluster smtp-gotify relay, which forwards to Gotify. The phase is idempotent: an active config short-circuits and only logs. Also fix the role-exists check: ZITADEL returns `key`, not `roleKey`, so the set was always empty and every re-run died on the first 409 "Role already exists". Verified: `zitadel-provision.py apply` now completes, reporting `smtp : already active`, `roles : 21 ensured`, `oidc apps : 11 ensured`. A resend then produced Gotify message 1185 ("Verify email, Code ZVYLQG") and submitting that code set verified_email for user riotpiao. --- scripts/iam/zitadel-provision.py | 52 +++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index eca1409..4b9c1ef 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -49,6 +49,10 @@ CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zi 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") +# Notification delivery target: the in-cluster SMTP -> Gotify relay. +SMTP_HOST = os.environ.get("ZITADEL_SMTP_HOST", "smtp-gotify.iam.svc.cluster.local:2525") +SMTP_FROM = os.environ.get("ZITADEL_SMTP_FROM", "zitadel@riotpiao.com") +SMTP_FROM_NAME = os.environ.get("ZITADEL_SMTP_FROM_NAME", "ZITADEL") # ── desired ZITADEL state ────────────────────────────────────────────────── # Declared here, applied through the API by the `apply` phase. This replaces the @@ -319,7 +323,10 @@ def _ensure_builtin_login(token: str) -> None: 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 [])} + # ZITADEL answers with `key`, not `roleKey`. Reading the wrong field left + # this set empty, so every re-run tried to recreate existing roles and died + # on the first 409. + have = {(r.get("key") or r.get("roleKey")) for r in (d.get("result") or [])} for key, display in roles.items(): if key in have: continue @@ -368,6 +375,48 @@ def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict]) -> tup return ids, secrets +def _ensure_smtp(token: str) -> str: + """Make sure the instance has one active SMTP provider. + + ZITADEL sends verification codes and password resets by SMTP only. A + freshly added config is created INACTIVE (state 3), and an inactive config + makes GetSMTPConfig answer 404 "SMTP configuration not found", so nothing is + ever delivered. Activating it is what enables sending: after the activate + call the same GET returns the config and a resend reaches the relay. + + This is instance state in the database, not a values key, so it must be + applied explicitly. The host points at the in-cluster smtp-gotify relay + (k8s/infra/iam/smtp-gotify.yaml), which forwards to Gotify. + """ + code, d = _api("GET", "/admin/v1/smtp", token) + if code == 200 and (d.get("smtpConfig") or {}).get("id"): + smtp_id = d["smtpConfig"]["id"] + print(f"smtp : already active ({smtp_id})") + return smtp_id + + code, d = _api("POST", "/admin/v1/smtp", token, { + "senderAddress": SMTP_FROM, + "senderName": SMTP_FROM_NAME, + "host": SMTP_HOST, + "tls": False, + }) + if code not in (200, 201) or not d.get("id"): + die(f"create smtp config failed: {code} {d}") + smtp_id = d["id"] + + # The activate call is mandatory: without it the config stays inactive and + # every send is dropped while the API still reports success. + code, d = _api("POST", f"/admin/v1/smtp/{smtp_id}/_activate", token, {}) + if code not in (200, 201): + die(f"activate smtp config {smtp_id} failed: {code} {d}") + + code, d = _api("GET", "/admin/v1/smtp", token) + if code != 200 or not (d.get("smtpConfig") or {}).get("id"): + die(f"smtp config {smtp_id} still not visible after activate: {code} {d}") + print(f"smtp : active ({smtp_id}) -> {SMTP_HOST}") + return smtp_id + + def phase_apply(_args: argparse.Namespace) -> int: """Declare the ZITADEL objects through the API. No Terraform, no state drift.""" token = bootstrap_token() @@ -375,6 +424,7 @@ def phase_apply(_args: argparse.Namespace) -> int: die("no iam-admin PAT — run the keys phase first") _ensure_builtin_login(token) + _ensure_smtp(token) project_id = _ensure_project(token, PROJECT_NAME) print(f"project : {PROJECT_NAME} ({project_id})") _ensure_roles(token, project_id, ROLES)