Implement role-based account journeys with database and browser acceptance suites
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s
Account journey acceptance / journeys (push) Failing after 0s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 12:20:02 +02:00
parent 75750c0036
commit 1127f852dd
24 changed files with 1554 additions and 148 deletions

View file

@ -0,0 +1,20 @@
name: Account journey acceptance
on:
push:
branches: [main]
paths: ["src/**", "tests/**", "scripts/run_journey_suites.py", "Makefile", ".forgejo/workflows/journeys.yaml"]
workflow_dispatch:
jobs:
journeys:
runs-on: self-hosted
steps:
- name: Test the exact commit
run: |
set -eu
mkdir -p journey-source
curl -fsSL "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" -o journey-source.tar.gz
tar xzf journey-source.tar.gz -C journey-source --strip-components=1
cd journey-source
PYTHONDONTWRITEBYTECODE=1 make test
PYTHONDONTWRITEBYTECODE=1 make test-journeys JOURNEY_ARGS="--report journey-report.json"
cat journey-report.json

View file

@ -15,3 +15,14 @@ test-conformance: test-unit check-layer
check-layer: check-layer:
PYTHONPATH=src $(PYTHON) scripts/check_layer_conformance.py --report PYTHONPATH=src $(PYTHON) scripts/check_layer_conformance.py --report
.PHONY: test-journeys
# Optional JOURNEY_ARGS supports --role, --report and --require-complete.
test-journeys:
PYTHONPATH=src:tests $(PYTHON) scripts/run_journey_suites.py $(JOURNEY_ARGS)
.PHONY: test-browser-journeys
test-browser-journeys:
PYTHONDONTWRITEBYTECODE=1 $(PYTHON) scripts/browser_journeys.py

View file

@ -94,6 +94,20 @@ must be tested against controlled accounts; self-service credentials are not an
administrative lookup credential. The portal help page is reachable before login administrative lookup credential. The portal help page is reachable before login
so OTP-required login does not hide recovery. so OTP-required login does not hide recovery.
## Executable implementation status
Implementation workplans: USER-WP-0028 (users), USER-WP-0029 (tenant admins),
USER-WP-0030 (platform admins), USER-WP-0031 (automated suites), and NK-WP-0038
(scoped provider lifecycle). The original review column above records the starting
gaps. For the current per-journey implementation state and executable tests, use
`tests/journey-coverage.json` and `make test-journeys`; the report never equates
provider-boundary tests with real OTP or mail acceptance.
Now implemented: actual login-name handoff, profile validation/retry, scoped
provider lifecycle, role succession, cross-connection last-admin protection,
confirmation/stale-state checks, first-admin bootstrap rollback/retry, invitation
delivery readout, onboarding follow-up, tenant audit, and platform delivery retry.
## Acceptance and remaining work ## Acceptance and remaining work
USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks

View file

@ -0,0 +1,50 @@
# Automated journey acceptance
Run `make test` for all regression and layer checks, then `make test-journeys` for
the role-based acceptance selection. Tests exercise WSGI routes, authorization,
CSRF, persisted records, provider failure/retry and confirmation state. They do
not send email or modify real directory users.
Examples:
```sh
make test-journeys JOURNEY_ARGS="--role user"
make test-journeys JOURNEY_ARGS="--role tenant_admin"
make test-journeys JOURNEY_ARGS="--role platform_admin"
make test-journeys JOURNEY_ARGS="--report /tmp/user-engine-journeys.json"
make test-journeys JOURNEY_ARGS="--require-complete"
```
The last command is deliberately a release-completeness gate: it fails while any
journey still has an implementation or external acceptance gap. A local suite
passing is distinct from all 29 journeys being complete. `tests/journey-coverage.json`
contains every journey ID, executable test selectors, implementation status and
named remaining work. In particular, OTP help tests do not claim provider OTP
activation coverage. KeyCape's Go suite and NetKingdom's provider suite are
separate owner checks.
CI `.forgejo/workflows/journeys.yaml` runs full regression and journey selection
against the exact pushed commit, and prints the JSON report. All fake identities,
provider failure controls and test sessions live in tests, not production routes.
Tenant lifecycle and role changes hold an in-memory lock or tenant-scoped
PostgreSQL advisory lock across provider calls and local mutation. Tests cover
concurrent administrator suspension with independent database connections, lock
release on exception and preservation of caller transactions. Nested first-admin
bootstrap transactions roll back all local records after a failure. Directory
changes and local state still are not one database transaction: desired-state
retry/readback is required after a crash between external success and local save.
`make test-browser-journeys` starts a loopback-only synthetic portal and isolated
Chromium profile, then drives user/admin/operator navigation and confirmation
with Node's native CDP client. Set `JOURNEY_CHROME` if Chromium cannot be found.
Missing browser dependencies fail explicitly; no test login endpoints are added
to the production application. The ordinary CI job runs the WSGI suites; the
browser command can also run on a runner provisioned with Node and Chromium.
Database suite: `tests/test_journey_postgres.py` uses the existing
`USER_ENGINE_POSTGRES_TEST_DSN` plus `USER_ENGINE_POSTGRES_TEST_RESET=1` contract.
Use only a disposable database: conformance tests reset user-engine tables.
The 2026-09-13 run used a fresh local Docker PostgreSQL container and completed
all 210 regression tests with zero skips. The normal dependency-free run skips
seven opt-in PostgreSQL tests. See the rollout evidence for exact commands.

View file

@ -0,0 +1,51 @@
// No production accounts, network providers or header overrides are used.
const [debug, base] = process.argv.slice(2);
const version=await (await fetch(debug+'/json/version')).json();
const ws=new WebSocket(version.webSocketDebuggerUrl);
await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true}));
let next=0;const pending=new Map();
ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}});
const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));});
const {targetId}=await call('Target.createTarget',{url:'about:blank'});
const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true});
const cmd=(method,params)=>call(method,params,sessionId);
await cmd('Page.enable');await cmd('Network.enable');
const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error('Browser evaluation failed');return r.result.value;};
async function waitFor(expression){for(let n=0;n<70;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);}
async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.href === '+JSON.stringify(base+path)+' && document.readyState === "complete"');}
async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});}
let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;}
try{
await navigate('/');
await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U01 anonymous session controls');
await identity('member');await navigate('/onboarding');
await check(`!!document.querySelector('a[href="/logout"]') && !document.querySelector('a[href="/login"]')`,'U01 authenticated session controls');
await navigate('/platform');
await check(`document.body.innerText.includes("Access is not available") && !!document.querySelector('a[href="/access-recovery"]')`,'T01 member denial has recovery');
await navigate('/security');
await check(`document.body.innerText.includes("Authenticator setup is temporarily unavailable")`,'U06 unavailable OTP is explicit');
await identity('admin');await navigate('/admin/tenant:trial:demo-company');
await check(`document.body.innerText.includes("Login name: actual.login")`,'T03 actual login name shown');
await check(`document.body.innerText.includes("Onboarding follow-up")`,'T07 onboarding state visible');
await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/status")).querySelector("button").click()`);
await waitFor('document.body.innerText.includes("Confirm change")');
await check(`document.body.innerText.includes("Other tenant access and the shared login are retained")`,'T06 scope confirmation');
await evaluate(`Array.from(document.links).find(a=>a.textContent==="Cancel without changes").click()`);
await waitFor('location.pathname === "/"');
await navigate('/admin/tenant:trial:demo-company');
await check(`document.body.innerText.includes("active for this tenant")`,'T06 cancel preserves active account');
await cmd('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});
await navigate('/security');
await check(`document.documentElement.scrollWidth <= innerWidth`,'U12 security recovery fits mobile');
await cmd('Emulation.clearDeviceMetricsOverride');
await identity('operator');await navigate('/platform');
await check(`!!document.querySelector('a[href="/platform/operations"]')`,'P01 platform recovery navigation');
await navigate('/platform/operations');
await check(`document.body.innerText.includes("Live sign-in, email receipt and authenticator health are not verified here")`,'P05 unknown provider health remains explicit');
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()`);
await waitFor('location.pathname === "/logged-out"');
await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U11 logout updates controls');
console.log(JSON.stringify({checks,result:'passed',scope:'isolated browser and synthetic providers; live OTP/email not inferred'}));
}finally{await call('Target.closeTarget',{targetId});ws.close();}

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Run Chromium against an isolated loopback-only portal with synthetic identities."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
from threading import Thread
import time
from wsgiref.simple_server import make_server, WSGIRequestHandler
ROOT=Path(__file__).resolve().parents[1]
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
from test_journey_roles import JourneyFixture
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
if not chrome:
matches=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome'))
chrome=str(matches[-1]) if matches else None
if not chrome or not shutil.which('node'):
raise SystemExit('Chromium and Node are required; set JOURNEY_CHROME to the Chromium executable. Browser tests were not run.')
fixture=JourneyFixture();fixture.setUp();fixture.member(email='actual.login@example.test')
class Quiet(WSGIRequestHandler):
def log_message(self,*args):pass
server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet)
worker=Thread(target=server.serve_forever,daemon=True);worker.start()
try:
with tempfile.TemporaryDirectory(prefix='user-engine-browser-') as profile:
process=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1',
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
port_file=Path(profile)/'DevToolsActivePort'
for _ in range(100):
if port_file.exists():break
if process.poll() is not None:raise RuntimeError('Chromium exited before test connection')
time.sleep(.1)
if not port_file.exists():raise RuntimeError('Chromium did not expose its test connection')
port=port_file.read_text().splitlines()[0]
subprocess.run(['node',str(ROOT/'scripts/browser_journeys.mjs'),'http://127.0.0.1:'+port,
'http://127.0.0.1:'+str(server.server_port)],check=True,timeout=55)
finally:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait()
finally:
server.shutdown();server.server_close();worker.join(timeout=5)

View file

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Run mapped acceptance tests; report external gaps separately from test results."""
import argparse
import json
from pathlib import Path
import sys
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
parser=argparse.ArgumentParser()
parser.add_argument('--role',choices=['user','tenant_admin','platform_admin'])
parser.add_argument('--report',type=Path)
parser.add_argument('--require-complete',action='store_true',help='Fail while any journey has an implementation or external acceptance gap')
args=parser.parse_args()
rows=json.loads((ROOT/'tests/journey-coverage.json').read_text())['journeys']
expected={f'U{i:02}' for i in range(1,14)}|{f'T{i:02}' for i in range(1,9)}|{f'P{i:02}' for i in range(1,9)}
if len(rows)!=29 or {r['id'] for r in rows}!=expected:
raise SystemExit('Journey coverage must contain each of the 29 journey IDs exactly once')
rows=[r for r in rows if not args.role or r['role']==args.role]
for row in rows:
if not row['tests'] or row['implementation'] not in {'implemented','partial','external-blocked'}:
raise SystemExit('Invalid coverage entry: '+row['id'])
if row['implementation']!='implemented' and not row['remaining']:
raise SystemExit('Unexplained journey gap: '+row['id'])
selectors=sorted({name for row in rows for name in row['tests']})
suite=unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromName(name) for name in selectors)
result=unittest.TextTestRunner(verbosity=2).run(suite)
failed={test.id() for test,_ in result.failures+result.errors}
skipped={test.id() for test,_ in result.skipped}
report={'tests_run':result.testsRun,'test_success':result.wasSuccessful(),'skipped':len(skipped),
'journeys':[dict(row,automated_result='failed' if failed.intersection(row['tests']) else 'skipped' if skipped.intersection(row['tests']) else 'passed') for row in rows],
'complete':result.wasSuccessful() and not skipped and all(r['implementation']=='implemented' for r in rows)}
if args.report:
args.report.parent.mkdir(parents=True,exist_ok=True)
args.report.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({'tests_run':report['tests_run'],'test_success':report['test_success'],'complete':report['complete'],
'unresolved_journeys':[r['id'] for r in rows if r['implementation']!='implemented']}))
raise SystemExit(0 if result.wasSuccessful() and not skipped and (not args.require_complete or report['complete']) else 1)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import copy import copy
import os import os
from threading import RLock
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Iterable, Iterator, Mapping, cast from typing import Iterable, Iterator, Mapping, cast
@ -78,6 +79,17 @@ class InMemoryUserEngineStore:
default=None, init=False, repr=False default=None, init=False, repr=False
) )
_lifecycle_lock: object = field(default_factory=RLock, repr=False, compare=False)
@contextmanager
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
# One reentrant lock also protects the in-memory transaction snapshot.
with self._lifecycle_lock:
yield
def outbox_history(self) -> tuple[OutboxEvent, ...]:
return tuple(self.outbox_events)
def migrate(self) -> None: def migrate(self) -> None:
"""Apply the standalone schema migration manifest.""" """Apply the standalone schema migration manifest."""
self.schema_version = SCHEMA_VERSION self.schema_version = SCHEMA_VERSION

View file

@ -8,6 +8,7 @@ pooling, securing, and observing those connections.
from __future__ import annotations from __future__ import annotations
import json import json
from threading import RLock
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files from importlib.resources import files
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
@ -80,6 +81,32 @@ class PostgresUserEngineStore:
def __init__(self, connection: PostgresConnection) -> None: def __init__(self, connection: PostgresConnection) -> None:
self.connection = connection self.connection = connection
self._lifecycle_lock = RLock()
self._transaction_depth = 0
self._transaction_failed = False
@contextmanager
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
# Session locks survive the service's local commits and cover provider
# side effects. Separate processes use the same tenant-scoped DB lock.
with self._lifecycle_lock:
key = "user-engine:tenant-lifecycle:" + tenant
with self._cursor() as cursor:
cursor.execute("SELECT pg_advisory_lock(hashtextextended(%s, 0))", (key,))
try:
yield
except BaseException:
# An aborted transaction cannot execute the unlock query.
self.connection.rollback()
raise
finally:
with self._cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (key,))
def outbox_history(self) -> tuple[OutboxEvent, ...]:
with self._cursor() as cursor:
cursor.execute("SELECT payload FROM user_engine_outbox_events ORDER BY occurred_at, event_id")
return tuple(cast(OutboxEvent, self._decode_payload_row("outbox_events", row)) for row in cursor.fetchall())
@property @property
def schema_version(self) -> str | None: def schema_version(self) -> str | None:
@ -97,16 +124,26 @@ class PostgresUserEngineStore:
@contextmanager @contextmanager
def transaction(self) -> Iterator[None]: def transaction(self) -> Iterator[None]:
begin = getattr(self.connection, "begin", None) outer = self._transaction_depth == 0
if callable(begin): if outer:
begin() self._transaction_failed = False
begin = getattr(self.connection, "begin", None)
if callable(begin): begin()
self._transaction_depth += 1
try: try:
yield yield
except Exception: except BaseException:
self.connection.rollback() self._transaction_failed = True
if outer: self.connection.rollback()
raise raise
else: else:
self.connection.commit() if outer:
if self._transaction_failed:
self.connection.rollback()
raise RuntimeError("nested transaction failed")
self.connection.commit()
finally:
self._transaction_depth -= 1
def save_user(self, user: User) -> None: def save_user(self, user: User) -> None:
self._upsert_record(user) self._upsert_record(user)

View file

@ -39,6 +39,14 @@ class HTTPIdentityProvisioningAdapter:
"preferred_username": request.preferred_username, "preferred_username": request.preferred_username,
}) })
def tenant_access(self, *, external_subject: str, tenant: str, roles: tuple[str, ...],
enabled: bool, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
return self._post("/v1/identities/tenant-access", {
"external_subject": external_subject, "tenant": tenant, "roles": roles,
"enabled": enabled, "idempotency_key": idempotency_key,
"correlation_id": correlation_id,
})
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult: def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id) return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id)

View file

@ -175,6 +175,12 @@ class IdentityProvisioningPort(Protocol):
def provision(self, request: ProvisioningRequest) -> ProvisioningResult: def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
"""Create or resume an external login identity.""" """Create or resume an external login identity."""
def tenant_access(
self, *, external_subject: str, tenant: str, roles: tuple[str, ...],
enabled: bool, idempotency_key: str, correlation_id: str,
) -> ProvisioningResult:
"""Change only this tenant's directory groups, preserving identity and other tenants."""
def suspend( def suspend(
self, *, external_subject: str, idempotency_key: str, correlation_id: str self, *, external_subject: str, idempotency_key: str, correlation_id: str
) -> ProvisioningResult: ) -> ProvisioningResult:
@ -422,6 +428,12 @@ class UserEngineStore(Protocol):
def append_outbox(self, event: OutboxEvent) -> None: def append_outbox(self, event: OutboxEvent) -> None:
"""Append an outbox event.""" """Append an outbox event."""
def tenant_lifecycle_guard(self, tenant: str):
"""Serialize a tenant's lifecycle/role changes across provider and local writes."""
def outbox_history(self) -> tuple[OutboxEvent, ...]:
"""Return delivery records including failed and completed attempts."""
def pending_outbox(self) -> tuple[OutboxEvent, ...]: def pending_outbox(self) -> tuple[OutboxEvent, ...]:
"""Return pending outbox events in write order.""" """Return pending outbox events in write order."""

View file

@ -1837,7 +1837,69 @@ class UserEngineService:
) )
return updated return updated
def set_tenant_account_status( def authorize_tenant_member_action(
self, actor: Actor, user_id: str, *, tenant: str, correlation_id: str,
) -> User:
"""Authorize before any credential-provider side effect or target readout."""
self.resolve_tenant_context(actor, tenant)
if not {"tenant-admin", PLATFORM_OPERATOR_ROLE}.intersection(actor.roles):
raise AuthorizationDenied("tenant administrator role required")
memberships = self.store.memberships_for_user(user_id, tenant=tenant)
if not any(m.scope_type == "tenant" and m.scope_id == tenant for m in memberships):
raise NotFoundError("account is not a member of this tenant")
self._authorize(actor, action="tenant.account.update",
resource_type="user-engine:tenant-account",
resource_id=f"{tenant}:{user_id}", tenant=tenant,
correlation_id=correlation_id, target_user_id=user_id)
return self._require_user(user_id)
def validate_tenant_role_change(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
self.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
if role not in {"user", "tenant-admin"}:
raise ValidationError("Choose User or Tenant administrator.")
self._authorize(actor, action="membership.write", resource_type="user-engine:membership",
resource_id=f"{tenant}:{user_id}:tenant:{tenant}", tenant=tenant,
correlation_id=correlation_id, target_user_id=user_id,
context={"scope_type":"tenant", "scope_id":tenant, "kind":role})
if role != "tenant-admin":
self.require_admin_successor(user_id, tenant=tenant)
def set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
with self.store.tenant_lifecycle_guard(tenant):
self._set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
def _set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
self.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
with self.store.transaction():
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
if membership.scope_type == "tenant" and membership.scope_id == tenant:
self.store.save_membership(replace(membership, kind=role, freshness_version=correlation_id))
self._record_mutation(actor, action="membership.write", subject=user_id, tenant=tenant,
correlation_id=correlation_id, decision_id=None,
event_type="membership.role_changed", aggregate_id=user_id,
payload={"user_id":user_id, "tenant":tenant, "role":role})
def require_admin_successor(self, user_id: str, *, tenant: str) -> None:
"""Do not deactivate the last active tenant administrator."""
members = self.store.memberships_for_tenant(tenant)
admins = {m.user_id for m in members if m.scope_type == "tenant"
and m.scope_id == tenant and m.kind == "tenant-admin"}
if user_id not in admins:
return
account = self.store.tenant_account(tenant, user_id)
if account is not None and account.status != AccountStatus.ACTIVE:
return
for other in admins - {user_id}:
state = self.store.tenant_account(tenant, other)
if state is not None and state.status == AccountStatus.ACTIVE:
return
raise ConflictError("Assign another active tenant administrator before disabling this account.")
def set_tenant_account_status(self, actor: Actor, user_id: str, status: AccountStatus, *, tenant: str, correlation_id: str | None = None) -> TenantAccount:
with self.store.tenant_lifecycle_guard(tenant):
return self._set_tenant_account_status(actor, user_id, status, tenant=tenant, correlation_id=correlation_id)
def _set_tenant_account_status(
self, self,
actor: Actor, actor: Actor,
user_id: str, user_id: str,
@ -1863,6 +1925,8 @@ class UserEngineService:
) )
updated = replace(account, status=status) updated = replace(account, status=status)
with self.store.transaction(): with self.store.transaction():
if status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
self.require_admin_successor(user_id, tenant=tenant_context.tenant)
self.store.save_tenant_account(updated) self.store.save_tenant_account(updated)
self._record_mutation( self._record_mutation(
actor, actor,

View file

@ -30,7 +30,7 @@ def postgres_provider_test_config(
environ: Mapping[str, str] | None = None, environ: Mapping[str, str] | None = None,
) -> tuple[PostgresProviderTestConfig | None, str | None]: ) -> tuple[PostgresProviderTestConfig | None, str | None]:
"""Return live test config or a skip reason.""" """Return live test config or a skip reason."""
env = environ or os.environ env = os.environ if environ is None else environ
dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip() dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip()
if not dsn: if not dsn:
return None, f"{POSTGRES_TEST_DSN_ENV} is not set" return None, f"{POSTGRES_TEST_DSN_ENV} is not set"

View file

@ -13,6 +13,7 @@ from contextvars import ContextVar
from dataclasses import asdict, is_dataclass, replace from dataclasses import asdict, is_dataclass, replace
from enum import Enum from enum import Enum
from html import escape from html import escape
import time
import hashlib import hashlib
import hmac import hmac
import json import json
@ -349,6 +350,8 @@ class PortalApplication:
actor = self._actor(environ) actor = self._actor(environ)
self._set_account_navigation(environ, actor) self._set_account_navigation(environ, actor)
if path.startswith("/api/v1/tenants/"):
self._require_tenant_admin(actor, path.split("/")[4])
if path == "/api/v1/me" and method == "GET": if path == "/api/v1/me" and method == "GET":
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id) return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
if path == "/api/v1/me/profile" and method == "PATCH": if path == "/api/v1/me/profile" and method == "PATCH":
@ -383,13 +386,25 @@ class PortalApplication:
body = self._form_body(environ) body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", ""))) self._require_csrf(environ, str(body.get("csrf_token", "")))
self.service.me(self._claims(environ), correlation_id=correlation_id) self.service.me(self._claims(environ), correlation_id=correlation_id)
self.service.update_self_service_profile( try:
actor, display_name=str(body.get("display_name", "")), self.service.update_self_service_profile(
consent_accepted=body.get("consent_accepted") == "yes", actor, display_name=str(body.get("display_name", "")),
consent_version="portal-terms-v1", consent_accepted=body.get("consent_accepted") == "yes",
correlation_id=correlation_id, consent_version="portal-terms-v1", correlation_id=correlation_id,
) )
return self._redirect(start_response, "/onboarding", correlation_id) except ValidationError:
name = escape(str(body.get("display_name", ""))[:201])
csrf = escape(self._csrf_token(environ))
checked = " checked" if body.get("consent_accepted") == "yes" else ""
page = self._page_html("Check your profile", f'''<h1>Check your profile</h1>
<p role="alert">Enter a display name of 1 to 200 characters. Your profile has not been saved.</p>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{csrf}">
<label>Display name <input name="display_name" value="{name}" required maxlength="200" aria-invalid="true"></label>
<label><input type="checkbox" name="consent_accepted" value="yes"{checked}> I accept portal terms version 1</label>
<button type="submit">Save profile</button></form><p><a href="/onboarding">Cancel</a></p>''')
return self._html(start_response, page, correlation_id, status="400 Bad Request")
return self._html(start_response, self._page_html("Profile saved",
'<h1>Profile saved</h1><p role="status">Your profile changes have been saved.</p><p><a href="/onboarding">Return to my account</a></p>'), correlation_id)
if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST": if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
body = self._form_body(environ) body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", ""))) self._require_csrf(environ, str(body.get("csrf_token", "")))
@ -511,6 +526,7 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable") raise ValidationError("identity provisioning is unavailable")
parts = path.split("/") parts = path.split("/")
tenant, user_id = parts[5], parts[7] tenant, user_id = parts[5], parts[7]
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
idempotency_key = self._idempotency_key(environ) idempotency_key = self._idempotency_key(environ)
user = self.service.store.user(user_id) user = self.service.store.user(user_id)
if user is None: if user is None:
@ -531,10 +547,9 @@ class PortalApplication:
) )
recovery = {"status": provisioned.status, "changed": ("identity",)} recovery = {"status": provisioned.status, "changed": ("identity",)}
else: else:
reconciled = self.provisioning.reconcile( self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
request, external_subject=identity.subject, desired_status="active" idempotency_key=idempotency_key, correlation_id=correlation_id)
) recovery = {"status": "tenant_active", "changed": ("tenant_access",)}
recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed}
account = self.service.set_tenant_account_status( account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE, tenant=tenant, actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
correlation_id=correlation_id, correlation_id=correlation_id,
@ -654,7 +669,8 @@ class PortalApplication:
if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST": if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST":
parts = path.split("/") parts = path.split("/")
tenant, invitation_id, action = parts[4], parts[6], parts[7] tenant, invitation_id, action = parts[4], parts[6], parts[7]
self.service.resolve_tenant_context(actor, tenant) self._require_tenant_admin(actor, tenant)
self._require_invitation_tenant(invitation_id, tenant)
expected = self._expected_version(environ) expected = self._expected_version(environ)
if action == "resend": if action == "resend":
value = self.service.resend_family_invitation( value = self.service.resend_family_invitation(
@ -674,10 +690,11 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable") raise ValidationError("identity provisioning is unavailable")
parts = path.split("/") parts = path.split("/")
tenant, user_id = parts[4], parts[6] tenant, user_id = parts[4], parts[6]
self.service.resolve_tenant_context(actor, tenant) self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
user = self.service.store.user(user_id) user = self.service.store.user(user_id)
if user is None: if user is None:
raise NotFoundError("user not found") raise NotFoundError("user not found")
self._require_setup_access(tenant, user_id)
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16: if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters") raise ValidationError("Idempotency-Key must contain at least 16 characters")
@ -730,21 +747,29 @@ class PortalApplication:
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16: if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters") raise ValidationError("Idempotency-Key must contain at least 16 characters")
identity = next(iter(self.service.store.identities_for_user(user_id)), None) account = self._change_status(actor, tenant, user_id, AccountStatus.DISABLED,
if identity is not None: idempotency_key=idempotency_key, correlation_id=correlation_id)
self.provisioning.deprovision(
external_subject=identity.subject,
idempotency_key=idempotency_key,
correlation_id=correlation_id,
)
account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.DISABLED,
tenant=tenant, correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", { return self._json(start_response, "200 OK", {
"status": "removed", "tenant_account": _jsonable(account), "status": "removed", "tenant_account": _jsonable(account),
"provider_identity_removed": identity is not None, "provider_identity_removed": False,
}, correlation_id) }, correlation_id)
if path in {"/platform/operations", "/platform/operations/replay"}:
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if method == "POST" and path.endswith("/replay"):
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
event = self.service.store.outbox_event(str(body.get("event_id", "")))
if event is None:
raise NotFoundError("delivery record not found")
if event.delivered_at is not None or event.claimed_by:
raise ConflictError("Delivery is already completed or being processed. Refresh its status.")
self.service.replay_outbox(actor, event.event_id, correlation_id=correlation_id)
return self._redirect(start_response, "/platform/operations?"+urlencode({"event_id":event.event_id}), correlation_id)
if method != "GET" or path.endswith("/replay"):
raise NotFoundError("operations route not found")
query = parse_qs(str(environ.get("QUERY_STRING", "")))
return self._html(start_response, self._operations_page(actor,
self._csrf_token(environ), query.get("event_id", [""])[0], correlation_id), correlation_id)
if path == "/platform" and method == "GET": if path == "/platform" and method == "GET":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT) self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
return self._html( return self._html(
@ -764,19 +789,26 @@ class PortalApplication:
idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id, idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id,
) )
email = str(body.get("admin_email", "")) email = str(body.get("admin_email", ""))
if email: with self.service.store.tenant_lifecycle_guard(tenant), self.service.store.transaction():
user = self.service.create_user( existing_admin = any(
actor, display_name=body.get("admin_display_name"), m.scope_type == "tenant" and m.scope_id == tenant and m.kind == "tenant-admin"
primary_email=email, correlation_id=correlation_id, and (u := self.service.store.user(m.user_id)) is not None
) and (u.primary_email or "").casefold() == email.casefold()
self.service.set_tenant_account_status( for m in self.service.store.memberships_for_tenant(tenant)
actor, user.user_id, AccountStatus.INVITED, ) if email else False
tenant=tenant, correlation_id=correlation_id, if email and not existing_admin:
) user = self.service.create_user(
self.service.add_membership( actor, display_name=body.get("admin_display_name"),
actor, user.user_id, tenant=tenant, scope_type="tenant", primary_email=email, correlation_id=correlation_id,
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id, )
) self.service.set_tenant_account_status(
actor, user.user_id, AccountStatus.INVITED,
tenant=tenant, correlation_id=correlation_id,
)
self.service.add_membership(
actor, user.user_id, tenant=tenant, scope_type="tenant",
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
)
return self._html( return self._html(
start_response, start_response,
self._platform_result(result, tenant, bool(email)), correlation_id, self._platform_result(result, tenant, bool(email)), correlation_id,
@ -834,6 +866,13 @@ class PortalApplication:
version = str(body.get("version", "")) version = str(body.get("version", ""))
if not version.isdigit(): if not version.isdigit():
raise ValidationError("the current record version is required") raise ValidationError("the current record version is required")
if operation in {"retire", "reactivate"}:
current = self.tenant_management.tenant(tenant=tenant, correlation_id=correlation_id)
preview = self._confirm_change(environ, start_response, body, str(current.version),
f"{operation.capitalize()} {tenant}",
"This changes the tenant lifecycle. Existing application sessions may take time to reflect the change. Review the tenant and reason before confirming.", correlation_id)
if preview is not None:
return preview
metadata = { metadata = {
key: str(body[key]) for key in ("display_name", "contact_email") key: str(body[key]) for key in ("display_name", "contact_email")
if str(body.get(key, "")).strip() if str(body.get(key, "")).strip()
@ -853,8 +892,13 @@ class PortalApplication:
correlation_id, correlation_id,
) )
if path.startswith("/admin/") and method == "GET": if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2] tenant = unquote(path.split("/")[2])
self.service.resolve_tenant_context(actor, tenant) self._require_tenant_admin(actor, tenant)
if path.endswith("/activity"):
self.service.tenant_diagnostics(actor, tenant=tenant, correlation_id=correlation_id)
return self._html(start_response, self._audit_page(tenant), correlation_id)
if len(path.split("/")) != 3:
raise NotFoundError("tenant page not found")
memberships = self.service.store.memberships_for_tenant(tenant) memberships = self.service.store.memberships_for_tenant(tenant)
invitations = self.service.store.family_invitations_for_tenant(tenant) invitations = self.service.store.family_invitations_for_tenant(tenant)
diagnostics = self.service.tenant_diagnostics( diagnostics = self.service.tenant_diagnostics(
@ -870,10 +914,23 @@ class PortalApplication:
) )
if path.startswith("/admin/") and method == "POST": if path.startswith("/admin/") and method == "POST":
parts = path.split("/") parts = path.split("/")
tenant = parts[2] tenant = unquote(parts[2])
self.service.resolve_tenant_context(actor, tenant) self._require_tenant_admin(actor, tenant)
if len(parts) == 6 and parts[3] == "users":
self.service.authorize_tenant_member_action(actor, parts[4], tenant=tenant, correlation_id=correlation_id)
body = self._form_body(environ) body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", ""))) self._require_csrf(environ, str(body.get("csrf_token", "")))
if len(parts) == 6 and parts[3] == "users" and parts[5] in {"status", "remove", "recover", "role"}:
user = self.service.store.user(parts[4])
state = self.service.store.tenant_account(tenant, parts[4])
snapshot = repr((state, self.service.store.memberships_for_user(parts[4], tenant=tenant)))
preview = self._confirm_change(environ, start_response, body, snapshot,
f"{parts[5].capitalize()} account in {tenant}",
f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained.", correlation_id)
if preview is not None:
return preview
if len(parts) == 4 and parts[3] in {"users", "invitations"} and body.get("role", "user") not in {"user", "tenant-admin"}:
raise ValidationError("Choose User or Tenant administrator.")
if len(parts) == 4 and parts[3] == "users": if len(parts) == 4 and parts[3] == "users":
user = self.service.create_user( user = self.service.create_user(
actor, actor,
@ -904,6 +961,7 @@ class PortalApplication:
return self._redirect(start_response, f"/admin/{tenant}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "invitations": if len(parts) == 6 and parts[3] == "invitations":
invitation_id, action = parts[4], parts[5] invitation_id, action = parts[4], parts[5]
self._require_invitation_tenant(invitation_id, tenant)
version = int(body.get("version", "0")) version = int(body.get("version", "0"))
if action == "resend": if action == "resend":
self.service.resend_family_invitation( self.service.resend_family_invitation(
@ -925,6 +983,7 @@ class PortalApplication:
user = self.service.store.user(user_id) user = self.service.store.user(user_id)
if user is None: if user is None:
raise NotFoundError("user not found") raise NotFoundError("user not found")
self._require_setup_access(tenant, user_id)
result = self.provisioning.provision(ProvisioningRequest( result = self.provisioning.provision(ProvisioningRequest(
user_id=user.user_id, user_id=user.user_id,
tenant=tenant, tenant=tenant,
@ -947,12 +1006,29 @@ class PortalApplication:
return self._html( return self._html(
start_response, start_response,
self._password_setup_handoff( self._password_setup_handoff(
result.password_setup_url, tenant result.password_setup_url, tenant, result.external_subject
), ),
correlation_id, correlation_id,
) )
query = urlencode({"provisioned": user.user_id, "status": result.status}) query = urlencode({"provisioned": user.user_id, "status": result.status})
return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "role":
role = str(body.get("role", ""))
user_id = parts[4]
with self.service.store.tenant_lifecycle_guard(tenant):
self.service.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
identity = next((i for i in self.service.store.identities_for_user(user_id) if i.provider == "netkingdom-lldap"), None)
account = self.service.store.tenant_account(tenant, user_id)
if identity is not None:
if not callable(getattr(self.provisioning, "tenant_access", None)):
raise ValidationError("Tenant-scoped identity changes are unavailable.")
enabled = account is not None and account.status == AccountStatus.ACTIVE
result = self.provisioning.tenant_access(external_subject=identity.subject, tenant=tenant,
roles=(role,), enabled=enabled, idempotency_key=f"portal-role-{tenant}-{user_id}-{role}", correlation_id=correlation_id)
if result.status != ("tenant_active" if enabled else "tenant_disabled"):
raise RuntimeError("tenant role change not confirmed")
self.service.set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "status": if len(parts) == 6 and parts[3] == "users" and parts[5] == "status":
status = AccountStatus(str(body.get("status", ""))) status = AccountStatus(str(body.get("status", "")))
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}: if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
@ -964,20 +1040,8 @@ class PortalApplication:
) )
return self._redirect(start_response, f"/admin/{tenant}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove": if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove":
if self.provisioning is None: self._change_status(actor, tenant, parts[4], AccountStatus.DISABLED,
raise ValidationError("identity provisioning is unavailable") idempotency_key=f"portal-remove-{tenant}-{parts[4]}", correlation_id=correlation_id)
user_id = parts[4]
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
if identity is not None:
self.provisioning.deprovision(
external_subject=identity.subject,
idempotency_key=f"portal-remove-{tenant}-{user_id}",
correlation_id=correlation_id,
)
self.service.set_tenant_account_status(
actor, user_id, AccountStatus.DISABLED,
tenant=tenant, correlation_id=correlation_id,
)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover": if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT) self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
@ -1003,9 +1067,8 @@ class PortalApplication:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
else: else:
self.provisioning.reconcile( self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
request, external_subject=identity.subject, desired_status="active" idempotency_key=request.idempotency_key, correlation_id=correlation_id)
)
self.service.set_tenant_account_status( self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE, actor, user_id, AccountStatus.ACTIVE,
tenant=tenant, correlation_id=correlation_id, tenant=tenant, correlation_id=correlation_id,
@ -1013,7 +1076,11 @@ class PortalApplication:
return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id)
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id) return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
def _change_status( def _change_status(self, actor: Any, tenant: str, user_id: str, status: AccountStatus, *, idempotency_key: str, correlation_id: str) -> Any:
with self.service.store.tenant_lifecycle_guard(tenant):
return self._change_status_locked(actor, tenant, user_id, status, idempotency_key=idempotency_key, correlation_id=correlation_id)
def _change_status_locked(
self, self,
actor: Any, actor: Any,
tenant: str, tenant: str,
@ -1025,34 +1092,103 @@ class PortalApplication:
) -> Any: ) -> Any:
if self.provisioning is None: if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable") raise ValidationError("identity provisioning is unavailable")
self.service.resolve_tenant_context(actor, tenant) self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
identity = next( if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
( raise ValidationError("unsupported tenant account status")
item for item in self.service.store.identities_for_user(user_id) if status != AccountStatus.ACTIVE:
if item.provider == "netkingdom-lldap" self.service.require_admin_successor(user_id, tenant=tenant)
), identity = next((item for item in self.service.store.identities_for_user(user_id)
None, if item.provider == "netkingdom-lldap"), None)
) if identity is not None:
if identity is None: if not callable(getattr(self.provisioning, "tenant_access", None)):
raise ValidationError("user has no managed login identity") raise ValidationError("Tenant-scoped identity changes are unavailable. No shared login was changed.")
if status == AccountStatus.SUSPENDED: result = self.provisioning.tenant_access(
self.provisioning.suspend( external_subject=identity.subject, tenant=tenant,
external_subject=identity.subject, roles=tuple(m.kind for m in self.service.store.memberships_for_user(user_id, tenant=tenant)
idempotency_key=idempotency_key, if m.scope_type == "tenant" and m.scope_id == tenant),
correlation_id=correlation_id, enabled=status == AccountStatus.ACTIVE,
idempotency_key=idempotency_key, correlation_id=correlation_id,
) )
elif status == AccountStatus.ACTIVE: expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled"
self.provisioning.reactivate( if result.status != expected:
external_subject=identity.subject, raise RuntimeError("tenant access change not confirmed")
idempotency_key=idempotency_key,
correlation_id=correlation_id,
)
else:
raise ValidationError("provider lifecycle supports active or suspended")
return self.service.set_tenant_account_status( return self.service.set_tenant_account_status(
actor, user_id, status, tenant=tenant, correlation_id=correlation_id actor, user_id, status, tenant=tenant, correlation_id=correlation_id,
) )
def _confirm_change(self, environ: Mapping[str, Any], start_response: StartResponse,
body: Mapping[str, str], snapshot: str, title: str, explanation: str,
correlation_id: str) -> list[bytes] | None:
path = str(environ.get("PATH_INFO", ""))
fields = {k: v for k, v in body.items() if k != "confirm_token"}
state = hashlib.sha256(snapshot.encode()).hexdigest()
def signature(stamp: str) -> str:
material = json.dumps([path, fields, state, stamp], sort_keys=True).encode()
return hmac.new(self.trusted_proxy_secret.encode(), material, hashlib.sha256).hexdigest()
supplied = str(body.get("confirm_token", ""))
if supplied:
stamp, _, digest = supplied.partition(".")
if not stamp.isdigit() or not 0 <= time.time()-int(stamp) <= 600 or not hmac.compare_digest(digest, signature(stamp)):
raise ConflictError("The confirmation expired or the account changed. Refresh and review the action again.")
return None
stamp = str(int(time.time()))
hidden = "".join(f'<input type="hidden" name="{escape(k)}" value="{escape(v)}">' for k,v in fields.items())
details = "".join(f'<li>{escape(k.replace("_", " "))}: {escape(v)}</li>' for k,v in fields.items() if k in {"status", "operation", "reason", "version", "role"})
page = self._page_html(title, f'<h1>{escape(title)}?</h1><p>{escape(explanation)}</p><ul>{details}</ul>'
f'<form method="post" action="{escape(path)}">{hidden}<input type="hidden" name="confirm_token" value="{stamp}.{signature(stamp)}">'
'<button type="submit">Confirm change</button></form><p><a href="/">Cancel without changes</a></p>')
return self._html(start_response, page, correlation_id)
def _audit_page(self, tenant: str) -> str:
records = [r for r in self.service.audit_records() if r.tenant == tenant][-100:]
rows = "".join(f'<tr><td>{escape(r.recorded_at.isoformat())}</td><td>{escape(r.action)}</td><td>{escape(r.actor.preferred_username or r.actor.subject)}</td><td>{escape(r.correlation_id)}</td></tr>' for r in reversed(records))
return self._page_html("Account activity", f'<h1>Account activity</h1><p>Tenant: {escape(tenant)}. Most recent 100 recorded actions. A recorded request is not proof of delivery or effective application access.</p>'
'<table><thead><tr><th>Time</th><th>Action</th><th>Actor</th><th>Support reference</th></tr></thead><tbody>'
+ (rows or '<tr><td colspan="4">No recorded activity yet.</td></tr>') + '</tbody></table>'
f'<p><a href="/admin/{escape(tenant)}">Return to tenant administration</a></p>')
@staticmethod
def _delivery_status(event: Any) -> str:
if event.delivered_at: return "Accepted by delivery adapter; receipt by the person is unverified"
if event.dead_lettered_at: return "Delivery stopped after repeated failures"
if event.failed_at: return "Delivery failed; retry pending"
if event.claimed_by: return "Being processed"
return "Queued for delivery"
def _operations_page(self, actor: Any, csrf: str, event_id: str, correlation_id: str) -> str:
self.service.tenant_diagnostics(actor, tenant=PLATFORM_TENANT, correlation_id=correlation_id)
events = list(self.service.store.outbox_history())[-100:]
if event_id:
event = self.service.store.outbox_event(event_id)
if event is None: raise NotFoundError("delivery record not found")
events = [event]
rows = ""
for event in events:
action = ""
if event.delivered_at is None and not event.claimed_by and (event.failed_at or event.dead_lettered_at):
action = f'<form method="post" action="/platform/operations/replay"><input type="hidden" name="csrf_token" value="{escape(csrf)}"><input type="hidden" name="event_id" value="{escape(event.event_id)}"><button type="submit">Queue a retry</button></form>'
rows += f'<tr><td>{escape(event.event_id)}</td><td>{escape(event.tenant)}</td><td>{escape(event.event_type)}</td><td>{escape(self._delivery_status(event))}</td><td>{escape(event.correlation_id)}</td><td>{action}</td></tr>'
return self._page_html("Service recovery", '<h1>Service recovery</h1><p>This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.</p>'
'<form method="get" action="/platform/operations"><label>Delivery record ID <input name="event_id"></label><button type="submit">Find delivery</button></form>'
'<table><thead><tr><th>Delivery</th><th>Tenant</th><th>Kind</th><th>Status</th><th>Support reference</th><th>Recovery</th></tr></thead><tbody>'
+ (rows or '<tr><td colspan="6">No delivery records. This does not prove mail was received.</td></tr>')
+ '</tbody></table><p>Queued retries are processed by the delivery worker. Check the record again for the result.</p><p><a href="/platform">Return to platform administration</a></p>')
def _require_setup_access(self, tenant: str, user_id: str) -> None:
account = self.service.store.tenant_account(tenant, user_id)
if account is not None and account.status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
raise ConflictError("Reactivate this tenant account before creating a password setup link.")
def _require_tenant_admin(self, actor: Any, tenant: str) -> None:
self.service.resolve_tenant_context(actor, tenant)
if not {"tenant-admin", "platform-operator"}.intersection(actor.roles):
raise AuthorizationDenied("tenant administrator role required")
def _require_invitation_tenant(self, invitation_id: str, tenant: str) -> None:
invitation = self.service.store.family_invitation(invitation_id)
if invitation is None or invitation.tenant != tenant:
raise NotFoundError("invitation not found in this tenant")
def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]: def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]:
if self.oidc_client is not None: if self.oidc_client is not None:
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
@ -1698,18 +1834,28 @@ Use the login name they provide; it may differ from your display name.</p></sect
) -> str: ) -> str:
rows = "".join( rows = "".join(
self._admin_row(tenant, item, platform_operator, csrf_token) self._admin_row(tenant, item, platform_operator, csrf_token)
for item in memberships for item in memberships if item.scope_type == "tenant" and item.scope_id == tenant
) or '<tr><td colspan="6">No members yet.</td></tr>' ) or '<tr><td colspan="6">No members yet.</td></tr>'
invitation_rows = "".join( invitation_rows = "".join(
self._invitation_admin_row(tenant, item, csrf_token) self._invitation_admin_row(tenant, item, csrf_token)
for item in invitations for item in invitations
) or '<tr><td colspan="5">No invitations yet.</td></tr>' ) or '<tr><td colspan="5">No invitations yet.</td></tr>'
progress_items = []
for journey in self.service.store.onboarding_journeys_for_tenant(tenant):
user = self.service.store.user(journey.user_id)
name = user.display_name or user.user_id if user else "Account"
pending = [step for step in journey.steps if step.status.value not in {"completed", "skipped", "cancelled"}]
steps = "; ".join(step.title + "" + step.status.value.replace("_", " ") for step in pending)
progress_items.append(f'<li>{escape(name)}: {escape(journey.status.value.replace("_", " "))}. {escape(steps)}'
'<p>Ask the person to open My account for profile steps, or use sign-in help for provider steps.</p></li>')
progress = "".join(progress_items) or "<li>No additional onboarding journeys are recorded.</li>"
diagnostic_items = "".join( diagnostic_items = "".join(
f"<li>{escape(item.replace('_', ' '))}</li>" for item in diagnostics.issues f"<li>{escape(item.replace('_', ' '))}</li>" for item in diagnostics.issues
) or "<li>No lifecycle gaps detected.</li>" ) or "<li>No lifecycle gaps detected.</li>"
return self._page_html( return self._page_html(
f"{tenant} users", f"{tenant} users",
f"""<h1>{escape(tenant)} users</h1> f"""<h1>{escape(tenant)} users</h1>
<p><a href="/admin/{escape(tenant)}/activity">Account activity and support references</a> · <a href="/security">Sign-in recovery help</a></p>
<section><h2>Add a user</h2> <section><h2>Add a user</h2>
<form method="post" action="/admin/{escape(tenant)}/users"> <form method="post" action="/admin/{escape(tenant)}/users">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> <input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
@ -1725,7 +1871,8 @@ Use the login name they provide; it may differ from your display name.</p></sect
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label> <label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
<button type="submit">Send invitation</button></form></section> <button type="submit">Send invitation</button></form></section>
<section><h2>Invitations</h2><table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Expires</th><th>Action</th></tr></thead><tbody>{invitation_rows}</tbody></table></section> <section><h2>Invitations</h2><table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Expires</th><th>Action</th></tr></thead><tbody>{invitation_rows}</tbody></table></section>
<section aria-labelledby="lifecycle-gaps"><h2 id="lifecycle-gaps">Lifecycle diagnostics</h2><p>Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.</p><ul>{diagnostic_items}</ul></section> <section aria-labelledby="lifecycle-gaps"><h2 id="lifecycle-gaps">Lifecycle diagnostics</h2><p>Check the account and invitation states below. Password and authenticator status are not available here.</p><ul>{diagnostic_items}</ul></section>
<section><h2>Onboarding follow-up</h2><ul>{progress}</ul><p>These are recorded workflow states. Missing password or authenticator evidence is not proof of completion.</p></section>
<section><h2>Members</h2><table><thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Directory</th><th>Action</th></tr></thead><tbody>{rows}</tbody></table></section>""", <section><h2>Members</h2><table><thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Directory</th><th>Action</th></tr></thead><tbody>{rows}</tbody></table></section>""",
) )
@ -1737,51 +1884,39 @@ Use the login name they provide; it may differ from your display name.</p></sect
<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/expire"> <form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/expire">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Expire</button></form>""" <input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Expire</button></form>"""
expires = invitation.expires_at.isoformat() if invitation.expires_at else "" expires = invitation.expires_at.isoformat() if invitation.expires_at else ""
events = [e for e in self.service.store.outbox_history() if e.tenant == tenant
and e.aggregate_id == invitation.invitation_id and e.event_type in {"family_invitation.created", "family_invitation.resent", "family_member.invited"}]
delivery = self._delivery_status(events[-1]) if events else "Delivery status unavailable"
return ( return (
f"<tr><td>{escape(invitation.primary_email)}</td><td>{escape(invitation.role)}</td>" f"<tr><td>{escape(invitation.primary_email)}</td><td>{escape(invitation.role)}</td>"
f"<td>{escape(invitation.status.value)}</td><td>{escape(expires)}</td><td>{actions}</td></tr>" f"<td>{escape(invitation.status.value)}<p>{escape(delivery)}</p></td><td>{escape(expires)}</td><td>{actions}</td></tr>"
) )
def _admin_row( def _admin_row(self, tenant: str, membership: Any, platform_operator: bool, csrf_token: str) -> str:
self, tenant: str, membership: Any,
platform_operator: bool, csrf_token: str,
) -> str:
user = self.service.store.user(membership.user_id) user = self.service.store.user(membership.user_id)
identities = self.service.store.identities_for_user(membership.user_id) directory = next((i for i in self.service.store.identities_for_user(membership.user_id)
directory = next( if i.provider == "netkingdom-lldap"), None)
(item for item in identities if item.provider == "netkingdom-lldap"), account = self.service.store.tenant_account(tenant, membership.user_id)
None, status = account.status if account else AccountStatus.INVITED
) inactive = status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}
tenant_account = self.service.store.tenant_account(tenant, membership.user_id) root = f"/admin/{quote(tenant, safe='')}/users/{quote(membership.user_id, safe='')}"
status = tenant_account.status if tenant_account else AccountStatus.INVITED def form(action: str, label: str, **fields: str) -> str:
action = ( hidden = "".join(f'<input type="hidden" name="{escape(k)}" value="{escape(v)}">' for k,v in fields.items())
f"""<span>Linked as {escape(directory.subject)}</span> return f'<form method="post" action="{root}/{action}"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">{hidden}<button type="submit">{label}</button></form>'
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision"> login = (f'<p>Login name: <strong>{escape(self._directory_login(directory.subject))}</strong></p>'
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> '<p>Password and authenticator status are not available here.</p>') if directory else '<p>Login not created. Prepare a login before asking this person to sign in.</p>'
<button type="submit">Create password setup link</button></form> actions = ""
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/status"> if not inactive:
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> actions += form("provision", "Create password setup link" if directory else "Create login")
<input type="hidden" name="status" value="{'active' if status == AccountStatus.SUSPENDED else 'suspended'}"> actions += form("status", "Reactivate" if inactive else "Suspend", status="active" if inactive else "suspended")
<button type="submit">{'Reactivate' if status == AccountStatus.SUSPENDED else 'Suspend'}</button></form>""" if status != AccountStatus.DISABLED:
if directory actions += form("remove", "Remove account")
else f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision"> next_role = "user" if membership.kind == "tenant-admin" else "tenant-admin"
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> actions += form("role", "Make user" if next_role == "user" else "Make tenant administrator", role=next_role)
<button type="submit">Create login</button></form>""" if platform_operator: actions += form("recover", "Recover identity")
) return (f'<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>'
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/remove"> f'<td>{escape(user.primary_email or "") if user else ""}</td><td>{escape(membership.kind)}</td>'
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> f'<td>{escape(status.value)} for this tenant</td><td>{login}</td><td>{actions}</td></tr>')
<button type="submit">Remove account</button></form>"""
if platform_operator:
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/recover">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<button type="submit">Recover identity</button></form>"""
return (
f"<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>"
f"<td>{escape(user.primary_email or '') if user else ''}</td>"
f"<td>{escape(membership.kind)}</td>"
f"<td>{escape(status.value)}</td>"
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
)
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str: def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
return self._page_html( return self._page_html(
@ -1948,13 +2083,19 @@ Use the login name they provide; it may differ from your display name.</p></sect
f"<li><span>{escape(step.title)}{escape(step.status.value)}</span>{gap}{action}</li>" f"<li><span>{escape(step.title)}{escape(step.status.value)}</span>{gap}{action}</li>"
) )
def _password_setup_handoff(self, setup_url: str, tenant: str) -> str: @staticmethod
def _directory_login(subject: str) -> str:
match = re.fullmatch(r"uid=([A-Za-z0-9._-]+),ou=people,dc=netkingdom,dc=local", subject)
return match.group(1) if match else subject
def _password_setup_handoff(self, setup_url: str, tenant: str, subject: str = "") -> str:
if not setup_url.startswith("https://"): if not setup_url.startswith("https://"):
raise ValidationError("password setup handoff must use HTTPS") raise ValidationError("password setup handoff must use HTTPS")
return self._page_html( return self._page_html(
"Password setup", "Password setup",
"<h1>Login identity created</h1>" "<h1>Login ready for password setup</h1>"
"<p>The password is handled only by the NetKingdom identity " + f"<p>Login name: <strong>{escape(self._directory_login(subject))}</strong>. Use this name when signing in; it may differ from the display name.</p>"
+ "<p>The password is handled only by the NetKingdom identity "
"surface. This short-lived link is single use.</p>" "surface. This short-lived link is single use.</p>"
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">' f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
"Continue to password setup</a></p>" "Continue to password setup</a></p>"
@ -1972,7 +2113,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
return return
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>' links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
if "platform-operator" in actor.roles: if "platform-operator" in actor.roles:
links += '<a href="/platform">Platform administration</a>' links += '<a href="/platform">Platform administration</a><a href="/platform/operations">Service recovery</a>'
elif "tenant-admin" in actor.roles: elif "tenant-admin" in actor.roles:
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>' links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
@ -2001,10 +2142,10 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
def _html( def _html(
self, start_response: StartResponse, body: str, correlation_id: str, self, start_response: StartResponse, body: str, correlation_id: str,
*, extra_headers: list[tuple[str, str]] | None = None, *, extra_headers: list[tuple[str, str]] | None = None, status: str = "200 OK",
) -> list[bytes]: ) -> list[bytes]:
data = body.encode() data = body.encode()
start_response("200 OK", [ start_response(status, [
("Content-Type", "text/html; charset=utf-8"), ("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(data))), ("Content-Length", str(len(data))),
*(extra_headers or []), *(extra_headers or []),

291
tests/journey-coverage.json Normal file
View file

@ -0,0 +1,291 @@
{
"schema_version": 1,
"journeys": [
{
"id": "U01",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_clarity.AccountClarityTests.test_anonymous_and_expired_sessions_have_login_without_logout",
"test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login"
],
"remaining": ""
},
{
"id": "U02",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_expired_browser_session_and_provider_outage_fail_closed",
"test_account_recovery.AccountRecoveryTests.test_failed_callback_has_clean_recovery_and_no_loop"
],
"remaining": "KEY-WP-0035: actual no-factor/enrolled login needs provider credential and policy rollout."
},
{
"id": "U03",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_browser_invitation_acceptance_and_onboarding_status",
"test_web.PortalApplicationTests.test_invitation_lifecycle_is_versioned_and_replay_safe"
],
"remaining": "Live notification delivery and invited-person acceptance remain unverified."
},
{
"id": "U04",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry"
],
"remaining": "Provider password reset is separately tested in net-kingdom; live email recovery remains unresolved."
},
{
"id": "U05",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U06",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U07",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U08",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U09",
"role": "user",
"implementation": "partial",
"tests": [
"test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user"
],
"remaining": "USER-WP-0028-T02/USER-WP-0026-T03: authoritative application catalogue and access requests not implemented."
},
{
"id": "U10",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_wrong_shared_identity_recovery_does_not_claim_a_known_session",
"test_portal_navigation.PortalNavigationTests.test_navigation_does_not_leak_between_operator_member_and_anonymous"
],
"remaining": "Authenticated multi-identity/tenant switching must be verified against live issuer."
},
{
"id": "U11",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_recovery.AccountRecoveryTests.test_shared_logout_clears_portal_then_uses_provider_confirmation",
"test_portal_navigation.PortalNavigationTests.test_get_logout_only_confirms_and_bad_csrf_does_not_end_session",
"test_portal_navigation.PortalNavigationTests.test_expired_session_logout_clears_stale_cookie"
],
"remaining": ""
},
{
"id": "U12",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_clarity.AccountClarityTests.test_browser_denial_is_recoverable_while_api_remains_json",
"test_account_recovery.AccountRecoveryTests.test_recovery_is_public_and_never_trusts_query_identity"
],
"remaining": ""
},
{
"id": "U13",
"role": "user",
"implementation": "implemented",
"tests": [
"test_journey_roles.UserJourneys.test_profile_validation_keeps_safe_input_and_retry_saves",
"test_web.PortalApplicationTests.test_browser_invitation_acceptance_and_onboarding_status"
],
"remaining": ""
},
{
"id": "T01",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_wrong_role_or_tenant_never_calls_provider",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload"
],
"remaining": ""
},
{
"id": "T02",
"role": "tenant_admin",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_invitation_lifecycle_is_versioned_and_replay_safe",
"test_journey_roles.TenantAdminJourneys.test_invalid_role_cannot_create_partial_account",
"test_journey_roles.TenantAdminJourneys.test_invitation_delivery_reports_queue_failure_and_adapter_acceptance"
],
"remaining": "Local invitation queue/failure/adapter-acceptance states are implemented. Actual receipt by the invited person remains live provider acceptance under U03."
},
{
"id": "T03",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry",
"test_web.PortalApplicationTests.test_admin_form_requires_csrf_and_supports_two_step_provisioning"
],
"remaining": ""
},
{
"id": "T04",
"role": "tenant_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry",
"test_journey_roles.TenantAdminJourneys.test_provider_failure_retains_local_state_and_retry_recovers"
],
"remaining": "Provider-owned lost-factor recovery still unverified; password setup assistance is supported."
},
{
"id": "T05",
"role": "tenant_admin",
"implementation": "partial",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_invalid_role_cannot_create_partial_account"
],
"remaining": "Tenant roles are managed; authoritative application-specific grant/revoke integration remains USER-WP-0028-T02."
},
{
"id": "T06",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_confirmation_cancel_tamper_and_stale_state",
"test_journey_roles.TenantAdminJourneys.test_tenant_removal_preserves_shared_identity_and_other_account",
"test_journey_roles.TenantAdminJourneys.test_provider_failure_retains_local_state_and_retry_recovers",
"test_journey_roles.TenantAdminJourneys.test_disabled_account_cannot_be_reactivated_by_password_setup"
],
"remaining": ""
},
{
"id": "T07",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_web.PortalApplicationTests.test_admin_form_requires_csrf_and_supports_two_step_provisioning"
],
"remaining": "Provider factor/password evidence remains explicitly unavailable; provider verification is tracked under U05-U08."
},
{
"id": "T08",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_last_admin_protected_then_successor_allows_transition",
"test_journey_roles.TenantAdminJourneys.test_admin_succession_promote_then_demote_with_confirmation",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload",
"test_journey_roles.TenantAdminJourneys.test_concurrent_admin_suspensions_keep_one_active_admin"
],
"remaining": "Disposable PostgreSQL cross-connection/rollback suite also passed; run its separate opt-in suite for DB changes."
},
{
"id": "P01",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_portal_navigation.PortalNavigationTests.test_operator_can_reach_administration_without_personal_membership",
"test_portal_navigation.PortalNavigationTests.test_existing_tenant_user_navigation_preserves_authority"
],
"remaining": ""
},
{
"id": "P02",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_bootstrap_retry_does_not_duplicate_first_admin",
"test_web.PortalApplicationTests.test_platform_tenant_authority_denial_is_redacted_and_creates_no_admin",
"test_journey_roles.PlatformAdminJourneys.test_partial_first_admin_setup_rolls_back_and_retry_finishes"
],
"remaining": "Local bootstrap is atomic and retryable; tenant creation remains delegated to its authority."
},
{
"id": "P03",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_portal_navigation.PortalNavigationTests.test_ambiguous_or_unknown_short_names_do_not_guess_a_tenant",
"test_portal_navigation.PortalNavigationTests.test_existing_tenant_is_selectable_and_short_name_resolves"
],
"remaining": ""
},
{
"id": "P04",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused"
],
"remaining": "Tenant identity recovery is scoped; verified OTP/account-ownership recovery remains provider-owned."
},
{
"id": "P05",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard"
],
"remaining": "Local delivery record operations work; approved factor credential renewal and mail receipt remain external dependencies."
},
{
"id": "P06",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_provider_handoff_rejects_unsafe_configuration"
],
"remaining": "Boundary test only. KeyCape policy suite covers optional/required/step-up; live policy management and provider rollout remain KEY-WP-0035."
},
{
"id": "P07",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_tenant_retirement_requires_confirmation_and_stale_confirmation_fails",
"test_web.PortalApplicationTests.test_platform_tenant_lifecycle_is_delegated_to_the_authority"
],
"remaining": ""
},
{
"id": "P08",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload"
],
"remaining": ""
}
]
}

View file

@ -0,0 +1,94 @@
"""Opt-in acceptance against a disposable PostgreSQL database, never production."""
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from test_web import FakeProvisioning
from user_engine.adapters import LocalAuthorizationCheckPort
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.domain import AccountStatus, User
from user_engine.errors import ConflictError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.testing.postgres_provider import postgres_provider_test_config, connect_postgres_provider, reset_user_engine_postgres_tables
from user_engine.web import PortalApplication
class PostgresJourneyTests(unittest.TestCase):
def setUp(self):
self.config,reason=postgres_provider_test_config()
if reason:self.skipTest(reason)
self.connections=[]
self.seed=self.connect()
reset_user_engine_postgres_tables(self.seed)
self.tenant='tenant:trial:concurrency'
claims=human_actor_claims(subject='operator',tenant='tenant:platform:root')
claims['roles']=['platform-operator']
self.actor=FixtureIdentityClaimsAdapter().normalize(claims)
self.provider=FakeProvisioning()
def connect(self):
connection=connect_postgres_provider(self.config.dsn)
self.connections.append(connection)
return connection
def service(self,connection):
return UserEngineService(store=PostgresUserEngineStore(connection),identity_adapter=FixtureIdentityClaimsAdapter(),authorization=LocalAuthorizationCheckPort())
def tearDown(self):
for connection in self.connections:connection.close()
def test_two_connections_cannot_disable_both_admins(self):
service=self.service(self.seed)
users=[]
for name in ['first','second']:
u=service.create_user(self.actor,display_name=name,primary_email=name+'@example.test')
service.set_tenant_account_status(self.actor,u.user_id,AccountStatus.ACTIVE,tenant=self.tenant)
service.add_membership(self.actor,u.user_id,tenant=self.tenant,scope_type='tenant',scope_id=self.tenant,kind='tenant-admin')
service.link_identity(self.actor,u.user_id,issuer='urn:netkingdom:directory',subject=name,provider='netkingdom-lldap')
users.append(u)
apps=[PortalApplication(self.service(self.connect()),trusted_proxy_secret='disposable-test-marker-only',login_url='https://test.example',provisioning=self.provider) for _ in users]
barrier=Barrier(2)
def disable(pair):
app,u=pair
barrier.wait(timeout=5)
try:
app._change_status(self.actor,self.tenant,u.user_id,AccountStatus.SUSPENDED,idempotency_key='test-'+u.user_id,correlation_id='concurrent-test')
return 'changed'
except ConflictError:return 'conflict'
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(disable,zip(apps,users)))
self.assertEqual(['changed','conflict'],sorted(results))
self.assertEqual(1,len(self.provider.actions))
self.assertEqual(1,sum(service.store.tenant_account(self.tenant,u.user_id).status==AccountStatus.ACTIVE for u in users))
def test_nested_bootstrap_rolls_back_all_local_records(self):
service=self.service(self.seed)
user_id=None
with self.assertRaises(RuntimeError):
with service.store.tenant_lifecycle_guard(self.tenant), service.store.transaction():
user=service.create_user(self.actor,display_name='Partial',primary_email='partial@example.test')
user_id=user.user_id
service.set_tenant_account_status(self.actor,user_id,AccountStatus.INVITED,tenant=self.tenant)
raise RuntimeError('failure before first-admin membership')
self.assertIsNone(service.store.user(user_id))
self.assertIsNone(service.store.tenant_account(self.tenant,user_id))
def test_guard_releases_after_exception(self):
first=PostgresUserEngineStore(self.seed)
second=PostgresUserEngineStore(self.connect())
with self.assertRaises(RuntimeError):
with first.tenant_lifecycle_guard(self.tenant):raise RuntimeError('simulated provider failure')
cursor=second.connection.cursor()
cursor.execute("SET statement_timeout = '2s'")
with second.tenant_lifecycle_guard(self.tenant):pass
cursor.close()
def test_successful_guard_does_not_commit_or_discard_caller_work(self):
first=PostgresUserEngineStore(self.seed)
second=PostgresUserEngineStore(self.connect())
u=User(user_id='pending-user',display_name='Pending')
first.save_user(u)
with first.tenant_lifecycle_guard(self.tenant):pass
self.assertIsNone(second.user(u.user_id))
self.seed.commit()
self.assertEqual(u,second.user(u.user_id))

287
tests/test_journey_roles.py Normal file
View file

@ -0,0 +1,287 @@
"""Repeatable user, tenant-admin and platform-admin acceptance journeys.
These tests exercise public WSGI routes and persisted state. Provider doubles are
explicit: they prove portal orchestration, not live OTP/mail/directory behavior.
"""
from dataclasses import replace
from html import unescape
import re
import unittest
from urllib.parse import quote
import test_portal_navigation
from test_web import invoke, invoke_confirmed, FakeProvisioning
from user_engine.domain import AccountStatus, OutboxEvent, utc_now
from user_engine.oidc import BrowserSession
from user_engine.testing.fixtures import human_actor_claims
TENANT='tenant:trial:demo-company'
OTHER='tenant:trial:other'
class JourneyFixture(unittest.TestCase):
def setUp(self):
test_portal_navigation.PortalNavigationTests.setUp(self)
self.app.provisioning=FakeProvisioning()
claims=human_actor_claims(subject='tenant-admin',tenant=TENANT)
claims['roles']=['tenant-admin']
self.oidc.sessions['admin']=BrowserSession(claims,9999999999,'admin-csrf')
self.actor=self.app.service.identity_adapter.normalize(self.oidc.claims('operator'))
def member(self, tenant=TENANT, role='user', email='person@example.test', linked=True):
user=self.app.service.create_user(self.actor,display_name='Display name',primary_email=email)
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.ACTIVE,tenant=tenant)
self.app.service.add_membership(self.actor,user.user_id,tenant=tenant,scope_type='tenant',scope_id=tenant,kind=role)
if linked:
self.app.service.link_identity(self.actor,user.user_id,issuer='urn:netkingdom:directory',
subject='uid='+email.split('@')[0]+',ou=people,dc=netkingdom,dc=local',provider='netkingdom-lldap')
return user
def post(self,path,who='admin',confirmed=False,**form):
form.setdefault('csrf_token',who+'-csrf')
call=invoke_confirmed if confirmed else invoke
return call(self.app,path,method='POST',cookie='ue_session='+who,form=form)
def confirm_token(self,body):
return unescape(re.search(rb'name="confirm_token" value="([^"]+)"',body).group(1).decode())
class UserJourneys(JourneyFixture):
def test_profile_validation_keeps_safe_input_and_retry_saves(self):
invoke(self.app,'/onboarding',cookie='ue_session=member')
session=self.app.service.me(self.oidc.claims('member'),correlation_id='before')
original=session.user.display_name
response,body=self.post('/onboarding/profile',who='member',display_name='<script>'+('x'*200),consent_accepted='yes')
self.assertEqual('400 Bad Request',response['status'])
self.assertIn(b'&lt;script&gt;',body)
self.assertNotIn(b'<script>',body)
self.assertIn(b'Your profile has not been saved',body)
self.assertEqual(original,self.app.service.store.user(session.user.user_id).display_name)
response,body=self.post('/onboarding/profile',who='member',display_name='Chosen display name',consent_accepted='yes')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Profile saved',body)
self.assertEqual('Chosen display name',self.app.service.store.user(session.user.user_id).display_name)
def test_password_handoff_names_actual_login_and_failure_can_retry(self):
user=self.member(email='actual.login@example.test',linked=False)
original=self.app.provisioning.provision
def fail(request): raise RuntimeError('private-provider-detail')
self.app.provisioning.provision=fail
path=f'/admin/{TENANT}/users/{user.user_id}/provision'
response,body=self.post(path)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertNotIn(b'private-provider-detail',body)
self.assertFalse(self.app.service.store.identities_for_user(user.user_id))
self.app.provisioning.provision=original
response,body=self.post(path)
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Login name: <strong>actual.login</strong>',body)
self.assertIn(b'may differ from the display name',body)
self.assertEqual(1,len(self.app.service.store.identities_for_user(user.user_id)))
class TenantAdminJourneys(JourneyFixture):
def test_wrong_role_or_tenant_never_calls_provider(self):
user=self.member(tenant=OTHER)
for who in ['admin','member']:
for action in ['provision','status','remove','recover']:
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/{action}',who=who,status='suspended')
self.assertIn(response['status'],['403 Forbidden','404 Not Found'])
self.assertEqual([],self.app.provisioning.requests)
self.assertEqual([],self.app.provisioning.actions)
response,_=invoke(self.app,f'/api/v1/tenants/{TENANT}/users',cookie='ue_session=member')
self.assertEqual('403 Forbidden',response['status'])
def test_confirmation_cancel_tamper_and_stale_state(self):
user=self.member();path=f'/admin/{TENANT}/users/{user.user_id}/status'
response,body=self.post(path,status='suspended')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Confirm change',body)
self.assertEqual([],self.app.provisioning.actions)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(TENANT,user.user_id).status)
token=self.confirm_token(body)
response,_=self.post(path,status='active',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.app.service.store.save_tenant_account(replace(self.app.service.store.tenant_account(TENANT,user.user_id),status=AccountStatus.INVITED))
response,_=self.post(path,status='suspended',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
def test_tenant_removal_preserves_shared_identity_and_other_account(self):
user=self.member()
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.ACTIVE,tenant=OTHER)
self.app.service.add_membership(self.actor,user.user_id,tenant=OTHER,scope_type='tenant',scope_id=OTHER,kind='user')
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/remove',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual(AccountStatus.DISABLED,self.app.service.store.tenant_account(TENANT,user.user_id).status)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(OTHER,user.user_id).status)
self.assertTrue(self.app.service.store.identities_for_user(user.user_id))
self.assertEqual([('tenant_disabled','uid=person,ou=people,dc=netkingdom,dc=local',TENANT)],self.app.provisioning.actions)
def test_last_admin_protected_then_successor_allows_transition(self):
first=self.member(role='tenant-admin',email='first@example.test')
path=f'/admin/{TENANT}/users/{first.user_id}/status'
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
self.member(role='tenant-admin',email='successor@example.test')
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual(AccountStatus.SUSPENDED,self.app.service.store.tenant_account(TENANT,first.user_id).status)
def test_provider_failure_retains_local_state_and_retry_recovers(self):
user=self.member();path=f'/admin/{TENANT}/users/{user.user_id}/status'
original=self.app.provisioning.tenant_access
def fail(**kwargs): raise RuntimeError('provider-secret')
self.app.provisioning.tenant_access=fail
response,body=self.post(path,status='suspended',confirmed=True)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertNotIn(b'provider-secret',body)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(TENANT,user.user_id).status)
self.app.provisioning.tenant_access=original
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('303 See Other',response['status'])
def test_concurrent_admin_suspensions_keep_one_active_admin(self):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
first=self.member(role='tenant-admin',email='first@example.test')
second=self.member(role='tenant-admin',email='second@example.test')
barrier=Barrier(2)
def suspend(user):
barrier.wait(timeout=5)
return invoke(self.app,f'/api/v1/tenants/{TENANT}/users/{user.user_id}',method='PATCH',
cookie='ue_session=admin',body={'status':'suspended'},headers={'HTTP_IDEMPOTENCY_KEY':'concurrent-'+user.user_id})[0]['status']
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(suspend,[first,second]))
self.assertEqual(['200 OK','409 Conflict'],sorted(results))
self.assertEqual(1,len(self.app.provisioning.actions))
self.assertEqual(1,sum(self.app.service.store.tenant_account(TENANT,u.user_id).status==AccountStatus.ACTIVE for u in [first,second]))
def test_admin_succession_promote_then_demote_with_confirmation(self):
first=self.member(role='tenant-admin',email='first@example.test')
second=self.member(email='second@example.test')
path=f'/admin/{TENANT}/users/{first.user_id}/role'
response,_=self.post(path,role='user',confirmed=True)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
response,_=self.post(f'/admin/{TENANT}/users/{second.user_id}/role',role='tenant-admin',confirmed=True)
self.assertEqual('303 See Other',response['status'])
response,_=self.post(path,role='user',confirmed=True)
self.assertEqual('303 See Other',response['status'])
roles={m.user_id:m.kind for m in self.app.service.store.memberships_for_tenant(TENANT)}
self.assertEqual('tenant-admin',roles[second.user_id]);self.assertEqual('user',roles[first.user_id])
self.assertTrue(any(r.action=='membership.write' and r.subject==first.user_id for r in self.app.service.audit_records()))
def test_disabled_account_cannot_be_reactivated_by_password_setup(self):
user=self.member()
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.DISABLED,tenant=TENANT)
path=f'/admin/{TENANT}/users/{user.user_id}/provision'
response,_=self.post(path)
self.assertEqual('409 Conflict',response['status'])
response,_=invoke(self.app,f'/api/v1/tenants/{TENANT}/users/{user.user_id}/provision',method='POST',cookie='ue_session=admin',headers={'HTTP_IDEMPOTENCY_KEY':'setup-1234567890123456'})
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.requests)
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertNotIn(b'Create password setup link',body)
self.assertIn(b'Reactivate',body)
def test_invitation_delivery_reports_queue_failure_and_adapter_acceptance(self):
response,_=self.post(f'/admin/{TENANT}/invitations',primary_email='invitee@example.test',display_name='Invitee',role='user')
self.assertEqual('303 See Other',response['status'])
event=next(e for e in self.app.service.store.outbox_history() if e.event_type=='family_member.invited')
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Queued for delivery',body)
self.app.service.store.save_outbox(replace(event,failed_at=utc_now(),failure_reason='secret detail'))
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Delivery failed',body);self.assertNotIn(b'secret detail',body)
self.app.service.store.save_outbox(replace(event,delivered_at=utc_now()))
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Accepted by delivery adapter; receipt by the person is unverified',body)
def test_invalid_role_cannot_create_partial_account(self):
before=len(self.app.service.store.users)
response,_=self.post(f'/admin/{TENANT}/users',display_name='Wrong role',primary_email='wrong@example.test',role='platform-operator')
self.assertEqual('400 Bad Request',response['status'])
self.assertEqual(before,len(self.app.service.store.users))
def test_audit_is_tenant_scoped_and_never_dumps_payload(self):
self.member();self.member(tenant=OTHER,email='private@example.test')
response,body=invoke(self.app,f'/admin/{TENANT}/activity',cookie='ue_session=admin')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Support reference',body)
self.assertNotIn(OTHER.encode(),body)
self.assertNotIn(b'private@example.test',body)
response,_=invoke(self.app,f'/admin/{OTHER}/activity',cookie='ue_session=admin')
self.assertEqual('403 Forbidden',response['status'])
class PlatformAdminJourneys(JourneyFixture):
def test_delivery_denial_redaction_retry_and_completed_guard(self):
event=OutboxEvent(event_id='delivery-test',event_type='family_invitation.created',aggregate_id='invitation',
tenant=TENANT,correlation_id='support-reference',payload={'private':'payload-secret'},
failed_at=utc_now(),failure_reason='credential-secret',dead_lettered_at=utc_now())
self.app.service.store.append_outbox(event)
for who in ['admin','member']:
response,_=invoke(self.app,'/platform/operations',cookie='ue_session='+who)
self.assertEqual('403 Forbidden',response['status'])
response,body=invoke(self.app,'/platform/operations',cookie='ue_session=operator',query='event_id=delivery-test')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Delivery stopped',body)
self.assertIn(b'Queue a retry',body)
for secret in [b'payload-secret',b'credential-secret']:self.assertNotIn(secret,body)
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id,csrf_token='wrong')
self.assertEqual('403 Forbidden',response['status'])
self.assertIsNotNone(self.app.service.store.outbox_event(event.event_id).failed_at)
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id)
self.assertEqual('303 See Other',response['status'])
queued=self.app.service.store.outbox_event(event.event_id)
self.assertIsNone(queued.failed_at);self.assertIsNone(queued.dead_lettered_at)
self.app.service.store.save_outbox(replace(queued,delivered_at=utc_now()))
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id)
self.assertEqual('409 Conflict',response['status'])
def test_partial_first_admin_setup_rolls_back_and_retry_finishes(self):
original=self.app.service.add_membership
before=len(self.app.service.store.users)
def fail(*args,**kwargs):raise RuntimeError('temporary membership failure')
self.app.service.add_membership=fail
form=dict(tenant='tenant:trial:new',display_name='New tenant',admin_email='first@example.test',admin_display_name='First')
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertEqual(before,len(self.app.service.store.users))
self.assertIn(form['tenant'],self.app.tenant_management.records)
self.app.service.add_membership=original
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('200 OK',response['status'])
self.assertEqual(1,len(self.app.service.store.memberships_for_tenant(form['tenant'])))
def test_bootstrap_retry_does_not_duplicate_first_admin(self):
form=dict(tenant='tenant:trial:new',display_name='New tenant',admin_email='first@example.test',admin_display_name='First')
for _ in range(2):
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('200 OK',response['status'])
memberships=self.app.service.store.memberships_for_tenant(form['tenant'])
self.assertEqual(1,len(memberships))
self.assertEqual('tenant-admin',memberships[0].kind)
def test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused(self):
user=self.member()
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/recover',who='operator',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual('tenant_active',self.app.provisioning.actions[-1][0])
response,_=invoke(self.app,f'/api/v1/platform/tenants/{TENANT}/users/{user.user_id}/recover',
method='POST',cookie='ue_session=operator',headers={'HTTP_IDEMPOTENCY_KEY':'recover-1234567890'})
self.assertEqual('200 OK',response['status'])
self.assertTrue(all(a[0]=='tenant_active' for a in self.app.provisioning.actions))
def test_tenant_retirement_requires_confirmation_and_stale_confirmation_fails(self):
authority=self.app.tenant_management
authority.create_tenant(tenant=TENANT,display_name='Demo',idempotency_key='seed',correlation_id='seed')
path='/platform/tenants/'+quote(TENANT,safe='')
response,body=self.post(path,who='operator',operation='retire',version='1',reason='contract ended')
self.assertEqual('active',authority.records[TENANT].lifecycle)
token=self.confirm_token(body)
authority.records[TENANT]=replace(authority.records[TENANT],version=2)
response,_=self.post(path,who='operator',operation='retire',version='1',reason='contract ended',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual('active',authority.records[TENANT].lifecycle)
response,_=self.post(path,who='operator',operation='retire',version='2',reason='contract ended',confirmed=True)
self.assertEqual('200 OK',response['status'])
self.assertEqual('retired',authority.records[TENANT].lifecycle)

View file

@ -97,6 +97,10 @@ class ProviderPostgresConformanceTests(unittest.TestCase):
def _store_factory(self) -> PostgresUserEngineStore: def _store_factory(self) -> PostgresUserEngineStore:
assert self.config is not None assert self.config is not None
# This factory deliberately resets the shared disposable database. End
# earlier read transactions before rerunning DDL on a new connection.
for existing in self.connections:
existing.commit()
connection = connect_postgres_provider(self.config.dsn) connection = connect_postgres_provider(self.config.dsn)
reset_user_engine_postgres_tables(connection) reset_user_engine_postgres_tables(connection)
self.connections.append(connection) self.connections.append(connection)

View file

@ -88,6 +88,7 @@ class PortalApplicationTests(unittest.TestCase):
login_url="https://kc.example/login", login_url="https://kc.example/login",
) )
self.claims = human_actor_claims(tenant="tenant:friendly:binky") self.claims = human_actor_claims(tenant="tenant:friendly:binky")
self.claims["roles"] = ["tenant-admin"]
def platform_claims(self): def platform_claims(self):
claims = human_actor_claims(subject="platform-operator", tenant="platform:root") claims = human_actor_claims(subject="platform-operator", tenant="platform:root")
@ -521,7 +522,7 @@ class PortalApplicationTests(unittest.TestCase):
body={ body={
"display_name": "Ada Admin", "display_name": "Ada Admin",
"primary_email": "ada@example.test", "primary_email": "ada@example.test",
"role": "tenant-admin", "role": "user",
}, },
) )
self.assertEqual("201 Created", created["status"]) self.assertEqual("201 Created", created["status"])
@ -550,7 +551,7 @@ class PortalApplicationTests(unittest.TestCase):
) )
self.assertEqual("200 OK", changed["status"]) self.assertEqual("200 OK", changed["status"])
self.assertEqual("suspended", json.loads(payload)["status"]) self.assertEqual("suspended", json.loads(payload)["status"])
self.assertIn(("suspend", "ada"), self.app.provisioning.actions) self.assertIn(("tenant_disabled", "ada", "tenant:friendly:binky"), self.app.provisioning.actions)
removed, payload = invoke_with_idempotency( removed, payload = invoke_with_idempotency(
self.app, self.app,
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}", f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}",
@ -558,7 +559,7 @@ class PortalApplicationTests(unittest.TestCase):
) )
self.assertEqual("200 OK", removed["status"]) self.assertEqual("200 OK", removed["status"])
self.assertEqual("removed", json.loads(payload)["status"]) self.assertEqual("removed", json.loads(payload)["status"])
self.assertIn(("deprovision", "ada"), self.app.provisioning.actions) self.assertIn(("tenant_disabled", "ada", "tenant:friendly:binky"), self.app.provisioning.actions)
def test_invitation_lifecycle_is_versioned_and_replay_safe(self): def test_invitation_lifecycle_is_versioned_and_replay_safe(self):
created, payload = invoke( created, payload = invoke(
@ -874,7 +875,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", renamed["status"]) self.assertEqual("200 OK", renamed["status"])
self.assertIn(b"Renamed In Browser", html) self.assertIn(b"Renamed In Browser", html)
retired, html = invoke( retired, html = invoke_confirmed(
self.app, f"/platform/tenants/{quoted}", method="POST", self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={ cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire", "csrf_token": "platform-csrf", "operation": "retire",
@ -886,7 +887,7 @@ class PortalApplicationTests(unittest.TestCase):
# A retired tenant offers no metadata form, matching the authority. # A retired tenant offers no metadata form, matching the authority.
self.assertNotIn(b"Save metadata", html) self.assertNotIn(b"Save metadata", html)
resubmitted, html = invoke( resubmitted, html = invoke_confirmed(
self.app, f"/platform/tenants/{quoted}", method="POST", self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={ cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire", "csrf_token": "platform-csrf", "operation": "retire",
@ -976,7 +977,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", admin_page["status"]) self.assertEqual("200 OK", admin_page["status"])
self.assertIn(b"Lifecycle diagnostics", html) self.assertIn(b"Lifecycle diagnostics", html)
self.assertIn(b"Recover identity", html) self.assertIn(b"Recover identity", html)
recovered, _ = invoke( recovered, _ = invoke_confirmed(
self.app, self.app,
f"/admin/tenant:friendly:browser/users/{memberships[0].user_id}/recover", f"/admin/tenant:friendly:browser/users/{memberships[0].user_id}/recover",
method="POST", cookie="ue_session=platform", method="POST", cookie="ue_session=platform",
@ -1150,7 +1151,7 @@ class PortalApplicationTests(unittest.TestCase):
"consent_accepted": "yes", "consent_accepted": "yes",
}, },
) )
self.assertEqual("303 See Other", saved["status"]) self.assertEqual("200 OK", saved["status"])
user = self.app.service.store.user(invitation.user_id) user = self.app.service.store.user(invitation.user_id)
self.assertEqual("Updated Invitee", user.display_name) self.assertEqual("Updated Invitee", user.display_name)
self.assertEqual("invitee@example.test", user.primary_email) self.assertEqual("invitee@example.test", user.primary_email)
@ -1202,6 +1203,10 @@ class FakeProvisioning:
password_setup_url=self.password_setup_url, password_setup_url=self.password_setup_url,
) )
def tenant_access(self, *, external_subject, tenant, roles, enabled, idempotency_key, correlation_id):
self.actions.append(("tenant_active" if enabled else "tenant_disabled", external_subject, tenant))
return ProvisioningResult("netkingdom-lldap", external_subject, "tenant_active" if enabled else "tenant_disabled")
def suspend(self, *, external_subject, idempotency_key, correlation_id): def suspend(self, *, external_subject, idempotency_key, correlation_id):
self.actions.append(("suspend", external_subject)) self.actions.append(("suspend", external_subject))
return ProvisioningResult("netkingdom-lldap", external_subject, "suspended") return ProvisioningResult("netkingdom-lldap", external_subject, "suspended")
@ -1340,3 +1345,13 @@ def invoke_with_idempotency(app, path, claims, *, method="POST", body=None):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
def invoke_confirmed(app, path, **kwargs):
from html import unescape
response, body = invoke(app, path, **kwargs)
token = re.search(rb'name="confirm_token" value="([^"]+)"', body)
if token:
kwargs['form'] = dict(kwargs['form'], confirm_token=unescape(token.group(1).decode()))
return invoke(app, path, **kwargs)
return response, body

View file

@ -104,3 +104,8 @@ platform operator, expired and wrong-tenant identities. Include invitation/mail,
actual OTP, shared sign-out, keyboard/mobile, form preservation, and authoritative actual OTP, shared sign-out, keyboard/mobile, form preservation, and authoritative
application access (USER-WP-0026-T03). Preserve VERGABE-WP-0019 real-user acceptance. application access (USER-WP-0026-T03). Preserve VERGABE-WP-0019 real-user acceptance.
Record live evidence and hand off any residuals as live work before closing. Record live evidence and hand off any residuals as live work before closing.
Implementation is split into USER-WP-0028 (user), USER-WP-0029 (tenant admin),
USER-WP-0030 (platform admin), and USER-WP-0031 (automated acceptance). These
are live workplans, not residuals parked only in the journey document.

View file

@ -0,0 +1,51 @@
---
id: USER-WP-0028
type: workplan
title: "User account journeys and recovery"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-13"
updated: "2026-09-13"
---
Implements docs/account-journeys.md under USER-WP-0027. A passing local
suite is not evidence that an external provider flow works live.
## Complete self-service feedback and safe retries
```task
id: USER-WP-0028-T01
status: done
priority: high
```
U01U04/U10U13: preserve safe profile input on validation failure; confirm saves, expose actual login name at password handoff, keep invitation/expired/session recovery reachable. Cover success, denial, provider interruption and retry.
## Connect authoritative application access
```task
id: USER-WP-0028-T02
status: todo
priority: high
```
U09 and T05: integrate a supported catalogue/admission source and scoped grants/revocation. Do not present static links or membership as effective authorization. Continues USER-WP-0026-T03; establish provider contract before deployment.
## Complete optional OTP onboarding with provider evidence
```task
id: USER-WP-0028-T03
status: wait
priority: high
```
U05U08: depends on KEY-WP-0035 and NK-WP-0033 credential custody. Verify enrollment/cancel/replace/lost-factor recovery and old-AAL1-session behavior before enabling live handoff. No stub acceptance.
Validation: 210 database-enabled regression tests passed with no skips,
including independent-connection last-admin protection and nested bootstrap
rollback. Thirteen isolated Chromium checks passed. Provider OTP and application
access integration remain explicitly open; no complete-journey claim is inferred.

View file

@ -0,0 +1,51 @@
---
id: USER-WP-0029
type: workplan
title: "Tenant administrator journeys and lifecycle isolation"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-13"
updated: "2026-09-13"
---
Implements docs/account-journeys.md under USER-WP-0027. A passing local
suite is not evidence that an external provider flow works live.
## Authorize target before provider calls
```task
id: USER-WP-0029-T01
status: done
priority: high
```
T01/T03/T04/T06: reject non-admin and wrong-tenant user IDs before provisioning/recovery/lifecycle calls; bind invitation operations to route tenant; protect shared identities from global tenant-scoped mutation.
## Confirm lifecycle changes and protect admin succession
```task
id: USER-WP-0029-T02
status: done
priority: high
```
T06/T08: preview target/scope/action, CSRF-bound confirmation, reject stale state and last-admin removal, provide cancel and recovery. Keep domain/API guards as well as UI checks. Document concurrency/external propagation limits.
## Explain onboarding and delivery state
```task
id: USER-WP-0029-T03
status: done
priority: high
```
T02/T03/T04/T07: show actual directory login separately from display name, account state, provider uncertainty, invitation delivery status and appropriate next steps; prevent duplicate rows from workload memberships.
Validation: 210 database-enabled regression tests passed with no skips,
including independent-connection last-admin protection and nested bootstrap
rollback. Thirteen isolated Chromium checks passed. Provider OTP and application
access integration remain explicitly open; no complete-journey claim is inferred.

View file

@ -0,0 +1,51 @@
---
id: USER-WP-0030
type: workplan
title: "Platform administration and operational recovery"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-13"
updated: "2026-09-13"
---
Implements docs/account-journeys.md under USER-WP-0027. A passing local
suite is not evidence that an external provider flow works live.
## Expose scoped audit and delivery recovery
```task
id: USER-WP-0030-T01
status: done
priority: high
```
P01/P03/P05/P08/T08: authorized browser views of safe audit metadata and delivery state, reference filtering, CSRF replay with readback, no raw payload/credential/error dumps.
## Make tenant lifecycle and bootstrap recoverable
```task
id: USER-WP-0030-T02
status: done
priority: high
```
P02/P04/P07: preserve first-admin setup on retry without duplication, explicit tenant lifecycle confirmation, stale-version recovery and validation; verify cross-role isolation.
## Integrate provider policy and credential operations
```task
id: USER-WP-0030-T03
status: wait
priority: high
```
P04P06: owner-approved factor credential delivery/renewal and provider recovery/policy control. Depends on KEY-WP-0035/NK-WP-0033; no secret vending through portal or chat.
Validation: 210 database-enabled regression tests passed with no skips,
including independent-connection last-admin protection and nested bootstrap
rollback. Thirteen isolated Chromium checks passed. Provider OTP and application
access integration remain explicitly open; no complete-journey claim is inferred.

View file

@ -0,0 +1,51 @@
---
id: USER-WP-0031
type: workplan
title: "Automated user and administrator journey suites"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-13"
updated: "2026-09-13"
---
Implements docs/account-journeys.md under USER-WP-0027. A passing local
suite is not evidence that an external provider flow works live.
## Add repeatable suites for each role
```task
id: USER-WP-0031-T01
status: done
priority: high
```
Run synthetic identities through actual WSGI routes and domain stores, with controlled provider failure/retry fixtures. Assert success, denial, unchanged state on failure, recovery and tenant isolation. Add make test-journeys and CI execution.
## Maintain machine-readable coverage for all 29 journeys
```task
id: USER-WP-0031-T02
status: done
priority: high
```
Map every journey to real tests and explicit external acceptance blockers. Report missing live acceptance as incomplete, never a passing placeholder. Keep matrix/workplans synchronized.
## Publish and verify implemented journeys
```task
id: USER-WP-0031-T03
status: progress
priority: high
```
Run full unit/conformance suites and live read-only smoke, deploy digest-pinned reviewed source with CAS and rollback evidence. Retain provider-dependent journeys as live tasks until external acceptance passes.
Validation: 210 database-enabled regression tests passed with no skips,
including independent-connection last-admin protection and nested bootstrap
rollback. Thirteen isolated Chromium checks passed. Provider OTP and application
access integration remain explicitly open; no complete-journey claim is inferred.