Implement scoped P06 authentication policy and guarded optional onboarding
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Build and Publish Container Image / build-and-push (push) Successful in 51s
Account journey acceptance / journeys (push) Successful in 7s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
tegwick 2026-09-14 00:00:07 +02:00
parent 0801ec55ac
commit 3bd1827a7f
10 changed files with 298 additions and 8 deletions

View file

@ -110,6 +110,7 @@ class PortalApplication:
self.tenant_management = tenant_management
self.outbox_delivery = outbox_delivery
self.operations_probe = None
self.authentication_policy = None
self.registration_verification = registration_verification
self.registration_clients = frozenset(registration_clients)
self.registration_tenants = frozenset(registration_tenants)
@ -186,11 +187,13 @@ class PortalApplication:
return self._metrics(start_response, correlation_id)
if path in {"/login", "/oidc/start"}:
query = parse_qs(str(environ.get("QUERY_STRING", "")))
if query.get("return_path", ["/"])[0] not in {"/", "/platform", "/platform/factor-recovery", "/platform/authentication-policy"}:
raise ValidationError("unsupported login return path")
tenant_hint = query.get("tenant_hint", [None])[0]
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
raise ValidationError("tenant_hint must be a tenant identifier")
location = (
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True} if query.get("recovery") == ["1"] else {}))
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True, "return_path": query.get("return_path", ["/platform/factor-recovery"])[0]} if query.get("recovery") == ["1"] else {}))
if self.oidc_client else self.login_url
)
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
@ -210,7 +213,7 @@ class PortalApplication:
except (ValueError, URLError, OSError):
return self._redirect(start_response, "/access-recovery", correlation_id)
headers = [
("Location", "/platform/factor-recovery" if self.oidc_client.sessions[session_id].recovery else "/"),
("Location", (self.oidc_client.sessions[session_id].return_path if self.oidc_client.sessions[session_id].return_path != "/" else "/platform/factor-recovery") if self.oidc_client.sessions[session_id].recovery else "/"),
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
*self._security_headers(correlation_id),
]
@ -353,6 +356,12 @@ class PortalApplication:
actor = self._actor(environ)
self._set_account_navigation(environ, actor)
privileged = path == "/platform" or path.startswith(("/platform/", "/admin/", "/api/v1/platform/", "/api/v1/tenants/"))
assurance = actor.assurance
if privileged and str(actor.principal_type.value) == "human" and not (assurance.get("level") == "aal2" and assurance.get("mfa") is True):
if path.startswith("/api/"):
return self._json(start_response, "403 Forbidden", {"error": "mfa_required"}, correlation_id)
return self._html(start_response, self._page_html("Verify with MFA", '<h1>Verify with MFA to administer accounts</h1><p>Your account session is signed in, but administration requires an authenticator. Your role still determines which actions you may use.</p><p><a class="button" href="/login?recovery=1&amp;return_path=/platform">Verify with MFA</a> · <a href="/security">Set up or recover an authenticator</a> · <a href="/">Return to your account</a></p>'), correlation_id)
if path.startswith("/api/v1/tenants/"):
self._require_tenant_admin(actor, path.split("/")[4])
if path == "/api/v1/me" and method == "GET":
@ -756,6 +765,28 @@ class PortalApplication:
"status": "removed", "tenant_account": _jsonable(account),
"provider_identity_removed": False,
}, correlation_id)
if path == "/platform/authentication-policy" and method in {"GET", "POST"}:
from user_engine.factor_recovery import fresh
from user_engine.authentication_policy import page
if "platform-operator" not in actor.roles:
raise AuthorizationDenied("platform operator required")
submitted = self._form_body(environ) if method == "POST" else {}
if method == "POST": self._require_csrf(environ, submitted.get("csrf_token", ""))
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
session = self.oidc_client.sessions.get(session_id or "") if self.oidc_client else None
if not session or not session.id_token or not fresh(session.claims):
result = {"failure": "fresh_platform_mfa_required"}
elif self.authentication_policy is None:
result = {"failure": "policy_unavailable"}
else:
try:
result = self.authentication_policy.call(session.id_token, {
"action": submitted.get("action", "status"), "client": submitted.get("client", ""),
"mode": submitted.get("mode", ""), "reference": submitted.get("reference", ""),
"confirmation": submitted.get("confirmation", ""), "acknowledged": submitted.get("acknowledged") == "yes",
})
except Exception: result = {"failure": "policy_unavailable"}
return self._html(start_response, self._page_html("Authentication policy", page(self._csrf_token(environ), result)), correlation_id)
if path == "/platform/factor-recovery" and method in {"GET", "POST"}:
from user_engine.factor_recovery import fresh, page
actor = self._actor(environ)
@ -1290,7 +1321,7 @@ class PortalApplication:
rows = "".join(f'<tr><td>{escape(name)}</td><td>{"Configured; live health unverified" if configured else "Unavailable in this portal"}</td><td>{escape(help_text)}</td></tr>'
for name, configured, help_text in capabilities)
return ('<section><h2>Service capabilities</h2><table><thead><tr><th>Service</th><th>Known state</th><th>Recovery step</th></tr></thead><tbody>'
+ rows + '</tbody></table>' + ('<p><a href="/platform/factor-recovery">Recover a lost authenticator</a></p>' if self.factor_recovery else '<p>Authenticator recovery is unavailable in this portal.</p>') + '<p>Other authentication policy changes are unavailable in this portal. A configured adapter is not a health check.</p></section>')
+ rows + '</tbody></table>' + ('<p><a href="/platform/factor-recovery">Recover a lost authenticator</a></p>' if self.factor_recovery else '<p>Authenticator recovery is unavailable in this portal.</p>') + '<p><a href="/platform/authentication-policy">Review authentication policy</a>. A configured adapter is not a health check.</p></section>')
def _require_setup_access(self, tenant: str, user_id: str) -> None:
account = self.service.store.tenant_account(tenant, user_id)
@ -1850,10 +1881,10 @@ This portal cannot currently confirm whether an authenticator is enabled for you
""" + handoff + """
<details><summary>How to set up an authenticator when setup is available</summary>
<ol><li>Open authenticator management and check that it shows your account.</li>
<li>Choose to add an authenticator and scan its QR code with your authenticator app.</li>
<li>Choose Enroll Token, select TOTP, and scan its QR code with your authenticator app.</li>
<li>Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.</li>
<li>Follow the recovery instructions shown there, then test a new sign-in before closing your current session.</li></ol>
<p>Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.</p></details>
<p>Opening the setup page does not activate two-step verification. To cancel unfinished setup, open All Tokens, select the pending token, and choose Delete. A confirmed authenticator cannot be removed or replaced from this password-only management session; ask your administrator to use audited authenticator recovery.</p></details>
<details><summary>A code is rejected, or I have lost my authenticator</summary>
<p>Use the newest code for the correct account and check that your device sets its time automatically.
If you cannot use your authenticator, follow the sign-in service's recovery instructions or ask your tenant administrator for account recovery.
@ -2065,6 +2096,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
return self._page_html(
"Platform administration",
f"""<h1>Platform administration</h1>
<p><a href="/platform/authentication-policy">Authentication policy</a></p>
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
{message}
<p>Choose a tenant to manage its users:</p><ul>{known_tenants}</ul>