Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
0801ec55ac
commit
3bd1827a7f
10 changed files with 298 additions and 8 deletions
35
docs/evidence/2026-09-13-p06-authentication-policy.md
Normal file
35
docs/evidence/2026-09-13-p06-authentication-policy.md
Normal 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.
|
||||
|
|
@ -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()`);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
56
src/user_engine/authentication_policy.py
Normal file
56
src/user_engine/authentication_policy.py
Normal 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&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>'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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&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>
|
||||
|
|
|
|||
70
tests/test_authentication_policy.py
Normal file
70
tests/test_authentication_policy.py
Normal 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'])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
64
workplans/USER-WP-0033-authentication-policy.md
Normal file
64
workplans/USER-WP-0033-authentication-policy.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue