diff --git a/docs/evidence/2026-09-13-p06-authentication-policy.md b/docs/evidence/2026-09-13-p06-authentication-policy.md new file mode 100644 index 0000000..61b3779 --- /dev/null +++ b/docs/evidence/2026-09-13-p06-authentication-policy.md @@ -0,0 +1,35 @@ +# P06 authentication policy acceptance + +Scoped to `vergabe-demo-company` and `user-engine-portal`. The issuer owns +persistent policy, revision-bound previews, acknowledged apply, durable receipts +and reviewed rollback. Explicit application AAL2 remains authoritative. Portal +administration requires MFA; policy and factor recovery require recent MFA. + +Optional means password-only until a confirmed active authenticator exists. +Provider lookup failures deny login. Pending setup can be cancelled; active +factor replacement requires audited recovery, including from old AAL1 sessions. + +## Verification + +- Previous interrupted run: full Go suite and 232 database-backed portal tests passed. +- Resumed run: all 30 portal Chromium checks and five portal policy tests passed. +- Issuer policy/runtime regression tests passed; two policy rollout and one + provider guard deployment tests passed. +- Installed privacyIDEA fixture `provider-p06-browser-46713f7e`: native adapter + confirms no-factor/pending login, activation, old-session MFA, explicit AAL2 + and successful OTP sign-in. Five browser checks pass: correct identity, + pending possession proof, cancellation, confirmed activation and denied + replacement with a visible recovery route. +- Provider image: `ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473`. + +Fixture repairs submit OTP to `/authorize/callback`, wait for asynchronous TOTP +selection, and wait for loaded pending state and an enabled Delete button. +The fixture uses disposable databases and synthetic identities, with no +production account changes. Reproduce using key-cape +`scripts/provider_browser_acceptance.py` and user-engine +`scripts/browser_journeys.py`. + +## Release + +Pending CI image publication and ordered live rollout. Do not infer deployment +or complete platform journey acceptance from these test results. diff --git a/scripts/browser_journeys.mjs b/scripts/browser_journeys.mjs index eb1d7f4..ddcb7a6 100644 --- a/scripts/browser_journeys.mjs +++ b/scripts/browser_journeys.mjs @@ -73,6 +73,27 @@ try{ await evaluate(`document.querySelector('input[name="identity_verified"]').checked=true;Array.from(document.forms).find(f=>f.querySelector('input[name="confirmation"]')).querySelector('button').click()`); await waitFor('document.body.innerText.includes("Authenticator recovery recorded")'); await check(`document.body.innerText.includes("case-1") && document.body.innerText.includes("Enroll a replacement") && !document.body.innerText.includes("server-only-token")`,'P04 receipt and replacement onboarding without credentials'); + await navigate('/platform/authentication-policy'); + await check(`document.body.innerText.includes("Application step-up always wins") && document.body.innerText.includes("Current policy")`,'P06 effective policy and step-up boundary'); + await evaluate(`document.querySelector('input[name="reference"]').value='p06-case';document.querySelector('select[name="mode"]').value='mandatory';document.querySelector('button[value="preview"]').click()`); + await waitFor('document.body.innerText.includes("Review policy change")'); + await check(`document.body.innerText.includes("unable to complete sign-in") && !!document.querySelector('input[name="acknowledged"][required]')`,'P06 lockout impact requires acknowledgement'); + await evaluate(`Array.from(document.links).find(a=>a.textContent==='Cancel without changes').click()`); + await waitFor('document.body.innerText.includes("Current policy") && !document.querySelector("input[name=confirmation]")'); + await check(`document.body.innerText.includes("Optional until an authenticator is activated")`,'P06 cancel preserves current policy'); + await evaluate(`document.querySelector('input[name="reference"]').value='p06-case';document.querySelector('select[name="mode"]').value='mandatory';document.querySelector('button[value="preview"]').click()`); + await waitFor('!!document.querySelector("input[name=acknowledged]")'); + await evaluate(`document.querySelector('input[name="acknowledged"]').checked=true;document.querySelector('input[name="confirmation"]').form.querySelector('button').click()`); + await waitFor('document.body.innerText.includes("Policy change recorded")'); + await check(`document.body.innerText.includes("p06-case") && !document.body.innerText.includes("server-only-token")`,'P06 recorded change has safe receipt'); + await evaluate(`document.querySelector('input[name="reference"]').value='rollback';document.querySelector('button[value="rollback"]').click()`); + await waitFor('document.body.innerText.includes("Review policy change")'); + await check(`!!document.querySelector('input[name="acknowledged"][required]')`,'P06 rollback requires a new review'); + await identity('operator-aal1');await navigate('/platform'); + await check(`document.body.innerText.includes("Verify with MFA to administer") && !!document.querySelector('a[href="/security"]')`,'P06 AAL1 operator cannot administer'); + await navigate('/onboarding'); + await check(`document.body.innerText.includes("My account") && !document.body.innerText.includes("Verify with MFA to administer")`,'P06 AAL1 account setup remains available'); + await identity('operator'); await navigate('/logout'); await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation'); await evaluate(`document.querySelector('form[action="/logout"] button').click()`); diff --git a/scripts/browser_journeys.py b/scripts/browser_journeys.py index 80ddb0d..6fe1139 100644 --- a/scripts/browser_journeys.py +++ b/scripts/browser_journeys.py @@ -32,6 +32,11 @@ class Delivery: def mail_delivery_status(self, event_id): return {"state":"failed" if self.attempts == 1 else "provider_accepted" if self.attempts else "not_found"} fixture.app.outbox_delivery = Delivery() +from test_authentication_policy import PolicyFixture +from dataclasses import replace +fixture.app.authentication_policy = PolicyFixture() +session = fixture.oidc.sessions['operator'] +fixture.oidc.sessions['operator-aal1'] = replace(session, claims=dict(session.claims, assurance=dict(level='aal1', mfa=False)), csrf_token='operator-aal1-csrf') class Quiet(WSGIRequestHandler): def log_message(self,*args):pass server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet) diff --git a/src/user_engine/authentication_policy.py b/src/user_engine/authentication_policy.py new file mode 100644 index 0000000..078654b --- /dev/null +++ b/src/user_engine/authentication_policy.py @@ -0,0 +1,56 @@ +"""Platform policy review and transport; issuer remains policy authority.""" +from html import escape +from user_engine.factor_recovery import FactorRecoveryClient + +class PolicyClient(FactorRecoveryClient): + def __init__(self, url): + super().__init__(url) + self.url = url.rstrip('/') + '/platform/authentication-policy' + +LABELS = {'mandatory': 'MFA required', 'optional_after_enrollment': 'Optional until an authenticator is activated'} + +def page(csrf, result=None): + result = result or {} + def hidden(name, value): + return '' + common = hidden('csrf_token', csrf) + html = '
Choose the sign-in requirement for one reviewed application. Other applications keep their existing policy.
' + html += 'Application step-up always wins. An explicit MFA request still requires an authenticator. Portal administration requires MFA; policy changes and lost-factor recovery require recent MFA.
' + failure = result.get('failure') + if failure: + messages = { + 'fresh_platform_mfa_required': 'Verify your identity with a fresh MFA sign-in before viewing or changing policy.', + 'preview_expired_or_changed': 'The review expired, changed or belongs to another session. Check the current policy and review again.', + 'policy_changed_review_again': 'Policy changed after this review. Check the current policy and review again.', + 'reference_already_used': 'This reference is already recorded. Check the history; use a new reference for a new change.', + 'policy_unchanged': 'The selected policy is already active. No change was made.', + 'unsupported_policy': 'This policy or application is unsupported. Choose one of the available policies.', + } + html += ''+escape(messages.get(failure, 'The policy service is unavailable. Existing policy remains in force. Check the current state before retrying a change.'))+'
' + if failure == 'fresh_platform_mfa_required': + html += 'Verify with MFA · Set up or recover an authenticator
' + if result.get('status') == 'recorded': + receipt = result.get('receipt', {}) + html += 'Reference: '+escape(str(receipt.get('reference','')))+'. Application: '+escape(str(receipt.get('client','')))+'. Recorded policy: '+escape(LABELS.get(receipt.get('after'), 'Unknown'))+'. Check the current policy below; later changes may supersede this receipt.
' + if result.get('status') == 'preview': + html += 'Application: '+escape(str(result.get('client','')))+ '. Change from '+escape(LABELS.get(result.get('before'),'Unknown'))+' to '+escape(LABELS.get(result.get('after'),'Unknown'))+'.
' + if result.get('after') == 'mandatory': + html += 'People without a confirmed working authenticator will be unable to complete sign-in. Verify enrollment and a recovery route before applying. This preview does not count unenrolled users.
' + else: + html += 'People with no confirmed authenticator can sign in with their password. Once an authenticator is activated, MFA is required. Pending or cancelled setup does not activate MFA; provider lookup failures deny sign-in.
' + html += 'This affects subsequent authorization requests. It does not revoke already issued tokens or change another application. Rollback restores the previous policy through another reviewed change.
' + html += 'Current policy: '+escape(LABELS.get(client.get('mode'),'Unknown'))+'. Revision '+escape(str(result.get('revision','unknown')))+'.
' + html += 'Check current policy · Return to platform administration
' diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py index 43e03e6..c8e8b99 100644 --- a/src/user_engine/oidc.py +++ b/src/user_engine/oidc.py @@ -18,6 +18,7 @@ class PendingLogin: verifier: str created_at: float recovery: bool = False + return_path: str = "/" @dataclass @@ -27,6 +28,7 @@ class BrowserSession: csrf_token: str = "" id_token: str = "" recovery: bool = False + return_path: str = "/" class OIDCClient: @@ -51,11 +53,13 @@ class OIDCClient: self.pending: dict[str, PendingLogin] = {} self.sessions: dict[str, BrowserSession] = {} - def begin(self, *, tenant_hint: str | None = None, recovery: bool = False) -> str: + def begin(self, *, tenant_hint: str | None = None, recovery: bool = False, return_path: str = "/") -> str: + if return_path not in {"/", "/platform", "/platform/factor-recovery", "/platform/authentication-policy"}: + raise ValueError("unsupported login return path") state = secrets.token_urlsafe(32) verifier = secrets.token_urlsafe(64) challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest()) - self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time(), recovery=recovery) + self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time(), recovery=recovery, return_path=return_path) self._prune() parameters = { 'response_type': 'code', @@ -107,6 +111,7 @@ class OIDCClient: csrf_token=secrets.token_urlsafe(32), id_token=token, recovery=pending.recovery, + return_path=pending.return_path, ) self._prune() return session_id diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index e13f941..2c15d93 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -122,6 +122,8 @@ def create_application() -> PortalApplication: from user_engine.operations_status import check_services app.operations_probe = lambda: check_services(app.oidc_client, app.outbox_delivery) + from user_engine.authentication_policy import PolicyClient + app.authentication_policy = PolicyClient(app.oidc_client.backend_url) return app def main() -> None: diff --git a/src/user_engine/web.py b/src/user_engine/web.py index fc9557c..f5485a7 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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", 'Your account session is signed in, but administration requires an authenticator. Your role still determines which actions you may use.
Verify with MFA · Set up or recover an authenticator · Return to your account
'), 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'| Service | Known state | Recovery step |
|---|
Authenticator recovery is unavailable in this portal.
') + 'Other authentication policy changes are unavailable in this portal. A configured adapter is not a health check.
Authenticator recovery is unavailable in this portal.
') + 'Review authentication policy. A configured adapter is not a health check.
') 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 + """Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.
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.
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.
Platform administration +Choose a tenant to manage its users: