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.
This commit is contained in:
2026-10-27 00:11:50 -07:00
parent 7f29d640fd
commit 80af9b1fd0
+51 -1
View File
@@ -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", "[email protected]")
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)