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 = '

Authentication policy

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 += '

Policy change recorded

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 += '

Review policy change

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 += '
'+common+hidden('action','apply')+hidden('confirmation',result.get('confirmation',''))+'

Cancel without changes

' + for client in result.get('clients') or []: + if not isinstance(client, dict): continue + html += '

'+escape(str(client.get('name') or client.get('id')))+ '

Current policy: '+escape(LABELS.get(client.get('mode'),'Unknown'))+'. Revision '+escape(str(result.get('revision','unknown')))+'.

' + html += '
'+common+hidden('client',client.get('id',''))+'
' + history = result.get('history') or [] + if history: + html += '

Recent policy changes

' + return 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", '

Verify with MFA to administer accounts

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'{escape(name)}{"Configured; live health unverified" if configured else "Unavailable in this portal"}{escape(help_text)}' for name, configured, help_text in capabilities) return ('

Service capabilities

' - + rows + '
ServiceKnown stateRecovery step
' + ('

Recover a lost authenticator

' if self.factor_recovery else '

Authenticator recovery is unavailable in this portal.

') + '

Other authentication policy changes are unavailable in this portal. A configured adapter is not a health check.

') + + rows + '' + ('

Recover a lost authenticator

' if self.factor_recovery else '

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 + """
How to set up an authenticator when setup is available
  1. Open authenticator management and check that it shows your account.
  2. -
  3. Choose to add an authenticator and scan its QR code with your authenticator app.
  4. +
  5. Choose Enroll Token, select TOTP, and scan its QR code with your authenticator app.
  6. Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.
  7. Follow the recovery instructions shown there, then test a new sign-in before closing your current session.
-

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.

A code is rejected, or I have lost my authenticator

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 +

Authentication policy

Manage an existing tenant

{message}

Choose a tenant to manage its users:

diff --git a/tests/test_authentication_policy.py b/tests/test_authentication_policy.py new file mode 100644 index 0000000..1f267a6 --- /dev/null +++ b/tests/test_authentication_policy.py @@ -0,0 +1,70 @@ +import time +from dataclasses import replace +from urllib.parse import parse_qs, urlsplit +from test_journey_roles import JourneyFixture +from test_web import invoke + +class PolicyFixture: + def __init__(self): self.calls=[]; self.unavailable=False + def call(self, token, body): + self.calls.append((token,body)) + if self.unavailable: raise RuntimeError('private-provider-error') + result=dict(success=True,status='current',revision=1,clients=[dict(id='vergabe-demo-company',name='Demo Company',mode='optional_after_enrollment')],history=[]) + if body['action'] in {'preview','rollback'}: + result.update(status='preview',client='vergabe-demo-company',before='optional_after_enrollment',after='mandatory',reference='p06-case',confirmation='fixture-confirmation') + if body['action']=='apply': + result.update(status='recorded',receipt=dict(client='vergabe-demo-company',after='mandatory',reference='p06-case')) + return result + +class AuthenticationPolicyJourney(JourneyFixture): + def setUp(self): + super().setUp(); self.provider=PolicyFixture(); self.app.authentication_policy=self.provider + session=self.oidc.sessions['operator'] + self.oidc.sessions['operator']=replace(session,id_token='server-only-id-token',claims=dict(session.claims,assurance=dict(level='aal2',mfa=True,at=time.time()))) + def test_aal1_account_access_does_not_grant_administration(self): + for who in ['member','admin','operator']: + self.oidc.sessions[who].claims['assurance']=dict(level='aal1',mfa=False) + for path in ['/','/onboarding','/security']: + response,_=invoke(self.app,path,cookie='ue_session='+who) + self.assertEqual('200 OK',response['status']) + for path in ['/platform','/admin/tenant:trial:demo-company','/platform/authentication-policy']: + _,body=invoke(self.app,path,cookie='ue_session='+who) + self.assertIn(b'Verify with MFA to administer',body) + response,_=self.post('/api/v1/platform/tenants',who=who) + self.assertEqual('403 Forbidden',response['status']) + self.assertEqual([],self.provider.calls) + self.assertEqual([],self.app.provisioning.actions) + def test_role_csrf_and_stale_mfa_deny_before_policy_service(self): + for who in ['member','admin']: + response,_=self.post('/platform/authentication-policy',who=who,action='preview') + self.assertEqual('403 Forbidden',response['status']) + response,_=self.post('/platform/authentication-policy',who='operator',csrf_token='wrong',action='preview') + self.assertEqual('403 Forbidden',response['status']) + self.oidc.sessions['operator'].claims['assurance']['at']=time.time()-301 + _,body=invoke(self.app,'/platform/authentication-policy',cookie='ue_session=operator') + self.assertIn(b'fresh MFA sign-in',body);self.assertEqual([],self.provider.calls) + def test_review_explains_lockout_scope_rollback_and_receipts(self): + _,body=self.post('/platform/authentication-policy',who='operator',action='preview',client='vergabe-demo-company',mode='mandatory',reference='p06-case') + self.assertIn(b'Review policy change',body);self.assertIn(b'unable to complete sign-in',body) + self.assertIn(b'does not revoke already issued tokens',body);self.assertIn(b'Cancel without changes',body) + self.assertNotIn(b'server-only-id-token',body) + self.assertEqual('preview',self.provider.calls[-1][1]['action']) + _,body=self.post('/platform/authentication-policy',who='operator',action='apply',confirmation='fixture-confirmation',acknowledged='yes') + self.assertIn(b'Policy change recorded',body);self.assertIn(b'p06-case',body) + self.assertTrue(self.provider.calls[-1][1]['acknowledged']) + _,body=self.post('/platform/authentication-policy',who='operator',action='rollback',client='vergabe-demo-company',reference='rollback') + self.assertIn(b'Review policy change',body) + def test_outage_is_redacted_and_current_status_is_retryable(self): + self.provider.unavailable=True + _,body=invoke(self.app,'/platform/authentication-policy',cookie='ue_session=operator') + self.assertIn(b'Existing policy remains in force',body);self.assertNotIn(b'private-provider-error',body) + self.provider.unavailable=False + _,body=invoke(self.app,'/platform/authentication-policy',cookie='ue_session=operator') + self.assertIn(b'Current policy',body) + def test_policy_stepup_binds_only_supported_return_path(self): + response,_=invoke(self.app,'/login',query='recovery=1&return_path=/platform/authentication-policy') + query=parse_qs(urlsplit(dict(response['headers'])['Location']).query) + self.assertEqual(['aal2'],query['acr_values']);self.assertEqual(['login'],query['prompt']) + self.assertEqual('/platform/authentication-policy',self.oidc.pending[query['state'][0]].return_path) + response,_=invoke(self.app,'/login',query='recovery=1&return_path=https://evil.example') + self.assertEqual('400 Bad Request',response['status']) diff --git a/tests/test_platform_support.py b/tests/test_platform_support.py index 46f5b23..d663a7c 100644 --- a/tests/test_platform_support.py +++ b/tests/test_platform_support.py @@ -76,4 +76,4 @@ class PlatformSupportJourneys(JourneyFixture): self.assertIn(b"Configured; live health unverified",body) self.assertIn(b"Unavailable in this portal",body) self.assertIn(b"assisted password setup",body) - self.assertIn(b"authentication policy changes are unavailable",body) + self.assertIn(b"Review authentication policy",body) diff --git a/workplans/USER-WP-0033-authentication-policy.md b/workplans/USER-WP-0033-authentication-policy.md new file mode 100644 index 0000000..1b96555 --- /dev/null +++ b/workplans/USER-WP-0033-authentication-policy.md @@ -0,0 +1,64 @@ +--- +id: USER-WP-0033 +type: workplan +title: "P06 scoped authentication policy and safe optional onboarding" +domain: communication +repo: user-engine +status: active +owner: codex +topic_slug: communication +created: "2026-09-13" +updated: "2026-09-13" +--- + +Implements P06 under USER-WP-0030-T03 and KEY-WP-0035. Authorized by the +user's P06 request and prior optional-OTP requirement. Existing MFA enforcement +for unrelated clients remains in its current configuration. + +## Protect privileged actions independently of ordinary account login + +```task +id: USER-WP-0033-T01 +status: done +priority: high +``` + +AAL1 users can reach account/onboarding and authenticator setup. Administrative +browser/API operations require MFA. Policy changes and factor recovery require +recent MFA. Explain step-up, unavailable setup and identity-switch recovery. + +## Provide scoped policy preview, confirmed apply, audit and rollback + +```task +id: USER-WP-0033-T02 +status: done +priority: high +``` + +Issuer-owned persistent policy state for the two reviewed browser clients only. +Support mandatory and optional-after-enrollment; explicit application AAL2 +always wins. Reject ambiguous or unsupported weakening, stale confirmation, +wrong role/audience and replay with altered intent. Durable receipts and guarded +rollback survive issuer replacement. Portal carries verified identity, no admin +credential. Changes affect subsequent authorization, not already issued tokens. + +## Verify onboarding and publish the scoped release + +```task +id: USER-WP-0033-T03 +status: progress +priority: high +``` + +Test no-factor/password-only, pending/cancel/confirmed enrollment, enrolled OTP, +old AAL1 sessions, mandatory/explicit AAL2, provider outage/recovery, privileged +portal denial and policy preview/apply/replay/rollback. Use actual installed +provider in isolated fixtures, browser tests and native non-mutating readback. +Enable only vergabe-demo-company and user-engine-portal optional policies after +privileged guards pass. Record canonical deployment and rollback evidence. + +Resumed after interruption: portal browser 30/30, policy tests 5/5, issuer policy +regressions and rollout tests pass. Installed-provider acceptance Job +`provider-p06-browser-46713f7e` passed native optional/old-session OTP plus five +browser checks. Fixed test endpoint and asynchronous TOTP/detail readiness. +Release and live readback remain in progress. See P06 evidence.