From 1127f852dde8b247bcc7a7f0caff805f25c5f37e Mon Sep 17 00:00:00 2001
From: tegwick
Date: Sun, 13 Sep 2026 12:20:02 +0200
Subject: [PATCH] Implement role-based account journeys with database and
browser acceptance suites
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
---
.forgejo/workflows/journeys.yaml | 20 +
Makefile | 11 +
docs/account-journeys.md | 14 +
docs/journey-test-suites.md | 50 +++
scripts/browser_journeys.mjs | 51 +++
scripts/browser_journeys.py | 47 ++
scripts/run_journey_suites.py | 39 ++
src/user_engine/adapters/local.py | 12 +
src/user_engine/adapters/postgres.py | 49 ++-
src/user_engine/adapters/provisioning.py | 8 +
src/user_engine/ports.py | 12 +
src/user_engine/service.py | 66 ++-
src/user_engine/testing/postgres_provider.py | 2 +-
src/user_engine/web.py | 407 ++++++++++++------
tests/journey-coverage.json | 291 +++++++++++++
tests/test_journey_postgres.py | 94 ++++
tests/test_journey_roles.py | 287 ++++++++++++
tests/test_postgres_provider_conformance.py | 4 +
tests/test_web.py | 29 +-
.../USER-WP-0027-account-journey-clarity.md | 5 +
.../USER-WP-0028-user-journey-acceptance.md | 51 +++
.../USER-WP-0029-tenant-admin-journeys.md | 51 +++
.../USER-WP-0030-platform-admin-journeys.md | 51 +++
.../USER-WP-0031-automated-journey-suites.md | 51 +++
24 files changed, 1554 insertions(+), 148 deletions(-)
create mode 100644 .forgejo/workflows/journeys.yaml
create mode 100644 docs/journey-test-suites.md
create mode 100644 scripts/browser_journeys.mjs
create mode 100644 scripts/browser_journeys.py
create mode 100644 scripts/run_journey_suites.py
create mode 100644 tests/journey-coverage.json
create mode 100644 tests/test_journey_postgres.py
create mode 100644 tests/test_journey_roles.py
create mode 100644 workplans/USER-WP-0028-user-journey-acceptance.md
create mode 100644 workplans/USER-WP-0029-tenant-admin-journeys.md
create mode 100644 workplans/USER-WP-0030-platform-admin-journeys.md
create mode 100644 workplans/USER-WP-0031-automated-journey-suites.md
diff --git a/.forgejo/workflows/journeys.yaml b/.forgejo/workflows/journeys.yaml
new file mode 100644
index 0000000..204ce5d
--- /dev/null
+++ b/.forgejo/workflows/journeys.yaml
@@ -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
diff --git a/Makefile b/Makefile
index fa9bc65..6d22e4e 100644
--- a/Makefile
+++ b/Makefile
@@ -15,3 +15,14 @@ test-conformance: test-unit check-layer
check-layer:
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
diff --git a/docs/account-journeys.md b/docs/account-journeys.md
index 6bb94b4..3c9ead2 100644
--- a/docs/account-journeys.md
+++ b/docs/account-journeys.md
@@ -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
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
USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks
diff --git a/docs/journey-test-suites.md b/docs/journey-test-suites.md
new file mode 100644
index 0000000..b6c4476
--- /dev/null
+++ b/docs/journey-test-suites.md
@@ -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.
diff --git a/scripts/browser_journeys.mjs b/scripts/browser_journeys.mjs
new file mode 100644
index 0000000..fa6847b
--- /dev/null
+++ b/scripts/browser_journeys.mjs
@@ -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();}
diff --git a/scripts/browser_journeys.py b/scripts/browser_journeys.py
new file mode 100644
index 0000000..381e6c8
--- /dev/null
+++ b/scripts/browser_journeys.py
@@ -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)
diff --git a/scripts/run_journey_suites.py b/scripts/run_journey_suites.py
new file mode 100644
index 0000000..5768e9b
--- /dev/null
+++ b/scripts/run_journey_suites.py
@@ -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)
diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py
index dbb7ca6..927d652 100644
--- a/src/user_engine/adapters/local.py
+++ b/src/user_engine/adapters/local.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import copy
import os
+from threading import RLock
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Mapping, cast
@@ -78,6 +79,17 @@ class InMemoryUserEngineStore:
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:
"""Apply the standalone schema migration manifest."""
self.schema_version = SCHEMA_VERSION
diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py
index c76f243..075946e 100644
--- a/src/user_engine/adapters/postgres.py
+++ b/src/user_engine/adapters/postgres.py
@@ -8,6 +8,7 @@ pooling, securing, and observing those connections.
from __future__ import annotations
import json
+from threading import RLock
from contextlib import contextmanager
from importlib.resources import files
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
@@ -80,6 +81,32 @@ class PostgresUserEngineStore:
def __init__(self, connection: PostgresConnection) -> None:
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
def schema_version(self) -> str | None:
@@ -97,16 +124,26 @@ class PostgresUserEngineStore:
@contextmanager
def transaction(self) -> Iterator[None]:
- begin = getattr(self.connection, "begin", None)
- if callable(begin):
- begin()
+ outer = self._transaction_depth == 0
+ if outer:
+ self._transaction_failed = False
+ begin = getattr(self.connection, "begin", None)
+ if callable(begin): begin()
+ self._transaction_depth += 1
try:
yield
- except Exception:
- self.connection.rollback()
+ except BaseException:
+ self._transaction_failed = True
+ if outer: self.connection.rollback()
raise
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:
self._upsert_record(user)
diff --git a/src/user_engine/adapters/provisioning.py b/src/user_engine/adapters/provisioning.py
index 237ae41..2532c60 100644
--- a/src/user_engine/adapters/provisioning.py
+++ b/src/user_engine/adapters/provisioning.py
@@ -39,6 +39,14 @@ class HTTPIdentityProvisioningAdapter:
"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:
return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id)
diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py
index 9ceef94..67b8565 100644
--- a/src/user_engine/ports.py
+++ b/src/user_engine/ports.py
@@ -175,6 +175,12 @@ class IdentityProvisioningPort(Protocol):
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
"""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(
self, *, external_subject: str, idempotency_key: str, correlation_id: str
) -> ProvisioningResult:
@@ -422,6 +428,12 @@ class UserEngineStore(Protocol):
def append_outbox(self, event: OutboxEvent) -> None:
"""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, ...]:
"""Return pending outbox events in write order."""
diff --git a/src/user_engine/service.py b/src/user_engine/service.py
index 01e6379..a945fa3 100644
--- a/src/user_engine/service.py
+++ b/src/user_engine/service.py
@@ -1837,7 +1837,69 @@ class UserEngineService:
)
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,
actor: Actor,
user_id: str,
@@ -1863,6 +1925,8 @@ class UserEngineService:
)
updated = replace(account, status=status)
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._record_mutation(
actor,
diff --git a/src/user_engine/testing/postgres_provider.py b/src/user_engine/testing/postgres_provider.py
index 3572a9d..c9f5074 100644
--- a/src/user_engine/testing/postgres_provider.py
+++ b/src/user_engine/testing/postgres_provider.py
@@ -30,7 +30,7 @@ def postgres_provider_test_config(
environ: Mapping[str, str] | None = None,
) -> tuple[PostgresProviderTestConfig | None, str | None]:
"""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()
if not dsn:
return None, f"{POSTGRES_TEST_DSN_ENV} is not set"
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index 5cac56b..fdde662 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -13,6 +13,7 @@ from contextvars import ContextVar
from dataclasses import asdict, is_dataclass, replace
from enum import Enum
from html import escape
+import time
import hashlib
import hmac
import json
@@ -349,6 +350,8 @@ class PortalApplication:
actor = self._actor(environ)
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":
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":
@@ -383,13 +386,25 @@ class PortalApplication:
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.service.me(self._claims(environ), correlation_id=correlation_id)
- self.service.update_self_service_profile(
- actor, display_name=str(body.get("display_name", "")),
- consent_accepted=body.get("consent_accepted") == "yes",
- consent_version="portal-terms-v1",
- correlation_id=correlation_id,
- )
- return self._redirect(start_response, "/onboarding", correlation_id)
+ try:
+ self.service.update_self_service_profile(
+ actor, display_name=str(body.get("display_name", "")),
+ consent_accepted=body.get("consent_accepted") == "yes",
+ consent_version="portal-terms-v1", correlation_id=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'''Check your profile
+Enter a display name of 1 to 200 characters. Your profile has not been saved.
+Cancel
''')
+ return self._html(start_response, page, correlation_id, status="400 Bad Request")
+ return self._html(start_response, self._page_html("Profile saved",
+ 'Profile saved Your profile changes have been saved.
Return to my account
'), correlation_id)
if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
@@ -511,6 +526,7 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
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)
user = self.service.store.user(user_id)
if user is None:
@@ -531,10 +547,9 @@ class PortalApplication:
)
recovery = {"status": provisioned.status, "changed": ("identity",)}
else:
- reconciled = self.provisioning.reconcile(
- request, external_subject=identity.subject, desired_status="active"
- )
- recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed}
+ self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
+ idempotency_key=idempotency_key, correlation_id=correlation_id)
+ recovery = {"status": "tenant_active", "changed": ("tenant_access",)}
account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
correlation_id=correlation_id,
@@ -654,7 +669,8 @@ class PortalApplication:
if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST":
parts = path.split("/")
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)
if action == "resend":
value = self.service.resend_family_invitation(
@@ -674,10 +690,11 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
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)
if user is None:
raise NotFoundError("user not found")
+ self._require_setup_access(tenant, user_id)
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
@@ -730,21 +747,29 @@ class PortalApplication:
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
- 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=idempotency_key,
- correlation_id=correlation_id,
- )
- account = self.service.set_tenant_account_status(
- actor, user_id, AccountStatus.DISABLED,
- tenant=tenant, correlation_id=correlation_id,
- )
+ account = self._change_status(actor, tenant, user_id, AccountStatus.DISABLED,
+ idempotency_key=idempotency_key, correlation_id=correlation_id)
return self._json(start_response, "200 OK", {
"status": "removed", "tenant_account": _jsonable(account),
- "provider_identity_removed": identity is not None,
+ "provider_identity_removed": False,
}, 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":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
return self._html(
@@ -764,19 +789,26 @@ class PortalApplication:
idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id,
)
email = str(body.get("admin_email", ""))
- if email:
- user = self.service.create_user(
- actor, display_name=body.get("admin_display_name"),
- primary_email=email, 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,
- )
+ with self.service.store.tenant_lifecycle_guard(tenant), self.service.store.transaction():
+ existing_admin = any(
+ m.scope_type == "tenant" and m.scope_id == tenant and m.kind == "tenant-admin"
+ and (u := self.service.store.user(m.user_id)) is not None
+ and (u.primary_email or "").casefold() == email.casefold()
+ for m in self.service.store.memberships_for_tenant(tenant)
+ ) if email else False
+ if email and not existing_admin:
+ user = self.service.create_user(
+ actor, display_name=body.get("admin_display_name"),
+ primary_email=email, 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(
start_response,
self._platform_result(result, tenant, bool(email)), correlation_id,
@@ -834,6 +866,13 @@ class PortalApplication:
version = str(body.get("version", ""))
if not version.isdigit():
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 = {
key: str(body[key]) for key in ("display_name", "contact_email")
if str(body.get(key, "")).strip()
@@ -853,8 +892,13 @@ class PortalApplication:
correlation_id,
)
if path.startswith("/admin/") and method == "GET":
- tenant = path.split("/")[2]
- self.service.resolve_tenant_context(actor, tenant)
+ tenant = unquote(path.split("/")[2])
+ 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)
invitations = self.service.store.family_invitations_for_tenant(tenant)
diagnostics = self.service.tenant_diagnostics(
@@ -870,10 +914,23 @@ class PortalApplication:
)
if path.startswith("/admin/") and method == "POST":
parts = path.split("/")
- tenant = parts[2]
- self.service.resolve_tenant_context(actor, tenant)
+ tenant = unquote(parts[2])
+ 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)
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":
user = self.service.create_user(
actor,
@@ -904,6 +961,7 @@ class PortalApplication:
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "invitations":
invitation_id, action = parts[4], parts[5]
+ self._require_invitation_tenant(invitation_id, tenant)
version = int(body.get("version", "0"))
if action == "resend":
self.service.resend_family_invitation(
@@ -925,6 +983,7 @@ class PortalApplication:
user = self.service.store.user(user_id)
if user is None:
raise NotFoundError("user not found")
+ self._require_setup_access(tenant, user_id)
result = self.provisioning.provision(ProvisioningRequest(
user_id=user.user_id,
tenant=tenant,
@@ -947,12 +1006,29 @@ class PortalApplication:
return self._html(
start_response,
self._password_setup_handoff(
- result.password_setup_url, tenant
+ result.password_setup_url, tenant, result.external_subject
),
correlation_id,
)
query = urlencode({"provisioned": user.user_id, "status": result.status})
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":
status = AccountStatus(str(body.get("status", "")))
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
@@ -964,20 +1040,8 @@ class PortalApplication:
)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove":
- if self.provisioning is None:
- raise ValidationError("identity provisioning is unavailable")
- 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,
- )
+ self._change_status(actor, tenant, parts[4], AccountStatus.DISABLED,
+ idempotency_key=f"portal-remove-{tenant}-{parts[4]}", 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] == "recover":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
@@ -1003,9 +1067,8 @@ class PortalApplication:
correlation_id=correlation_id,
)
else:
- self.provisioning.reconcile(
- request, external_subject=identity.subject, desired_status="active"
- )
+ self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
+ idempotency_key=request.idempotency_key, correlation_id=correlation_id)
self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE,
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._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,
actor: Any,
tenant: str,
@@ -1025,34 +1092,103 @@ class PortalApplication:
) -> Any:
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
- self.service.resolve_tenant_context(actor, tenant)
- identity = next(
- (
- item for item in self.service.store.identities_for_user(user_id)
- if item.provider == "netkingdom-lldap"
- ),
- None,
- )
- if identity is None:
- raise ValidationError("user has no managed login identity")
- if status == AccountStatus.SUSPENDED:
- self.provisioning.suspend(
- external_subject=identity.subject,
- idempotency_key=idempotency_key,
- correlation_id=correlation_id,
+ self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
+ if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
+ raise ValidationError("unsupported tenant account status")
+ if status != AccountStatus.ACTIVE:
+ self.service.require_admin_successor(user_id, tenant=tenant)
+ identity = next((item for item in self.service.store.identities_for_user(user_id)
+ if item.provider == "netkingdom-lldap"), None)
+ if identity is not None:
+ if not callable(getattr(self.provisioning, "tenant_access", None)):
+ raise ValidationError("Tenant-scoped identity changes are unavailable. No shared login was changed.")
+ result = self.provisioning.tenant_access(
+ external_subject=identity.subject, tenant=tenant,
+ roles=tuple(m.kind for m in self.service.store.memberships_for_user(user_id, tenant=tenant)
+ if m.scope_type == "tenant" and m.scope_id == tenant),
+ enabled=status == AccountStatus.ACTIVE,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
)
- elif status == AccountStatus.ACTIVE:
- self.provisioning.reactivate(
- external_subject=identity.subject,
- idempotency_key=idempotency_key,
- correlation_id=correlation_id,
- )
- else:
- raise ValidationError("provider lifecycle supports active or suspended")
+ expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled"
+ if result.status != expected:
+ raise RuntimeError("tenant access change not confirmed")
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' ' for k,v in fields.items())
+ details = "".join(f'{escape(k.replace("_", " "))}: {escape(v)} ' for k,v in fields.items() if k in {"status", "operation", "reason", "version", "role"})
+ page = self._page_html(title, f'{escape(title)}? {escape(explanation)}
'
+ f'Cancel without changes
')
+ 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'{escape(r.recorded_at.isoformat())} {escape(r.action)} {escape(r.actor.preferred_username or r.actor.subject)} {escape(r.correlation_id)} ' for r in reversed(records))
+ return self._page_html("Account activity", f'Account activity Tenant: {escape(tenant)}. Most recent 100 recorded actions. A recorded request is not proof of delivery or effective application access.
'
+ 'Time Action Actor Support reference '
+ + (rows or 'No recorded activity yet. ') + '
'
+ f'Return to tenant administration
')
+
+ @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''
+ rows += f'{escape(event.event_id)} {escape(event.tenant)} {escape(event.event_type)} {escape(self._delivery_status(event))} {escape(event.correlation_id)} {action} '
+ return self._page_html("Service recovery", 'Service recovery This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.
'
+ ''
+ 'Delivery Tenant Kind Status Support reference Recovery '
+ + (rows or 'No delivery records. This does not prove mail was received. ')
+ + '
Queued retries are processed by the delivery worker. Check the record again for the result.
Return to platform administration
')
+
+ 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]:
if self.oidc_client is not None:
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.
str:
rows = "".join(
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 'No members yet. '
invitation_rows = "".join(
self._invitation_admin_row(tenant, item, csrf_token)
for item in invitations
) or 'No invitations yet. '
+ 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'{escape(name)}: {escape(journey.status.value.replace("_", " "))}. {escape(steps)}'
+ 'Ask the person to open My account for profile steps, or use sign-in help for provider steps.
')
+ progress = "".join(progress_items) or "No additional onboarding journeys are recorded. "
diagnostic_items = "".join(
f"{escape(item.replace('_', ' '))} " for item in diagnostics.issues
) or "No lifecycle gaps detected. "
return self._page_html(
f"{tenant} users",
f"""{escape(tenant)} users
+Account activity and support references · Sign-in recovery help
Invitations Email Role Status Expires Action {invitation_rows}
-Lifecycle diagnostics Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.
+Lifecycle diagnostics Check the account and invitation states below. Password and authenticator status are not available here.
+Onboarding follow-up These are recorded workflow states. Missing password or authenticator evidence is not proof of completion.
Members User Email Role Status Directory Action {rows}
""",
)
@@ -1737,51 +1884,39 @@ Use the login name they provide; it may differ from your display name.
Expire """
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 (
f"{escape(invitation.primary_email)} {escape(invitation.role)} "
- f"{escape(invitation.status.value)} {escape(expires)} {actions} "
+ f"{escape(invitation.status.value)}{escape(delivery)}
{escape(expires)} {actions} "
)
- def _admin_row(
- self, tenant: str, membership: Any,
- platform_operator: bool, csrf_token: str,
- ) -> str:
+ def _admin_row(self, tenant: str, membership: Any, platform_operator: bool, csrf_token: str) -> str:
user = self.service.store.user(membership.user_id)
- identities = self.service.store.identities_for_user(membership.user_id)
- directory = next(
- (item for item in identities if item.provider == "netkingdom-lldap"),
- None,
- )
- tenant_account = self.service.store.tenant_account(tenant, membership.user_id)
- status = tenant_account.status if tenant_account else AccountStatus.INVITED
- action = (
- f"""Linked as {escape(directory.subject)}
-
-"""
- if directory
- else f""""""
- )
- action += f""""""
- if platform_operator:
- action += f""""""
- return (
- f"{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)} "
- f"{escape(user.primary_email or '') if user else ''} "
- f"{escape(membership.kind)} "
- f"{escape(status.value)} "
- f"{'linked' if directory else 'pending'} {action} "
- )
+ directory = next((i for i in self.service.store.identities_for_user(membership.user_id)
+ if i.provider == "netkingdom-lldap"), None)
+ account = self.service.store.tenant_account(tenant, membership.user_id)
+ status = account.status if account else AccountStatus.INVITED
+ inactive = status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}
+ root = f"/admin/{quote(tenant, safe='')}/users/{quote(membership.user_id, safe='')}"
+ def form(action: str, label: str, **fields: str) -> str:
+ hidden = "".join(f' ' for k,v in fields.items())
+ return f''
+ login = (f'Login name: {escape(self._directory_login(directory.subject))}
'
+ 'Password and authenticator status are not available here.
') if directory else 'Login not created. Prepare a login before asking this person to sign in.
'
+ actions = ""
+ if not inactive:
+ actions += form("provision", "Create password setup link" if directory else "Create login")
+ actions += form("status", "Reactivate" if inactive else "Suspend", status="active" if inactive else "suspended")
+ if status != AccountStatus.DISABLED:
+ actions += form("remove", "Remove account")
+ next_role = "user" if membership.kind == "tenant-admin" else "tenant-admin"
+ actions += form("role", "Make user" if next_role == "user" else "Make tenant administrator", role=next_role)
+ if platform_operator: actions += form("recover", "Recover identity")
+ return (f'{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)} '
+ f'{escape(user.primary_email or "") if user else ""} {escape(membership.kind)} '
+ f'{escape(status.value)} for this tenant {login} {actions} ')
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
return self._page_html(
@@ -1948,13 +2083,19 @@ Use the login name they provide; it may differ from your display name.{escape(step.title)} — {escape(step.status.value)} {gap}{action}"
)
- 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://"):
raise ValidationError("password setup handoff must use HTTPS")
return self._page_html(
"Password setup",
- "Login identity created "
- "The password is handled only by the NetKingdom identity "
+ "
Login ready for password setup "
+ + f"Login name: {escape(self._directory_login(subject))} . Use this name when signing in; it may differ from the display name.
"
+ + "The password is handled only by the NetKingdom identity "
"surface. This short-lived link is single use.
"
f''
"Continue to password setup
"
@@ -1972,7 +2113,7 @@ Use the login name they provide; it may differ from your display name.HomeMy account Sign-in security '
if "platform-operator" in actor.roles:
- links += 'Platform administration '
+ links += 'Platform administration Service recovery '
elif "tenant-admin" in actor.roles:
links += f'Manage users '
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(
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]:
data = body.encode()
- start_response("200 OK", [
+ start_response(status, [
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(data))),
*(extra_headers or []),
diff --git a/tests/journey-coverage.json b/tests/journey-coverage.json
new file mode 100644
index 0000000..6adb02d
--- /dev/null
+++ b/tests/journey-coverage.json
@@ -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": ""
+ }
+ ]
+}
diff --git a/tests/test_journey_postgres.py b/tests/test_journey_postgres.py
new file mode 100644
index 0000000..0d41b1a
--- /dev/null
+++ b/tests/test_journey_postgres.py
@@ -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))
diff --git a/tests/test_journey_roles.py b/tests/test_journey_roles.py
new file mode 100644
index 0000000..3dd9aa3
--- /dev/null
+++ b/tests/test_journey_roles.py
@@ -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='