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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ from contextvars import ContextVar
from dataclasses import asdict, is_dataclass, replace
from enum import Enum
from html import escape
import time
import hashlib
import hmac
import json
@ -349,6 +350,8 @@ class PortalApplication:
actor = self._actor(environ)
self._set_account_navigation(environ, actor)
if path.startswith("/api/v1/tenants/"):
self._require_tenant_admin(actor, path.split("/")[4])
if path == "/api/v1/me" and method == "GET":
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
if path == "/api/v1/me/profile" and method == "PATCH":
@ -383,13 +386,25 @@ class PortalApplication:
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.service.me(self._claims(environ), correlation_id=correlation_id)
self.service.update_self_service_profile(
actor, display_name=str(body.get("display_name", "")),
consent_accepted=body.get("consent_accepted") == "yes",
consent_version="portal-terms-v1",
correlation_id=correlation_id,
)
return self._redirect(start_response, "/onboarding", correlation_id)
try:
self.service.update_self_service_profile(
actor, display_name=str(body.get("display_name", "")),
consent_accepted=body.get("consent_accepted") == "yes",
consent_version="portal-terms-v1", correlation_id=correlation_id,
)
except ValidationError:
name = escape(str(body.get("display_name", ""))[:201])
csrf = escape(self._csrf_token(environ))
checked = " checked" if body.get("consent_accepted") == "yes" else ""
page = self._page_html("Check your profile", f'''<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":
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
@ -511,6 +526,7 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
tenant, user_id = parts[5], parts[7]
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
idempotency_key = self._idempotency_key(environ)
user = self.service.store.user(user_id)
if user is None:
@ -531,10 +547,9 @@ class PortalApplication:
)
recovery = {"status": provisioned.status, "changed": ("identity",)}
else:
reconciled = self.provisioning.reconcile(
request, external_subject=identity.subject, desired_status="active"
)
recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed}
self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
idempotency_key=idempotency_key, correlation_id=correlation_id)
recovery = {"status": "tenant_active", "changed": ("tenant_access",)}
account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
correlation_id=correlation_id,
@ -654,7 +669,8 @@ class PortalApplication:
if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST":
parts = path.split("/")
tenant, invitation_id, action = parts[4], parts[6], parts[7]
self.service.resolve_tenant_context(actor, tenant)
self._require_tenant_admin(actor, tenant)
self._require_invitation_tenant(invitation_id, tenant)
expected = self._expected_version(environ)
if action == "resend":
value = self.service.resend_family_invitation(
@ -674,10 +690,11 @@ class PortalApplication:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
tenant, user_id = parts[4], parts[6]
self.service.resolve_tenant_context(actor, tenant)
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
user = self.service.store.user(user_id)
if user is None:
raise NotFoundError("user not found")
self._require_setup_access(tenant, user_id)
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
@ -730,21 +747,29 @@ class PortalApplication:
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(idempotency_key) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
if identity is not None:
self.provisioning.deprovision(
external_subject=identity.subject,
idempotency_key=idempotency_key,
correlation_id=correlation_id,
)
account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.DISABLED,
tenant=tenant, correlation_id=correlation_id,
)
account = self._change_status(actor, tenant, user_id, AccountStatus.DISABLED,
idempotency_key=idempotency_key, correlation_id=correlation_id)
return self._json(start_response, "200 OK", {
"status": "removed", "tenant_account": _jsonable(account),
"provider_identity_removed": identity is not None,
"provider_identity_removed": False,
}, correlation_id)
if path in {"/platform/operations", "/platform/operations/replay"}:
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if method == "POST" and path.endswith("/replay"):
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
event = self.service.store.outbox_event(str(body.get("event_id", "")))
if event is None:
raise NotFoundError("delivery record not found")
if event.delivered_at is not None or event.claimed_by:
raise ConflictError("Delivery is already completed or being processed. Refresh its status.")
self.service.replay_outbox(actor, event.event_id, correlation_id=correlation_id)
return self._redirect(start_response, "/platform/operations?"+urlencode({"event_id":event.event_id}), correlation_id)
if method != "GET" or path.endswith("/replay"):
raise NotFoundError("operations route not found")
query = parse_qs(str(environ.get("QUERY_STRING", "")))
return self._html(start_response, self._operations_page(actor,
self._csrf_token(environ), query.get("event_id", [""])[0], correlation_id), correlation_id)
if path == "/platform" and method == "GET":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
return self._html(
@ -764,19 +789,26 @@ class PortalApplication:
idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id,
)
email = str(body.get("admin_email", ""))
if email:
user = self.service.create_user(
actor, display_name=body.get("admin_display_name"),
primary_email=email, correlation_id=correlation_id,
)
self.service.set_tenant_account_status(
actor, user.user_id, AccountStatus.INVITED,
tenant=tenant, correlation_id=correlation_id,
)
self.service.add_membership(
actor, user.user_id, tenant=tenant, scope_type="tenant",
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
)
with self.service.store.tenant_lifecycle_guard(tenant), self.service.store.transaction():
existing_admin = any(
m.scope_type == "tenant" and m.scope_id == tenant and m.kind == "tenant-admin"
and (u := self.service.store.user(m.user_id)) is not None
and (u.primary_email or "").casefold() == email.casefold()
for m in self.service.store.memberships_for_tenant(tenant)
) if email else False
if email and not existing_admin:
user = self.service.create_user(
actor, display_name=body.get("admin_display_name"),
primary_email=email, correlation_id=correlation_id,
)
self.service.set_tenant_account_status(
actor, user.user_id, AccountStatus.INVITED,
tenant=tenant, correlation_id=correlation_id,
)
self.service.add_membership(
actor, user.user_id, tenant=tenant, scope_type="tenant",
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
)
return self._html(
start_response,
self._platform_result(result, tenant, bool(email)), correlation_id,
@ -834,6 +866,13 @@ class PortalApplication:
version = str(body.get("version", ""))
if not version.isdigit():
raise ValidationError("the current record version is required")
if operation in {"retire", "reactivate"}:
current = self.tenant_management.tenant(tenant=tenant, correlation_id=correlation_id)
preview = self._confirm_change(environ, start_response, body, str(current.version),
f"{operation.capitalize()} {tenant}",
"This changes the tenant lifecycle. Existing application sessions may take time to reflect the change. Review the tenant and reason before confirming.", correlation_id)
if preview is not None:
return preview
metadata = {
key: str(body[key]) for key in ("display_name", "contact_email")
if str(body.get(key, "")).strip()
@ -853,8 +892,13 @@ class PortalApplication:
correlation_id,
)
if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2]
self.service.resolve_tenant_context(actor, tenant)
tenant = unquote(path.split("/")[2])
self._require_tenant_admin(actor, tenant)
if path.endswith("/activity"):
self.service.tenant_diagnostics(actor, tenant=tenant, correlation_id=correlation_id)
return self._html(start_response, self._audit_page(tenant), correlation_id)
if len(path.split("/")) != 3:
raise NotFoundError("tenant page not found")
memberships = self.service.store.memberships_for_tenant(tenant)
invitations = self.service.store.family_invitations_for_tenant(tenant)
diagnostics = self.service.tenant_diagnostics(
@ -870,10 +914,23 @@ class PortalApplication:
)
if path.startswith("/admin/") and method == "POST":
parts = path.split("/")
tenant = parts[2]
self.service.resolve_tenant_context(actor, tenant)
tenant = unquote(parts[2])
self._require_tenant_admin(actor, tenant)
if len(parts) == 6 and parts[3] == "users":
self.service.authorize_tenant_member_action(actor, parts[4], tenant=tenant, correlation_id=correlation_id)
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
if len(parts) == 6 and parts[3] == "users" and parts[5] in {"status", "remove", "recover", "role"}:
user = self.service.store.user(parts[4])
state = self.service.store.tenant_account(tenant, parts[4])
snapshot = repr((state, self.service.store.memberships_for_user(parts[4], tenant=tenant)))
preview = self._confirm_change(environ, start_response, body, snapshot,
f"{parts[5].capitalize()} account in {tenant}",
f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained.", correlation_id)
if preview is not None:
return preview
if len(parts) == 4 and parts[3] in {"users", "invitations"} and body.get("role", "user") not in {"user", "tenant-admin"}:
raise ValidationError("Choose User or Tenant administrator.")
if len(parts) == 4 and parts[3] == "users":
user = self.service.create_user(
actor,
@ -904,6 +961,7 @@ class PortalApplication:
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "invitations":
invitation_id, action = parts[4], parts[5]
self._require_invitation_tenant(invitation_id, tenant)
version = int(body.get("version", "0"))
if action == "resend":
self.service.resend_family_invitation(
@ -925,6 +983,7 @@ class PortalApplication:
user = self.service.store.user(user_id)
if user is None:
raise NotFoundError("user not found")
self._require_setup_access(tenant, user_id)
result = self.provisioning.provision(ProvisioningRequest(
user_id=user.user_id,
tenant=tenant,
@ -947,12 +1006,29 @@ class PortalApplication:
return self._html(
start_response,
self._password_setup_handoff(
result.password_setup_url, tenant
result.password_setup_url, tenant, result.external_subject
),
correlation_id,
)
query = urlencode({"provisioned": user.user_id, "status": result.status})
return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "role":
role = str(body.get("role", ""))
user_id = parts[4]
with self.service.store.tenant_lifecycle_guard(tenant):
self.service.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
identity = next((i for i in self.service.store.identities_for_user(user_id) if i.provider == "netkingdom-lldap"), None)
account = self.service.store.tenant_account(tenant, user_id)
if identity is not None:
if not callable(getattr(self.provisioning, "tenant_access", None)):
raise ValidationError("Tenant-scoped identity changes are unavailable.")
enabled = account is not None and account.status == AccountStatus.ACTIVE
result = self.provisioning.tenant_access(external_subject=identity.subject, tenant=tenant,
roles=(role,), enabled=enabled, idempotency_key=f"portal-role-{tenant}-{user_id}-{role}", correlation_id=correlation_id)
if result.status != ("tenant_active" if enabled else "tenant_disabled"):
raise RuntimeError("tenant role change not confirmed")
self.service.set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "status":
status = AccountStatus(str(body.get("status", "")))
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
@ -964,20 +1040,8 @@ class PortalApplication:
)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove":
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
user_id = parts[4]
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
if identity is not None:
self.provisioning.deprovision(
external_subject=identity.subject,
idempotency_key=f"portal-remove-{tenant}-{user_id}",
correlation_id=correlation_id,
)
self.service.set_tenant_account_status(
actor, user_id, AccountStatus.DISABLED,
tenant=tenant, correlation_id=correlation_id,
)
self._change_status(actor, tenant, parts[4], AccountStatus.DISABLED,
idempotency_key=f"portal-remove-{tenant}-{parts[4]}", correlation_id=correlation_id)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
@ -1003,9 +1067,8 @@ class PortalApplication:
correlation_id=correlation_id,
)
else:
self.provisioning.reconcile(
request, external_subject=identity.subject, desired_status="active"
)
self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
idempotency_key=request.idempotency_key, correlation_id=correlation_id)
self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE,
tenant=tenant, correlation_id=correlation_id,
@ -1013,7 +1076,11 @@ class PortalApplication:
return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id)
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
def _change_status(
def _change_status(self, actor: Any, tenant: str, user_id: str, status: AccountStatus, *, idempotency_key: str, correlation_id: str) -> Any:
with self.service.store.tenant_lifecycle_guard(tenant):
return self._change_status_locked(actor, tenant, user_id, status, idempotency_key=idempotency_key, correlation_id=correlation_id)
def _change_status_locked(
self,
actor: Any,
tenant: str,
@ -1025,34 +1092,103 @@ class PortalApplication:
) -> Any:
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
self.service.resolve_tenant_context(actor, tenant)
identity = next(
(
item for item in self.service.store.identities_for_user(user_id)
if item.provider == "netkingdom-lldap"
),
None,
)
if identity is None:
raise ValidationError("user has no managed login identity")
if status == AccountStatus.SUSPENDED:
self.provisioning.suspend(
external_subject=identity.subject,
idempotency_key=idempotency_key,
correlation_id=correlation_id,
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
raise ValidationError("unsupported tenant account status")
if status != AccountStatus.ACTIVE:
self.service.require_admin_successor(user_id, tenant=tenant)
identity = next((item for item in self.service.store.identities_for_user(user_id)
if item.provider == "netkingdom-lldap"), None)
if identity is not None:
if not callable(getattr(self.provisioning, "tenant_access", None)):
raise ValidationError("Tenant-scoped identity changes are unavailable. No shared login was changed.")
result = self.provisioning.tenant_access(
external_subject=identity.subject, tenant=tenant,
roles=tuple(m.kind for m in self.service.store.memberships_for_user(user_id, tenant=tenant)
if m.scope_type == "tenant" and m.scope_id == tenant),
enabled=status == AccountStatus.ACTIVE,
idempotency_key=idempotency_key, correlation_id=correlation_id,
)
elif status == AccountStatus.ACTIVE:
self.provisioning.reactivate(
external_subject=identity.subject,
idempotency_key=idempotency_key,
correlation_id=correlation_id,
)
else:
raise ValidationError("provider lifecycle supports active or suspended")
expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled"
if result.status != expected:
raise RuntimeError("tenant access change not confirmed")
return self.service.set_tenant_account_status(
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
actor, user_id, status, tenant=tenant, correlation_id=correlation_id,
)
def _confirm_change(self, environ: Mapping[str, Any], start_response: StartResponse,
body: Mapping[str, str], snapshot: str, title: str, explanation: str,
correlation_id: str) -> list[bytes] | None:
path = str(environ.get("PATH_INFO", ""))
fields = {k: v for k, v in body.items() if k != "confirm_token"}
state = hashlib.sha256(snapshot.encode()).hexdigest()
def signature(stamp: str) -> str:
material = json.dumps([path, fields, state, stamp], sort_keys=True).encode()
return hmac.new(self.trusted_proxy_secret.encode(), material, hashlib.sha256).hexdigest()
supplied = str(body.get("confirm_token", ""))
if supplied:
stamp, _, digest = supplied.partition(".")
if not stamp.isdigit() or not 0 <= time.time()-int(stamp) <= 600 or not hmac.compare_digest(digest, signature(stamp)):
raise ConflictError("The confirmation expired or the account changed. Refresh and review the action again.")
return None
stamp = str(int(time.time()))
hidden = "".join(f'<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]:
if self.oidc_client is not None:
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
@ -1698,18 +1834,28 @@ Use the login name they provide; it may differ from your display name.</p></sect
) -> str:
rows = "".join(
self._admin_row(tenant, item, platform_operator, csrf_token)
for item in memberships
for item in memberships if item.scope_type == "tenant" and item.scope_id == tenant
) or '<tr><td colspan="6">No members yet.</td></tr>'
invitation_rows = "".join(
self._invitation_admin_row(tenant, item, csrf_token)
for item in invitations
) 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(
f"<li>{escape(item.replace('_', ' '))}</li>" for item in diagnostics.issues
) or "<li>No lifecycle gaps detected.</li>"
return self._page_html(
f"{tenant} users",
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>
<form method="post" action="/admin/{escape(tenant)}/users">
<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>
<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 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>""",
)
@ -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">
<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 ""
events = [e for e in self.service.store.outbox_history() if e.tenant == tenant
and e.aggregate_id == invitation.invitation_id and e.event_type in {"family_invitation.created", "family_invitation.resent", "family_member.invited"}]
delivery = self._delivery_status(events[-1]) if events else "Delivery status unavailable"
return (
f"<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(
self, tenant: str, membership: Any,
platform_operator: bool, csrf_token: str,
) -> str:
def _admin_row(self, tenant: str, membership: Any, platform_operator: bool, csrf_token: str) -> str:
user = self.service.store.user(membership.user_id)
identities = self.service.store.identities_for_user(membership.user_id)
directory = next(
(item for item in identities if item.provider == "netkingdom-lldap"),
None,
)
tenant_account = self.service.store.tenant_account(tenant, membership.user_id)
status = tenant_account.status if tenant_account else AccountStatus.INVITED
action = (
f"""<span>Linked as {escape(directory.subject)}</span>
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<button type="submit">Create password setup link</button></form>
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/status">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="status" value="{'active' if status == AccountStatus.SUSPENDED else 'suspended'}">
<button type="submit">{'Reactivate' if status == AccountStatus.SUSPENDED else 'Suspend'}</button></form>"""
if directory
else f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<button type="submit">Create login</button></form>"""
)
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/remove">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<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>"
)
directory = next((i for i in self.service.store.identities_for_user(membership.user_id)
if i.provider == "netkingdom-lldap"), None)
account = self.service.store.tenant_account(tenant, membership.user_id)
status = account.status if account else AccountStatus.INVITED
inactive = status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}
root = f"/admin/{quote(tenant, safe='')}/users/{quote(membership.user_id, safe='')}"
def form(action: str, label: str, **fields: str) -> str:
hidden = "".join(f'<input type="hidden" name="{escape(k)}" value="{escape(v)}">' for k,v in fields.items())
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>'
login = (f'<p>Login name: <strong>{escape(self._directory_login(directory.subject))}</strong></p>'
'<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>'
actions = ""
if not inactive:
actions += form("provision", "Create password setup link" if directory else "Create login")
actions += form("status", "Reactivate" if inactive else "Suspend", status="active" if inactive else "suspended")
if status != AccountStatus.DISABLED:
actions += form("remove", "Remove account")
next_role = "user" if membership.kind == "tenant-admin" else "tenant-admin"
actions += form("role", "Make user" if next_role == "user" else "Make tenant administrator", role=next_role)
if platform_operator: actions += form("recover", "Recover identity")
return (f'<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><td>{escape(membership.kind)}</td>'
f'<td>{escape(status.value)} for this tenant</td><td>{login}</td><td>{actions}</td></tr>')
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
return self._page_html(
@ -1948,13 +2083,19 @@ Use the login name they provide; it may differ from your display name.</p></sect
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://"):
raise ValidationError("password setup handoff must use HTTPS")
return self._page_html(
"Password setup",
"<h1>Login identity created</h1>"
"<p>The password is handled only by the NetKingdom identity "
"<h1>Login ready for password setup</h1>"
+ 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>"
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
"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
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
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:
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
@ -2001,10 +2142,10 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
def _html(
self, start_response: StartResponse, body: str, correlation_id: str,
*, extra_headers: list[tuple[str, str]] | None = None,
*, extra_headers: list[tuple[str, str]] | None = None, status: str = "200 OK",
) -> list[bytes]:
data = body.encode()
start_response("200 OK", [
start_response(status, [
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(data))),
*(extra_headers or []),