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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user