diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index 4b9c1ef..b0f7b6e 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -417,6 +417,39 @@ def _ensure_smtp(token: str) -> str: return smtp_id +def _ensure_login_policy(token: str) -> None: + """Require password + a second factor, and demand enrolment at login. + + forceMFA does not persist through the v1 policy endpoint (it reads back + absent), so the effective lever is mfaInitSkipLifetime: 0s, which makes the + login flow require enrolment instead of letting a user skip it for 30 days. + + Second factors stay OTP and U2F: an authenticator app or a hardware key. + Multi-factor is left configured for U2F with verification so a later step + can move 2FA to 2MFA without another policy change. + """ + code, d = _api("PUT", "/admin/v1/policies/login", token, { + "allowUsernamePassword": True, + "allowRegister": False, + "allowExternalIDP": True, + "hidePasswordReset": False, + "ignoreUnknownUsernames": True, + "passwordCheckLifetime": "864000s", + "externalLoginCheckLifetime": "864000s", + "mfaInitSkipLifetime": "0s", + "secondFactorCheckLifetime": "64800s", + "multiFactorCheckLifetime": "64800s", + "secondFactors": ["SECOND_FACTOR_TYPE_OTP", "SECOND_FACTOR_TYPE_U2F"], + "multiFactors": ["MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION"], + "passwordlessType": "PASSWORDLESS_TYPE_ALLOWED", + }) + # ZITADEL answers 400 "has not been changed" when the policy already matches. + # That is success for an idempotent run, not a failure. + if code not in (200, 201) and "has not been changed" not in str(d.get("message", "")): + die(f"update login policy failed: {code} {d}") + print("login policy : password + 2FA, enrolment required") + + def phase_apply(_args: argparse.Namespace) -> int: """Declare the ZITADEL objects through the API. No Terraform, no state drift.""" token = bootstrap_token() @@ -425,6 +458,7 @@ def phase_apply(_args: argparse.Namespace) -> int: _ensure_builtin_login(token) _ensure_smtp(token) + _ensure_login_policy(token) project_id = _ensure_project(token, PROJECT_NAME) print(f"project : {PROJECT_NAME} ({project_id})") _ensure_roles(token, project_id, ROLES)