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

@ -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.

View file

@ -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()`);

View file

@ -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)

View file

@ -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 '<input type="hidden" name="'+name+'" value="'+escape(str(value), quote=True)+'">'
common = hidden('csrf_token', csrf)
html = '<h1>Authentication policy</h1><p>Choose the sign-in requirement for one reviewed application. Other applications keep their existing policy.</p>'
html += '<p><strong>Application step-up always wins.</strong> An explicit MFA request still requires an authenticator. Portal administration requires MFA; policy changes and lost-factor recovery require recent MFA.</p>'
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 += '<p role="alert">'+escape(messages.get(failure, 'The policy service is unavailable. Existing policy remains in force. Check the current state before retrying a change.'))+'</p>'
if failure == 'fresh_platform_mfa_required':
html += '<p><a class="button" href="/login?recovery=1&amp;return_path=/platform/authentication-policy">Verify with MFA</a> · <a href="/security">Set up or recover an authenticator</a></p>'
if result.get('status') == 'recorded':
receipt = result.get('receipt', {})
html += '<h2>Policy change recorded</h2><p>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.</p>'
if result.get('status') == 'preview':
html += '<section><h2>Review policy change</h2><p>Application: <strong>'+escape(str(result.get('client','')))+ '</strong>. Change from '+escape(LABELS.get(result.get('before'),'Unknown'))+' to <strong>'+escape(LABELS.get(result.get('after'),'Unknown'))+'</strong>.</p>'
if result.get('after') == 'mandatory':
html += '<p>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.</p>'
else:
html += '<p>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.</p>'
html += '<p>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.</p>'
html += '<form method="post" action="/platform/authentication-policy">'+common+hidden('action','apply')+hidden('confirmation',result.get('confirmation',''))+'<label><input type="checkbox" name="acknowledged" value="yes" required> I reviewed who may lose access and verified the enrollment and recovery route.</label><button type="submit">Apply this policy</button></form><p><a href="/platform/authentication-policy">Cancel without changes</a></p></section>'
for client in result.get('clients') or []:
if not isinstance(client, dict): continue
html += '<section><h2>'+escape(str(client.get('name') or client.get('id')))+ '</h2><p>Current policy: <strong>'+escape(LABELS.get(client.get('mode'),'Unknown'))+'</strong>. Revision '+escape(str(result.get('revision','unknown')))+'.</p>'
html += '<form method="post" action="/platform/authentication-policy">'+common+hidden('client',client.get('id',''))+'<label>Policy <select name="mode">'
for value,label in LABELS.items():
html += '<option value="'+value+'"'+(' selected' if value==client.get('mode') else '')+'>'+label+'</option>'
html += '</select></label><label>Change reference <input name="reference" maxlength="150" required></label><button name="action" value="preview">Preview policy</button><button name="action" value="rollback">Preview rollback</button></form></section>'
history = result.get('history') or []
if history:
html += '<h2>Recent policy changes</h2><ul>'
for receipt in reversed(history):
html += '<li>'+escape(str(receipt.get('reference','')))+''+escape(str(receipt.get('client','')))+': '+escape(LABELS.get(receipt.get('before'),'Unknown'))+''+escape(LABELS.get(receipt.get('after'),'Unknown'))+'; actor '+escape(str(receipt.get('actor','')))+', revision '+escape(str(receipt.get('revision','')))+'.</li>'
html += '</ul>'
return html+'<p><a href="/platform/authentication-policy">Check current policy</a> · <a href="/platform">Return to platform administration</a></p>'

View file

@ -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

View file

@ -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:

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>

View file

@ -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'])

View file

@ -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)

View file

@ -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.