Expand portal onboarding and administration
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-08 23:09:23 +02:00
parent 3d334479e9
commit 8229c6dd33
17 changed files with 1601 additions and 32 deletions

View file

@ -7,6 +7,7 @@ from user_engine.adapters.local import (
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
__all__ = [
"InMemoryUserEngineStore",
@ -14,4 +15,5 @@ __all__ = [
"PostgresUserEngineStore",
"VerifiedIdentityClaimsAdapter",
"HTTPIdentityProvisioningAdapter",
"HTTPTenantManagementAdapter",
]

View file

@ -161,6 +161,12 @@ class InMemoryUserEngineStore:
if invitation.user_id == user_id
)
def family_invitations_for_tenant(self, tenant: str) -> tuple[FamilyInvitation, ...]:
return tuple(
invitation for invitation in self.family_invitations.values()
if invitation.tenant == tenant
)
def save_registration_session(self, session: RegistrationSession) -> None:
self.registration_sessions[session.registration_id] = session
@ -324,7 +330,19 @@ class InMemoryUserEngineStore:
self.outbox_events.append(event)
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
return tuple(self.outbox_events)
return tuple(
item for item in self.outbox_events
if item.delivered_at is None and item.dead_lettered_at is None
)
def save_outbox(self, event: OutboxEvent) -> None:
self.outbox_events = [
event if item.event_id == event.event_id else item
for item in self.outbox_events
]
def outbox_event(self, event_id: str) -> OutboxEvent | None:
return next((item for item in self.outbox_events if item.event_id == event_id), None)
def record_counts(self) -> Mapping[str, int]:
return {

View file

@ -198,6 +198,12 @@ class PostgresUserEngineStore:
self._query_records("family_invitations", user_id=user_id),
)
def family_invitations_for_tenant(self, tenant: str) -> tuple[FamilyInvitation, ...]:
return cast(
tuple[FamilyInvitation, ...],
self._query_records("family_invitations", tenant=tenant),
)
def save_registration_session(self, session: RegistrationSession) -> None:
self._upsert_record(session)
@ -413,7 +419,7 @@ class PostgresUserEngineStore:
"""
SELECT payload
FROM user_engine_outbox_events
WHERE claimed_at IS NULL AND delivered_at IS NULL
WHERE claimed_at IS NULL AND delivered_at IS NULL AND failed_at IS NULL
ORDER BY occurred_at, event_id
"""
)
@ -422,6 +428,31 @@ class PostgresUserEngineStore:
for row in cursor.fetchall()
)
def save_outbox(self, event: OutboxEvent) -> None:
store_record = store_record_for(event)
with self._cursor() as cursor:
cursor.execute(
"""
UPDATE user_engine_outbox_events
SET payload = %s::jsonb, claimed_at = %s, claimed_by = %s,
delivered_at = %s, failed_at = %s, failure_reason = %s
WHERE event_id = %s
""",
(json.dumps(store_record.payload), event.claimed_at, event.claimed_by,
event.delivered_at, event.failed_at, event.failure_reason, event.event_id),
)
def outbox_event(self, event_id: str) -> OutboxEvent | None:
with self._cursor() as cursor:
cursor.execute(
"SELECT payload FROM user_engine_outbox_events WHERE event_id = %s",
(event_id,),
)
row = cursor.fetchone()
return None if row is None else cast(
OutboxEvent, self._decode_payload_row("outbox_events", row)
)
def record_counts(self) -> Mapping[str, int]:
counts = {key: 0 for key in USER_ENGINE_RECORD_COUNT_KEYS}
with self._cursor() as cursor:

View file

@ -0,0 +1,52 @@
"""HTTP adapter for the provider-neutral tenant authority."""
from __future__ import annotations
import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from user_engine.ports import TenantProvisioningResult
class HTTPTenantManagementAdapter:
def __init__(self, *, base_url: str, bearer_token: str, timeout: float = 10) -> None:
self.base_url = base_url.rstrip("/")
self.bearer_token = bearer_token.strip()
if not self.bearer_token:
raise ValueError("bearer token must not be empty")
self.timeout = timeout
def create_tenant(
self, *, tenant: str, display_name: str, idempotency_key: str,
correlation_id: str,
) -> TenantProvisioningResult:
request = Request(
self.base_url + "/v1/tenants",
data=json.dumps({
"tenant": tenant, "display_name": display_name,
"idempotency_key": idempotency_key,
"correlation_id": correlation_id,
}).encode(),
headers={
"Authorization": f"Bearer {self.bearer_token}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
"X-Request-ID": correlation_id,
},
method="POST",
)
try:
with urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read())
except HTTPError as exc:
exc.read(4096)
raise RuntimeError(f"tenant authority failed ({exc.code})") from exc
except URLError as exc:
raise RuntimeError("tenant authority unavailable") from exc
return TenantProvisioningResult(
tenant=str(result.get("tenant") or tenant),
status=str(result["status"]),
resumed=bool(result.get("resumed", False)),
external_ref=str(result["external_ref"]) if result.get("external_ref") else None,
)

View file

@ -229,6 +229,10 @@ class User:
display_name: str | None = None
primary_email: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
profile_completed_at: datetime | None = None
consented_at: datetime | None = None
consent_version: str | None = None
@dataclass(frozen=True)
@ -531,6 +535,8 @@ class FamilyInvitation:
updated_at: datetime = field(default_factory=utc_now)
accepted_at: datetime | None = None
revoked_at: datetime | None = None
expires_at: datetime | None = None
version: int = 1
@dataclass(frozen=True)
@ -679,3 +685,10 @@ class OutboxEvent:
tenant: str
correlation_id: str
occurred_at: datetime = field(default_factory=utc_now)
delivery_attempts: int = 0
claimed_by: str | None = None
claimed_at: datetime | None = None
delivered_at: datetime | None = None
failed_at: datetime | None = None
failure_reason: str | None = None
dead_lettered_at: datetime | None = None

View file

@ -48,13 +48,13 @@ class OIDCClient:
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, BrowserSession] = {}
def begin(self) -> str:
def begin(self, *, tenant_hint: str | None = None) -> str:
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time())
self._prune()
return f"{self.issuer}/authorize?{urlencode({
parameters = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@ -62,7 +62,10 @@ class OIDCClient:
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
})}"
}
if tenant_hint:
parameters["tenant_hint"] = tenant_hint
return f"{self.issuer}/authorize?{urlencode(parameters)}"
def complete(self, *, code: str, state: str) -> str:
pending = self.pending.pop(state, None)

View file

@ -75,6 +75,24 @@ class IdentityDriftResult:
changed: tuple[str, ...] = ()
@dataclass(frozen=True)
class TenantProvisioningResult:
tenant: str
status: str
resumed: bool = False
external_ref: str | None = None
class TenantManagementPort(Protocol):
"""Provider-neutral seam to the tenant authority (normally tenant-engine)."""
def create_tenant(
self, *, tenant: str, display_name: str, idempotency_key: str,
correlation_id: str,
) -> TenantProvisioningResult:
"""Create or resume a tenant without making user-engine authoritative."""
class IdentityProvisioningPort(Protocol):
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
@ -205,6 +223,11 @@ class UserEngineStore(Protocol):
) -> tuple[FamilyInvitation, ...]:
"""Return family invitations for a user."""
def family_invitations_for_tenant(
self, tenant: str
) -> tuple[FamilyInvitation, ...]:
"""Return invitations visible in one tenant."""
def save_registration_session(self, session: RegistrationSession) -> None:
"""Create or replace a registration session."""
@ -308,6 +331,12 @@ class UserEngineStore(Protocol):
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
"""Return pending outbox events in write order."""
def save_outbox(self, event: OutboxEvent) -> None:
"""Persist outbox delivery state."""
def outbox_event(self, event_id: str) -> OutboxEvent | None:
"""Return an outbox event including delivery state."""
def record_counts(self) -> Mapping[str, int]:
"""Return adapter-neutral record counts for diagnostics."""

View file

@ -10,6 +10,7 @@ from user_engine.adapters import (
PostgresUserEngineStore,
VerifiedIdentityClaimsAdapter,
HTTPIdentityProvisioningAdapter,
HTTPTenantManagementAdapter,
)
from user_engine.service import UserEngineService
from user_engine.oidc import OIDCClient
@ -39,6 +40,12 @@ def create_application() -> PortalApplication:
),
authorization=LocalAuthorizationCheckPort(),
)
tenant_management = None
if os.environ.get("USER_ENGINE_TENANT_MANAGEMENT_URL"):
tenant_management = HTTPTenantManagementAdapter(
base_url=_required("USER_ENGINE_TENANT_MANAGEMENT_URL"),
bearer_token=_required("USER_ENGINE_TENANT_MANAGEMENT_TOKEN"),
)
return PortalApplication(
service,
trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"),
@ -56,6 +63,7 @@ def create_application() -> PortalApplication:
base_url=_required("USER_ENGINE_PROVISIONING_URL"),
bearer_token=_required("USER_ENGINE_PROVISIONING_TOKEN"),
),
tenant_management=tenant_management,
)

View file

@ -3,8 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Any, Iterable, Mapping
from datetime import datetime, timedelta
from typing import Any, Callable, Iterable, Mapping
from user_engine.domain import (
Account,
@ -325,6 +325,56 @@ class UserEngineService:
platform_operator=platform_operator,
)
def update_self_service_profile(
self,
actor: Actor,
*,
display_name: str,
consent_accepted: bool,
consent_version: str,
correlation_id: str | None = None,
) -> User:
"""Update safe self-service fields; verified email remains immutable."""
display_name = display_name.strip()
consent_version = consent_version.strip()
if not 1 <= len(display_name) <= 200:
raise ValidationError("display_name must contain 1 to 200 characters")
if consent_accepted and not consent_version:
raise ValidationError("consent_version is required when consent is accepted")
correlation_id = correlation_id or new_id("corr")
identity = self.store.find_identity(actor.issuer, actor.subject)
if identity is None:
raise NotFoundError("current identity is not linked")
user = self._require_user(identity.user_id)
decision = self._authorize(
actor, action="profile.self.update", resource_type="user-engine:user",
resource_id=user.user_id, tenant=actor.tenant,
correlation_id=correlation_id, target_user_id=user.user_id,
)
now = utc_now()
updated = replace(
user, display_name=display_name, updated_at=now,
profile_completed_at=now,
consented_at=now if consent_accepted else None,
consent_version=consent_version if consent_accepted else None,
)
with self.store.transaction():
self.store.save_user(updated)
self._record_mutation(
actor, action="profile.self.update", subject=user.user_id,
tenant=actor.tenant, correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.self_service_profile_updated",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"profile_complete": True,
"consent_accepted": consent_accepted,
"consent_version": consent_version if consent_accepted else None,
},
)
return updated
def start_registration(
self,
actor: Actor,
@ -2362,12 +2412,19 @@ class UserEngineService:
member: FamilyMemberSpec,
catalog_namespace: str = "dataspace",
correlation_id: str | None = None,
expires_in: timedelta = timedelta(days=7),
) -> FamilyMemberInvitation:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
role = _family_role_value(member.role)
if not member.primary_email:
raise ValidationError("family member primary_email is required")
if any(
item.primary_email.casefold() == member.primary_email.casefold()
and item.status == InvitationStatus.PENDING
for item in self.store.family_invitations_for_tenant(tenant_context.tenant)
):
raise ConflictError("a pending invitation already exists for this address")
if member.issuer and member.subject:
existing = self.store.find_identity(member.issuer, member.subject)
if existing is not None:
@ -2409,7 +2466,7 @@ class UserEngineService:
correlation_id=correlation_id,
)
profile_defaults = dict(member.profile_defaults)
if member.display_name:
if member.display_name and role not in {"user", TENANT_ADMIN_ROLE}:
profile_defaults.setdefault("member_display_name", member.display_name)
self._apply_family_profile_defaults(
actor,
@ -2440,6 +2497,7 @@ class UserEngineService:
invited_by=actor.subject,
correlation_id=correlation_id,
last_sent_correlation_id=correlation_id,
expires_at=utc_now() + expires_in,
)
self.store.save_family_invitation(invitation)
self._record_mutation(
@ -2474,10 +2532,13 @@ class UserEngineService:
invitation_id: str,
*,
correlation_id: str | None = None,
expected_version: int | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be resent")
if expected_version is not None and invitation.version != expected_version:
raise ConflictError("invitation version does not match")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
@ -2495,6 +2556,8 @@ class UserEngineService:
resend_count=invitation.resend_count + 1,
last_sent_correlation_id=correlation_id,
updated_at=utc_now(),
expires_at=utc_now() + timedelta(days=7),
version=invitation.version + 1,
)
with self.store.transaction():
self.store.save_family_invitation(updated)
@ -2522,10 +2585,13 @@ class UserEngineService:
invitation_id: str,
*,
correlation_id: str | None = None,
expected_version: int | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be revoked")
if expected_version is not None and invitation.version != expected_version:
raise ConflictError("invitation version does not match")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
@ -2551,6 +2617,7 @@ class UserEngineService:
status=InvitationStatus.REVOKED,
updated_at=utc_now(),
revoked_at=utc_now(),
version=invitation.version + 1,
)
self.store.save_family_invitation(updated)
self._record_mutation(
@ -2583,8 +2650,13 @@ class UserEngineService:
raise ValidationError("revoked invitations cannot be accepted")
if invitation.status == InvitationStatus.ACCEPTED:
raise ValidationError("invitation is already accepted")
if invitation.expires_at is not None and invitation.expires_at <= utc_now():
raise ValidationError("invitation has expired")
actor = self.identity_adapter.normalize(claims)
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
claimed_email = str(claims.get("email") or "").strip().casefold()
if not claimed_email or claimed_email != invitation.primary_email.casefold():
raise AuthorizationDenied("invitation identity does not match")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
@ -2625,6 +2697,7 @@ class UserEngineService:
status=InvitationStatus.ACCEPTED,
updated_at=accepted_at,
accepted_at=accepted_at,
version=invitation.version + 1,
)
self.store.save_family_invitation(accepted)
self._record_mutation(
@ -2709,6 +2782,73 @@ class UserEngineService:
def outbox_events(self) -> tuple[OutboxEvent, ...]:
return self.store.pending_outbox()
def deliver_outbox(
self,
actor: Actor,
deliver: Callable[[OutboxEvent], None],
*,
worker_id: str,
max_attempts: int = 3,
correlation_id: str | None = None,
) -> tuple[OutboxEvent, ...]:
"""Claim and deliver pending events, retaining bounded failure details."""
if not worker_id.strip():
raise ValidationError("outbox worker_id is required")
if not 1 <= max_attempts <= 20:
raise ValidationError("outbox max_attempts must be between 1 and 20")
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor, action="outbox.deliver", resource_type="user-engine:outbox",
resource_id="pending", tenant=actor.tenant,
correlation_id=correlation_id,
)
results: list[OutboxEvent] = []
for event in self.store.pending_outbox():
claimed = replace(
event, claimed_by=worker_id, claimed_at=utc_now(),
delivery_attempts=event.delivery_attempts + 1,
failed_at=None, failure_reason=None,
)
with self.store.transaction():
self.store.save_outbox(claimed)
try:
deliver(claimed)
except Exception as exc:
reason = str(exc).strip()[:200] or type(exc).__name__
failed = replace(
claimed, failed_at=utc_now(), failure_reason=reason,
dead_lettered_at=(utc_now() if claimed.delivery_attempts >= max_attempts else None),
)
with self.store.transaction():
self.store.save_outbox(failed)
results.append(failed)
else:
delivered = replace(claimed, delivered_at=utc_now())
with self.store.transaction():
self.store.save_outbox(delivered)
results.append(delivered)
return tuple(results)
def replay_outbox(
self, actor: Actor, event_id: str, *, correlation_id: str | None = None
) -> OutboxEvent:
correlation_id = correlation_id or new_id("corr")
event = self.store.outbox_event(event_id)
if event is None:
raise NotFoundError("outbox event not found")
self.resolve_tenant_context(actor, event.tenant)
self._authorize(
actor, action="outbox.replay", resource_type="user-engine:outbox-event",
resource_id=event_id, tenant=event.tenant, correlation_id=correlation_id,
)
replayed = replace(
event, claimed_by=None, claimed_at=None, failed_at=None,
failure_reason=None, dead_lettered_at=None,
)
with self.store.transaction():
self.store.save_outbox(replayed)
return replayed
def outbox_diagnostics(self) -> OutboxDiagnostics:
event_types: dict[str, int] = {}
pending = self.store.pending_outbox()
@ -4728,6 +4868,8 @@ def _family_profile_key(catalog_namespace: str, key: str) -> str:
def _family_role_value(role: FamilyRole | str) -> str:
if str(role) in {"user", TENANT_ADMIN_ROLE}:
return str(role)
try:
return FamilyRole(str(role)).value
except ValueError as exc:

View file

@ -17,11 +17,11 @@ import secrets
from typing import Any, Callable, Iterable, Mapping
from urllib.parse import parse_qs, urlencode
from user_engine.domain import AccountStatus
from user_engine.domain import AccountStatus, FamilyMemberSpec
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
from user_engine.oidc import OIDCClient, cookie_value
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest
from user_engine.service import UserEngineService
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest, TenantManagementPort
from user_engine.service import PLATFORM_TENANT, UserEngineService
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
@ -52,6 +52,8 @@ class PortalApplication:
public_registration: bool = True,
oidc_client: OIDCClient | None = None,
provisioning: IdentityProvisioningPort | None = None,
tenant_management: TenantManagementPort | None = None,
outbox_delivery: Callable[[Any], None] | None = None,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -61,12 +63,16 @@ class PortalApplication:
self.public_registration = public_registration
self.oidc_client = oidc_client
self.provisioning = provisioning
self.tenant_management = tenant_management
self.outbox_delivery = outbox_delivery
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
try:
return self._dispatch(environ, start_response, str(correlation_id))
except (ValidationError, ConflictError, ValueError) as exc:
except ConflictError as exc:
return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id)
except (ValidationError, ValueError) as exc:
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
except RuntimeError:
return self._error(
@ -97,7 +103,14 @@ class PortalApplication:
raise AuthorizationDenied("metrics require the trusted workload marker")
return self._metrics(start_response, correlation_id)
if path in {"/login", "/oidc/start"}:
location = self.oidc_client.begin() if self.oidc_client else self.login_url
query = parse_qs(str(environ.get("QUERY_STRING", "")))
tenant_hint = query.get("tenant_hint", [None])[0]
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
raise ValidationError("tenant_hint must be a tenant identifier")
location = (
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None)
if self.oidc_client else self.login_url
)
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
return [b""]
if path == "/oidc/callback":
@ -133,6 +146,196 @@ class PortalApplication:
actor = self._actor(environ)
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":
self._idempotency_key(environ)
self.service.me(self._claims(environ), correlation_id=correlation_id)
body = self._body(environ)
updated = self.service.update_self_service_profile(
actor, display_name=str(body.get("display_name", "")),
consent_accepted=bool(body.get("consent_accepted", False)),
consent_version=str(body.get("consent_version") or "portal-terms-v1"),
correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
if path == "/onboarding" and method == "GET":
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
memberships = self.service.store.memberships_for_user(session.user.user_id)
journeys = self.service.store.onboarding_journeys_for_user(session.user.user_id)
query = parse_qs(str(environ.get("QUERY_STRING", "")))
selected_tenant = query.get("tenant", [session.actor.tenant])[0]
allowed_tenants = {item.tenant for item in memberships} | {session.actor.tenant}
if selected_tenant not in allowed_tenants:
raise AuthorizationDenied("tenant selection is not a membership")
return self._html(
start_response,
self._onboarding(
session, memberships, journeys, str(selected_tenant),
self._csrf_token(environ),
),
correlation_id,
)
if path == "/onboarding/profile" and method == "POST":
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)
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", "")))
parts = path.split("/")
journey_id, step_key = parts[2], parts[4]
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
journey = self.service.store.onboarding_journey(journey_id)
if journey is None or journey.user_id != session.user.user_id:
raise NotFoundError("onboarding journey not found")
step = next((item for item in journey.steps if item.step_key == step_key), None)
if step is None:
raise NotFoundError("onboarding step not found")
if step.subsystem != "user-engine" or step.handoff is not None:
raise AuthorizationDenied("subsystem-owned steps require their handoff")
self.service.complete_onboarding_step(
actor, journey_id, step_key, correlation_id=correlation_id
)
return self._redirect(start_response, "/onboarding", correlation_id)
if path.startswith("/invitations/") and method == "GET":
invitation = self.service.store.family_invitation(path.split("/")[2])
if invitation is None:
raise NotFoundError("invitation not found")
self.service.resolve_tenant_context(actor, invitation.tenant)
return self._html(
start_response,
self._invitation_acceptance(invitation, self._csrf_token(environ)),
correlation_id,
)
if path.startswith("/invitations/") and method == "POST":
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.service.accept_family_invitation(
self._claims(environ), path.split("/")[2], correlation_id=correlation_id
)
return self._redirect(start_response, "/onboarding", correlation_id)
if path == "/api/v1/platform/tenants" and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if self.tenant_management is None:
raise ValidationError("tenant management is unavailable")
idempotency_key = self._idempotency_key(environ)
body = self._body(environ)
tenant = str(body.get("tenant") or "")
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
raise ValidationError("a non-platform tenant identifier is required")
result = self.tenant_management.create_tenant(
tenant=tenant,
display_name=str(body.get("display_name") or tenant),
idempotency_key=idempotency_key,
correlation_id=correlation_id,
)
admin = body.get("first_admin")
bootstrap = None
if admin is not None:
if not isinstance(admin, Mapping):
raise ValidationError("first_admin must be an object")
user = self.service.create_user(
actor, display_name=admin.get("display_name"),
primary_email=admin.get("primary_email"),
correlation_id=correlation_id,
)
account = self.service.set_tenant_account_status(
actor, user.user_id, AccountStatus.INVITED,
tenant=tenant, correlation_id=correlation_id,
)
membership = self.service.add_membership(
actor, user.user_id, tenant=tenant, scope_type="tenant",
scope_id=tenant, kind="tenant-admin",
correlation_id=correlation_id,
)
bootstrap = {"user": user, "tenant_account": account, "membership": membership}
return self._json(start_response, "201 Created", {
"tenant": _jsonable(result), "first_admin": _jsonable(bootstrap),
}, correlation_id)
if path == "/api/v1/platform/outbox/deliver" and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if self.outbox_delivery is None:
raise ValidationError("outbox delivery is unavailable")
body = self._body(environ)
events = self.service.deliver_outbox(
actor, self.outbox_delivery,
worker_id=str(body.get("worker_id") or "portal-operator"),
max_attempts=int(body.get("max_attempts") or 3),
correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", {"items": _jsonable(events)}, correlation_id)
if path.startswith("/api/v1/platform/outbox/") and path.endswith("/replay") and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
event = self.service.replay_outbox(
actor, path.split("/")[5], correlation_id=correlation_id
)
return self._json(start_response, "200 OK", _jsonable(event), correlation_id)
if path.startswith("/api/v1/platform/tenants/") and path.endswith("/recover") and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
tenant, user_id = parts[5], parts[7]
idempotency_key = self._idempotency_key(environ)
user = self.service.store.user(user_id)
if user is None:
raise NotFoundError("user not found")
request = ProvisioningRequest(
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
display_name=user.display_name, idempotency_key=idempotency_key,
correlation_id=correlation_id,
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
)
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
if identity is None:
provisioned = self.provisioning.provision(request)
self.service.link_identity(
actor, user_id, issuer="urn:netkingdom:directory",
subject=provisioned.external_subject, provider=provisioned.provider,
correlation_id=correlation_id,
)
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}
account = self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", {
"recovery": _jsonable(recovery), "tenant_account": _jsonable(account),
}, correlation_id)
if path.startswith("/api/v1/invitations/") and path.endswith("/claim") and method == "POST":
invitation_id = path.split("/")[4]
accepted = self.service.accept_family_invitation(
self._claims(environ), invitation_id, correlation_id=correlation_id
)
return self._json(start_response, "200 OK", _jsonable(accepted), correlation_id)
if path.startswith("/api/v1/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
self._idempotency_key(environ)
parts = path.split("/")
journey_id, step_key = parts[4], parts[6]
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
journey = self.service.store.onboarding_journey(journey_id)
if journey is None or journey.user_id != session.user.user_id:
raise NotFoundError("onboarding journey not found")
step = next((item for item in journey.steps if item.step_key == step_key), None)
if step is None:
raise NotFoundError("onboarding step not found")
if step.subsystem != "user-engine" or step.handoff is not None:
raise AuthorizationDenied("subsystem-owned steps require their handoff")
updated = self.service.complete_onboarding_step(
actor, journey_id, step_key, correlation_id=correlation_id
)
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
if path == "/api/v1/registrations" and method == "POST":
if not self.public_registration:
raise AuthorizationDenied("public registration disabled")
@ -198,6 +401,45 @@ class PortalApplication:
"membership": _jsonable(membership),
"provisioning_status": "pending",
}, correlation_id)
if path.startswith("/api/v1/tenants/") and path.endswith("/invitations"):
tenant = path.split("/")[4]
self.service.resolve_tenant_context(actor, tenant)
if method == "GET":
items = self.service.store.family_invitations_for_tenant(tenant)
return self._json(start_response, "200 OK", {"items": _jsonable(items)}, correlation_id)
if method == "POST":
body = self._body(environ)
invited = self.service.invite_family_member(
actor,
tenant=tenant,
family_scope_id=str(body.get("scope_id") or tenant),
application_id=str(body.get("application_id") or "app.user-portal"),
member=FamilyMemberSpec(
primary_email=str(body.get("primary_email") or ""),
display_name=body.get("display_name"),
role=str(body.get("role") or "user"),
),
correlation_id=correlation_id,
)
return self._json(start_response, "201 Created", _jsonable(invited), correlation_id)
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)
expected = self._expected_version(environ)
if action == "resend":
value = self.service.resend_family_invitation(
actor, invitation_id, correlation_id=correlation_id,
expected_version=expected,
)
elif action == "expire":
value = self.service.revoke_family_invitation(
actor, invitation_id, correlation_id=correlation_id,
expected_version=expected,
)
else:
raise NotFoundError("invitation action not found")
return self._json(start_response, "200 OK", _jsonable(value), correlation_id)
if path.startswith("/api/v1/tenants/") and path.endswith("/provision") and method == "POST":
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
@ -250,13 +492,80 @@ class PortalApplication:
correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "DELETE":
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
parts = path.split("/")
tenant, user_id = parts[4], parts[6]
self.service.resolve_tenant_context(actor, tenant)
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,
)
return self._json(start_response, "200 OK", {
"status": "removed", "tenant_account": _jsonable(account),
"provider_identity_removed": identity is not None,
}, correlation_id)
if path == "/platform" and method == "GET":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
return self._html(
start_response, self._platform(self._csrf_token(environ)), correlation_id
)
if path == "/platform/tenants" and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if self.tenant_management is None:
raise ValidationError("tenant management is unavailable")
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
tenant = str(body.get("tenant", ""))
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
raise ValidationError("a non-platform tenant identifier is required")
result = self.tenant_management.create_tenant(
tenant=tenant, display_name=str(body.get("display_name") or tenant),
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,
)
return self._html(
start_response,
self._platform_result(result, tenant, bool(email)), correlation_id,
)
if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2]
self.service.resolve_tenant_context(actor, tenant)
memberships = self.service.store.memberships_for_tenant(tenant)
invitations = self.service.store.family_invitations_for_tenant(tenant)
diagnostics = self.service.tenant_diagnostics(
actor, tenant=tenant, correlation_id=correlation_id
)
return self._html(
start_response,
self._admin(tenant, memberships, self._csrf_token(environ)),
self._admin(
tenant, memberships, invitations, diagnostics,
"platform-operator" in actor.roles, self._csrf_token(environ),
),
correlation_id,
)
if path.startswith("/admin/") and method == "POST":
@ -282,6 +591,33 @@ class PortalApplication:
correlation_id=correlation_id,
)
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 4 and parts[3] == "invitations":
self.service.invite_family_member(
actor, tenant=tenant, family_scope_id=tenant,
application_id="app.user-portal",
member=FamilyMemberSpec(
primary_email=str(body.get("primary_email", "")),
display_name=body.get("display_name"),
role=str(body.get("role", "user")),
), correlation_id=correlation_id,
)
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]
version = int(body.get("version", "0"))
if action == "resend":
self.service.resend_family_invitation(
actor, invitation_id, expected_version=version,
correlation_id=correlation_id,
)
elif action == "expire":
self.service.revoke_family_invitation(
actor, invitation_id, expected_version=version,
correlation_id=correlation_id,
)
else:
raise NotFoundError("invitation action not found")
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision":
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
@ -327,6 +663,54 @@ class PortalApplication:
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] == "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,
)
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)
if self.provisioning is None:
raise ValidationError("identity provisioning is unavailable")
user_id = parts[4]
user = self.service.store.user(user_id)
if user is None:
raise NotFoundError("user not found")
request = ProvisioningRequest(
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
display_name=user.display_name,
idempotency_key=f"portal-recover-{tenant}-{user_id}",
correlation_id=correlation_id,
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
)
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
if identity is None:
result = self.provisioning.provision(request)
self.service.link_identity(
actor, user_id, issuer="urn:netkingdom:directory",
subject=result.external_subject, provider=result.provider,
correlation_id=correlation_id,
)
else:
self.provisioning.reconcile(
request, external_subject=identity.subject, desired_status="active"
)
self.service.set_tenant_account_status(
actor, user_id, AccountStatus.ACTIVE,
tenant=tenant, correlation_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)
def _change_status(
@ -435,9 +819,24 @@ class PortalApplication:
limit = max(1, min(100, int(query.get("limit", ["25"])[0])))
return offset, limit
@staticmethod
def _expected_version(environ: Mapping[str, Any]) -> int:
value = str(environ.get("HTTP_IF_MATCH", "")).strip().strip('"')
if not value.isdigit():
raise ValidationError("If-Match invitation version is required")
return int(value)
@staticmethod
def _idempotency_key(environ: Mapping[str, Any]) -> str:
value = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
if len(value) < 16:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
return value
def _home(self, actor: Any | None) -> str:
identity = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
if actor is not None
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
)
@ -448,11 +847,22 @@ class PortalApplication:
+ identity,
)
def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
def _admin(
self, tenant: str, memberships: tuple[Any, ...],
invitations: tuple[Any, ...], diagnostics: Any,
platform_operator: bool, csrf_token: str,
) -> str:
rows = "".join(
self._admin_row(tenant, item, csrf_token)
self._admin_row(tenant, item, platform_operator, csrf_token)
for item in memberships
) 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>'
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>
@ -463,10 +873,35 @@ class PortalApplication:
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
<button type="submit">Add user</button></form></section>
<section><h2>Invite a user</h2>
<form method="post" action="/admin/{escape(tenant)}/invitations">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" autocomplete="name"></label>
<label>Email <input name="primary_email" type="email" required autocomplete="email"></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>
<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><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>""",
)
def _admin_row(self, tenant: str, membership: Any, csrf_token: str) -> str:
def _invitation_admin_row(self, tenant: str, invitation: Any, csrf_token: str) -> str:
actions = ""
if invitation.status.value == "pending":
actions = f"""<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/resend">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Resend</button></form>
<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 ""
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>"
)
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(
@ -489,6 +924,13 @@ class PortalApplication:
<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>"
@ -497,6 +939,98 @@ class PortalApplication:
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
)
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
return self._page_html(
"Accept invitation",
f"""<h1>Join {escape(invitation.tenant)}</h1>
<p>You were invited as <strong>{escape(invitation.role)}</strong>. The invitation expires at {escape(invitation.expires_at.isoformat() if invitation.expires_at else 'the tenant policy deadline')}.</p>
<form method="post" action="/invitations/{escape(invitation.invitation_id)}">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<button type="submit">Accept invitation</button></form>
<p>Your password and MFA remain on the identity-provider surface.</p>""",
)
def _platform(self, csrf_token: str) -> str:
return self._page_html(
"Platform administration",
f"""<h1>Platform administration</h1>
<section aria-labelledby="create-tenant"><h2 id="create-tenant">Create tenant</h2>
<form method="post" action="/platform/tenants">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
<label>Display name <input name="display_name" required></label>
<fieldset><legend>First administrator (optional)</legend>
<label>Name <input name="admin_display_name" autocomplete="name"></label>
<label>Email <input name="admin_email" type="email" autocomplete="email"></label></fieldset>
<button type="submit">Create tenant</button></form></section>""",
)
def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str:
return self._page_html(
"Tenant created",
f"""<h1>Tenant {escape(result.status)}</h1>
<p><strong>{escape(tenant)}</strong> was processed by the tenant authority.</p>
<p>{'The first administrator is prepared and awaiting onboarding.' if admin_prepared else 'No first administrator was requested.'}</p>
<p><a class="button" href="/admin/{escape(tenant)}">Open tenant administration</a></p>
<p><a href="/platform">Return to platform administration</a></p>""",
)
def _onboarding(
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
selected_tenant: str, csrf_token: str,
) -> str:
membership_items = "".join(
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
for item in memberships
) or "<li>No tenant memberships yet.</li>"
journey_items = "".join(
self._onboarding_journey_item(item, csrf_token) for item in journeys
) or "<li>No additional onboarding steps are required.</li>"
verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
consent_checked = " checked" if session.user.consented_at else ""
return self._page_html(
"Onboarding",
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
<section aria-labelledby="verification"><h2 id="verification">Email and sign-in</h2><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
<label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label>
<button type="submit">Save profile</button></form></section>
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
)
@staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
steps = "".join(
PortalApplication._onboarding_step_item(journey.journey_id, step, csrf_token)
for step in journey.steps
)
return (
f"<li><strong>{escape(journey.status.value)}</strong>"
f"<ol>{steps}</ol></li>"
)
@staticmethod
def _onboarding_step_item(journey_id: str, step: Any, csrf_token: str) -> str:
action = ""
if (
step.status.value == "in_progress"
and step.subsystem == "user-engine"
and step.handoff is None
):
action = f"""<form method="post" action="/onboarding/{escape(journey_id)}/steps/{escape(step.step_key)}/complete">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<button type="submit">Mark complete</button></form>"""
elif step.handoff is not None or step.subsystem != "user-engine":
action = "<span>Continue on the provider-owned surface; this page will resume after its callback.</span>"
gap = f" <span>Support category: {escape(step.lifecycle_gap)}</span>" if step.lifecycle_gap else ""
return (
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:
if not setup_url.startswith("https://"):
raise ValidationError("password setup handoff must use HTTPS")