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
This commit is contained in:
parent
75750c0036
commit
1127f852dd
24 changed files with 1554 additions and 148 deletions
20
.forgejo/workflows/journeys.yaml
Normal file
20
.forgejo/workflows/journeys.yaml
Normal 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
|
||||||
11
Makefile
11
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
50
docs/journey-test-suites.md
Normal file
50
docs/journey-test-suites.md
Normal 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.
|
||||||
51
scripts/browser_journeys.mjs
Normal file
51
scripts/browser_journeys.mjs
Normal 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();}
|
||||||
47
scripts/browser_journeys.py
Normal file
47
scripts/browser_journeys.py
Normal 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)
|
||||||
39
scripts/run_journey_suites.py
Normal file
39
scripts/run_journey_suites.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
outer = self._transaction_depth == 0
|
||||||
|
if outer:
|
||||||
|
self._transaction_failed = False
|
||||||
begin = getattr(self.connection, "begin", None)
|
begin = getattr(self.connection, "begin", None)
|
||||||
if callable(begin):
|
if callable(begin): 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:
|
||||||
|
if outer:
|
||||||
|
if self._transaction_failed:
|
||||||
|
self.connection.rollback()
|
||||||
|
raise RuntimeError("nested transaction failed")
|
||||||
self.connection.commit()
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
try:
|
||||||
self.service.update_self_service_profile(
|
self.service.update_self_service_profile(
|
||||||
actor, display_name=str(body.get("display_name", "")),
|
actor, display_name=str(body.get("display_name", "")),
|
||||||
consent_accepted=body.get("consent_accepted") == "yes",
|
consent_accepted=body.get("consent_accepted") == "yes",
|
||||||
consent_version="portal-terms-v1",
|
consent_version="portal-terms-v1", correlation_id=correlation_id,
|
||||||
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,7 +789,14 @@ 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():
|
||||||
|
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(
|
user = self.service.create_user(
|
||||||
actor, display_name=body.get("admin_display_name"),
|
actor, display_name=body.get("admin_display_name"),
|
||||||
primary_email=email, correlation_id=correlation_id,
|
primary_email=email, correlation_id=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 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,
|
||||||
)
|
)
|
||||||
if identity is None:
|
expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled"
|
||||||
raise ValidationError("user has no managed login identity")
|
if result.status != expected:
|
||||||
if status == AccountStatus.SUSPENDED:
|
raise RuntimeError("tenant access change not confirmed")
|
||||||
self.provisioning.suspend(
|
|
||||||
external_subject=identity.subject,
|
|
||||||
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")
|
|
||||||
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
291
tests/journey-coverage.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
94
tests/test_journey_postgres.py
Normal file
94
tests/test_journey_postgres.py
Normal 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
287
tests/test_journey_roles.py
Normal 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'<script>',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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
51
workplans/USER-WP-0028-user-journey-acceptance.md
Normal file
51
workplans/USER-WP-0028-user-journey-acceptance.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
U01–U04/U10–U13: 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
|
||||||
|
```
|
||||||
|
|
||||||
|
U05–U08: 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.
|
||||||
51
workplans/USER-WP-0029-tenant-admin-journeys.md
Normal file
51
workplans/USER-WP-0029-tenant-admin-journeys.md
Normal 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.
|
||||||
51
workplans/USER-WP-0030-platform-admin-journeys.md
Normal file
51
workplans/USER-WP-0030-platform-admin-journeys.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
P04–P06: 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.
|
||||||
51
workplans/USER-WP-0031-automated-journey-suites.md
Normal file
51
workplans/USER-WP-0031-automated-journey-suites.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue