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

@ -28,7 +28,7 @@
| workplan | USER-WP-0018 | finished | — | workplans/USER-WP-0018-postgres-store-adapter.md |
| workplan | USER-WP-0019 | finished | — | workplans/USER-WP-0019-provider-backed-postgres-conformance.md |
| workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
| workplan | USER-WP-0021 | backlog | — | workplans/USER-WP-0021-portal-product-expansion.md |
| workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
| task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
| task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
@ -154,8 +154,8 @@
| task | USER-WP-0020-T06 | done | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
| task | USER-WP-0020-T07 | done | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
| task | USER-WP-0020-T08 | done | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
| task | USER-WP-0021-T01 | todo | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T02 | todo | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T03 | todo | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T04 | todo | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T01 | progress | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T02 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T03 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T04 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
| task | USER-WP-0021-T05 | wait | — | workplans/USER-WP-0021-portal-product-expansion.md |

View file

@ -42,3 +42,17 @@ Initial logical names:
- Sensitive writes must fail closed when authorization is unavailable.
- Claims enrichment must be optional and must not make user-engine a token
issuer.
## Portal integration settings
The production portal requires its existing database, OIDC, proxy-marker, and
identity-provisioning settings. Platform tenant creation is enabled only when
both of these settings are present:
- `USER_ENGINE_TENANT_MANAGEMENT_URL` — tenant-authority base URL;
- `USER_ENGINE_TENANT_MANAGEMENT_TOKEN` — workload-scoped bearer token.
The adapter calls `POST /v1/tenants` with correlation and idempotency headers.
The token is never returned in errors, audit records, outbox events, or browser
responses. When the settings are absent, ordinary portal behavior remains
available and platform tenant creation fails closed as unavailable.

View file

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: user-engine portal API
version: 0.1.0
version: 0.2.0
servers:
- url: /api/v1
security:
@ -15,6 +15,19 @@ paths:
description: Current user and linked identities
"403":
$ref: "#/components/responses/Denied"
/me/profile:
patch:
operationId: updateCurrentUserProfile
description: Updates display name and versioned consent; verified email cannot be changed here.
parameters: [{$ref: "#/components/parameters/IdempotencyKey"}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/UpdateSelfProfile"}
responses:
"200": {description: Durable self-service profile state}
"403": {$ref: "#/components/responses/Denied"}
/registrations:
post:
operationId: startRegistration
@ -49,6 +62,19 @@ paths:
responses:
"200": {description: Tenant-scoped memberships}
"403": {$ref: "#/components/responses/Denied"}
post:
operationId: createTenantUser
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateUser"}
responses:
"201": {description: Tenant user and membership created}
"403": {$ref: "#/components/responses/Denied"}
/tenants/{tenant}/users/{userId}:
patch:
operationId: updateTenantUserLifecycle
@ -62,6 +88,132 @@ paths:
responses:
"200": {description: Tenant account updated}
"403": {$ref: "#/components/responses/Denied"}
delete:
operationId: removeTenantUser
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/UserId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Login deprovisioned and tenant account disabled}
"403": {$ref: "#/components/responses/Denied"}
/tenants/{tenant}/invitations:
get:
operationId: listInvitations
parameters: [{$ref: "#/components/parameters/Tenant"}]
responses:
"200": {description: Tenant invitations}
"403": {$ref: "#/components/responses/Denied"}
post:
operationId: createInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateInvitation"}
responses:
"201": {description: Invitation created with an expiry and version}
"409": {$ref: "#/components/responses/Conflict"}
/tenants/{tenant}/invitations/{invitationId}/resend:
post:
operationId: resendInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IfMatch"
responses:
"200": {description: Invitation expiry renewed and version advanced}
"409": {$ref: "#/components/responses/Conflict"}
/tenants/{tenant}/invitations/{invitationId}/expire:
post:
operationId: expireInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IfMatch"
responses:
"200": {description: Invitation revoked}
"409": {$ref: "#/components/responses/Conflict"}
/invitations/{invitationId}/claim:
post:
operationId: claimInvitation
parameters:
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Invitation claimed and identity linked}
"400": {description: Invitation expired, revoked, or already claimed}
/onboarding/{journeyId}/steps/{stepKey}/complete:
post:
operationId: completeOnboardingStep
description: Completes an active user-engine-owned step. Provider-owned steps must use their external handoff and callback.
parameters:
- name: journeyId
in: path
required: true
schema: {type: string}
- name: stepKey
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Updated resumable onboarding journey}
"403": {$ref: "#/components/responses/Denied"}
"404": {description: Journey is absent or belongs to another user}
/platform/tenants:
post:
operationId: createPlatformTenant
description: Creates a tenant through the configured tenant authority and optionally prepares its first administrator.
parameters: [{$ref: "#/components/parameters/IdempotencyKey"}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateTenant"}
responses:
"201": {description: Tenant created or resumed and first administrator prepared}
"403": {$ref: "#/components/responses/Denied"}
/platform/tenants/{tenant}/users/{userId}/recover:
post:
operationId: recoverTenantUser
description: Reconciles or recreates the provider identity and activates the tenant account without exposing provider credentials.
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/UserId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Redacted recovery outcome}
"403": {$ref: "#/components/responses/Denied"}
/platform/outbox/deliver:
post:
operationId: deliverOutbox
requestBody:
content:
application/json:
schema:
type: object
properties:
worker_id: {type: string}
max_attempts: {type: integer, minimum: 1, maximum: 20}
additionalProperties: false
responses:
"200": {description: Bounded delivery results}
"403": {$ref: "#/components/responses/Denied"}
/platform/outbox/{eventId}/replay:
post:
operationId: replayOutboxEvent
parameters:
- name: eventId
in: path
required: true
schema: {type: string}
responses:
"200": {description: Event returned to pending delivery}
"403": {$ref: "#/components/responses/Denied"}
components:
securitySchemes:
verifiedOidc:
@ -78,6 +230,77 @@ components:
in: header
required: true
schema: {type: string, minLength: 16, maxLength: 200}
UserId:
name: userId
in: path
required: true
schema: {type: string}
InvitationId:
name: invitationId
in: path
required: true
schema: {type: string}
IfMatch:
name: If-Match
in: header
required: true
schema: {type: string, pattern: '^"?[0-9]+"?$'}
schemas:
CreateUser:
type: object
required: [primary_email, role]
properties:
primary_email: {type: string, format: email}
display_name: {type: string, maxLength: 200}
role: {type: string, enum: [user, tenant-admin]}
additionalProperties: false
CreateInvitation:
type: object
required: [primary_email]
properties:
primary_email: {type: string, format: email}
display_name: {type: string, maxLength: 200}
role: {type: string, enum: [user, tenant-admin]}
application_id: {type: string}
scope_id: {type: string}
additionalProperties: false
CreateTenant:
type: object
required: [tenant, display_name]
properties:
tenant: {type: string, pattern: '^tenant:'}
display_name: {type: string, minLength: 1, maxLength: 200}
first_admin:
type: object
required: [primary_email]
properties:
primary_email: {type: string, format: email}
display_name: {type: string, maxLength: 200}
additionalProperties: false
additionalProperties: false
UpdateSelfProfile:
type: object
required: [display_name, consent_accepted, consent_version]
properties:
display_name: {type: string, minLength: 1, maxLength: 200}
consent_accepted: {type: boolean}
consent_version: {type: string, minLength: 1, maxLength: 100}
additionalProperties: false
Error:
type: object
required: [error, message, correlation_id]
properties:
error: {type: string}
message: {type: string}
correlation_id: {type: string}
responses:
Denied:
description: Caller is unauthenticated or unauthorized
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
Conflict:
description: Optimistic concurrency or uniqueness conflict
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}

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")

View file

@ -64,8 +64,10 @@ class FamilyDataspaceOnboardingTests(unittest.TestCase):
onboarding = _onboard_family(service, owner.actor)
invitation = onboarding.invitations[0].invitation
claims = _member_claims(subject="child-sso")
claims["email"] = "child@example.test"
acceptance = service.accept_family_invitation(
_member_claims(subject="child-sso"),
claims,
invitation.invitation_id,
correlation_id="corr-accept",
)

View file

@ -21,6 +21,12 @@ class OIDCClientTests(unittest.TestCase):
self.assertIn(query["state"][0], self.client.pending)
self.assertNotIn(self.client.pending[query["state"][0]].verifier, url.query)
def test_begin_can_forward_a_tenant_hint_without_changing_session_authority(self):
url = urlparse(self.client.begin(tenant_hint="tenant:friendly:binky"))
self.assertEqual(
["tenant:friendly:binky"], parse_qs(url.query)["tenant_hint"]
)
def test_opaque_session_and_cookie_parser(self):
self.client.sessions["opaque"] = BrowserSession(
claims={"sub": "person"}, expires_at=9999999999

View file

@ -1,14 +1,21 @@
import io
import json
import unittest
from dataclasses import replace
from datetime import timedelta
from urllib.parse import urlencode
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
OnboardingJourney, OnboardingJourneyStatus, OnboardingStep,
OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff,
)
from user_engine.oidc import BrowserSession, OIDCClient
from user_engine.ports import ProvisioningResult
from user_engine.ports import IdentityDriftResult, ProvisioningResult, TenantProvisioningResult
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
from user_engine.domain import utc_now
SECRET = "test-proxy-secret-with-adequate-length"
@ -16,7 +23,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
def invoke(
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
form=None, cookie=None,
form=None, cookie=None, headers=None,
):
payload = (
urlencode(form).encode()
@ -38,6 +45,7 @@ def invoke(
if claims is not None:
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
environ.update(headers or {})
captured = {}
def start_response(status, headers):
@ -64,6 +72,11 @@ class PortalApplicationTests(unittest.TestCase):
)
self.claims = human_actor_claims(tenant="tenant:friendly:binky")
def platform_claims(self):
claims = human_actor_claims(subject="platform-operator", tenant="platform:root")
claims["roles"] = ["platform-operator"]
return claims
def test_public_health_and_home(self):
health, payload = invoke(self.app, "/healthz")
self.assertEqual("200 OK", health["status"])
@ -72,6 +85,9 @@ class PortalApplicationTests(unittest.TestCase):
home, html = invoke(self.app, "/")
self.assertEqual("200 OK", home["status"])
self.assertIn(b"Sign in with KeyCape", html)
self.assertIn(b'name="viewport"', html)
self.assertIn(b"focus-visible", html)
self.assertIn(b"<main>", html)
def test_metrics_expose_only_bounded_aggregate_state(self):
denied, _ = invoke(self.app, "/metrics", claims={}, marker="")
@ -93,12 +109,61 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("403 Forbidden", result["status"])
self.assertNotIn(b"attacker", payload)
def test_expired_browser_session_and_provider_outage_fail_closed(self):
oidc = OIDCClient(
issuer="https://kc.example", client_id="portal",
redirect_uri="https://users.example/oidc/callback", audience="portal",
)
oidc.sessions["expired"] = BrowserSession(
claims=self.claims, expires_at=0, csrf_token="expired-csrf"
)
self.app.oidc_client = oidc
expired, _ = invoke(
self.app, "/onboarding", cookie="ue_session=expired"
)
self.assertEqual("403 Forbidden", expired["status"])
self.app.oidc_client = None
self.app.provisioning = FailingProvisioning()
created, payload = invoke(
self.app, "/api/v1/tenants/tenant:friendly:binky/users",
method="POST", claims=self.claims,
body={"primary_email": "failure@example.test", "role": "user"},
)
user_id = json.loads(payload)["user"]["user_id"]
before = self.app.service.store.tenant_account(
"tenant:friendly:binky", user_id
).status
failed, payload = invoke_with_idempotency(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision",
self.claims,
)
self.assertEqual("502 Bad Gateway", failed["status"])
self.assertNotIn(b"provider-secret", payload)
self.assertEqual(
before,
self.app.service.store.tenant_account(
"tenant:friendly:binky", user_id
).status,
)
def test_verified_claims_create_current_user(self):
result, payload = invoke(self.app, "/api/v1/me", claims=self.claims)
self.assertEqual("200 OK", result["status"])
decoded = json.loads(payload)
self.assertEqual("tenant:friendly:binky", decoded["actor"]["tenant"])
updated, payload = invoke_with_idempotency(
self.app, "/api/v1/me/profile", self.claims, method="PATCH",
body={
"display_name": "Sample Person", "consent_accepted": True,
"consent_version": "portal-terms-v1",
},
)
self.assertEqual("200 OK", updated["status"])
self.assertEqual("Sample Person", json.loads(payload)["display_name"])
def test_registration_api_is_correlated(self):
result, payload = invoke(
self.app,
@ -151,6 +216,217 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", changed["status"])
self.assertEqual("suspended", json.loads(payload)["status"])
self.assertIn(("suspend", "ada"), self.app.provisioning.actions)
removed, payload = invoke_with_idempotency(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}",
self.claims, method="DELETE",
)
self.assertEqual("200 OK", removed["status"])
self.assertEqual("removed", json.loads(payload)["status"])
self.assertIn(("deprovision", "ada"), self.app.provisioning.actions)
def test_invitation_lifecycle_is_versioned_and_replay_safe(self):
created, payload = invoke(
self.app,
"/api/v1/tenants/tenant:friendly:binky/invitations",
method="POST", claims=self.claims,
body={"primary_email": "invitee@example.test", "role": "user"},
)
self.assertEqual("201 Created", created["status"])
invitation = json.loads(payload)["invitation"]
invitation_id = invitation["invitation_id"]
self.assertEqual(1, invitation["version"])
self.assertIsNotNone(invitation["expires_at"])
duplicate, _ = invoke(
self.app,
"/api/v1/tenants/tenant:friendly:binky/invitations",
method="POST", claims=self.claims,
body={"primary_email": "INVITEE@example.test", "role": "user"},
)
self.assertEqual("409 Conflict", duplicate["status"])
listed, payload = invoke(
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
claims=self.claims,
)
self.assertEqual("200 OK", listed["status"])
self.assertEqual(1, len(json.loads(payload)["items"]))
resent, payload = invoke(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/resend",
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"1"'},
)
self.assertEqual("200 OK", resent["status"])
self.assertEqual(2, json.loads(payload)["version"])
conflict, _ = invoke(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/expire",
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"1"'},
)
self.assertEqual("409 Conflict", conflict["status"])
expired, payload = invoke(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/expire",
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"2"'},
)
self.assertEqual("200 OK", expired["status"])
self.assertEqual("revoked", json.loads(payload)["status"])
claimable, payload = invoke(
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
method="POST", claims=self.claims,
body={"primary_email": "sample.user@example.test", "role": "user"},
)
self.assertEqual("201 Created", claimable["status"])
claim_id = json.loads(payload)["invitation"]["invitation_id"]
claimed, _ = invoke(
self.app, f"/api/v1/invitations/{claim_id}/claim",
method="POST", claims=self.claims,
)
self.assertEqual("200 OK", claimed["status"])
replayed, _ = invoke(
self.app, f"/api/v1/invitations/{claim_id}/claim",
method="POST", claims=self.claims,
)
self.assertEqual("400 Bad Request", replayed["status"])
timed, payload = invoke(
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
method="POST", claims=self.claims,
body={"primary_email": "expired@example.test", "role": "user"},
)
timed_id = json.loads(payload)["invitation"]["invitation_id"]
timed_invitation = self.app.service.store.family_invitation(timed_id)
self.app.service.store.save_family_invitation(replace(
timed_invitation, expires_at=utc_now() - timedelta(seconds=1)
))
expired_claims = dict(self.claims)
expired_claims["email"] = "expired@example.test"
timed_out, _ = invoke(
self.app, f"/api/v1/invitations/{timed_id}/claim",
method="POST", claims=expired_claims,
)
self.assertEqual("400 Bad Request", timed_out["status"])
def test_outbox_delivery_replay_and_dead_letter(self):
invoke(self.app, "/api/v1/me", claims=self.claims)
actor = self.app.service.identity_adapter.normalize(self.claims)
event = self.app.service.outbox_events()[0]
def unavailable(_event):
raise RuntimeError("notification provider unavailable; token=redacted")
first = self.app.service.deliver_outbox(
actor, unavailable, worker_id="worker-a", max_attempts=2
)[0]
self.assertEqual(1, first.delivery_attempts)
self.assertIsNotNone(first.failed_at)
self.assertIsNone(first.dead_lettered_at)
replayed = self.app.service.replay_outbox(actor, event.event_id)
self.assertIsNone(replayed.failure_reason)
second = self.app.service.deliver_outbox(
actor, unavailable, worker_id="worker-a", max_attempts=2
)[0]
self.assertIsNotNone(second.dead_lettered_at)
self.assertLessEqual(len(second.failure_reason), 200)
replayed = self.app.service.replay_outbox(actor, event.event_id)
delivered = self.app.service.deliver_outbox(
actor, lambda _event: None, worker_id="worker-b"
)[0]
self.assertEqual(replayed.event_id, delivered.event_id)
self.assertIsNotNone(delivered.delivered_at)
self.assertNotIn(delivered, self.app.service.outbox_events())
def test_platform_tenant_bootstrap_recovery_and_outbox_transport(self):
self.app.tenant_management = FakeTenantManagement()
self.app.provisioning = FakeProvisioning()
claims = self.platform_claims()
denied, _ = invoke_with_idempotency(
self.app, "/api/v1/platform/tenants", self.claims,
body={"tenant": "tenant:friendly:new", "display_name": "New"},
)
self.assertEqual("403 Forbidden", denied["status"])
created, payload = invoke_with_idempotency(
self.app, "/api/v1/platform/tenants", claims,
body={
"tenant": "tenant:friendly:new", "display_name": "New Tenant",
"first_admin": {"primary_email": "admin@new.test", "display_name": "Admin"},
},
)
self.assertEqual("201 Created", created["status"])
decoded = json.loads(payload)
self.assertEqual("created", decoded["tenant"]["status"])
self.assertEqual("tenant-admin", decoded["first_admin"]["membership"]["kind"])
user_id = decoded["first_admin"]["user"]["user_id"]
recovered, payload = invoke_with_idempotency(
self.app,
f"/api/v1/platform/tenants/tenant:friendly:new/users/{user_id}/recover",
claims,
)
self.assertEqual("200 OK", recovered["status"])
self.assertEqual("active", json.loads(payload)["tenant_account"]["status"])
self.app.outbox_delivery = lambda _event: None
delivered, payload = invoke(
self.app, "/api/v1/platform/outbox/deliver", method="POST",
claims=claims, body={"worker_id": "test-worker"},
)
self.assertEqual("200 OK", delivered["status"])
self.assertTrue(json.loads(payload)["items"])
def test_platform_browser_tenant_and_first_admin_bootstrap(self):
claims = self.platform_claims()
oidc = OIDCClient(
issuer="https://kc.example", client_id="portal",
redirect_uri="https://users.example/oidc/callback", audience="portal",
)
oidc.sessions["platform"] = BrowserSession(
claims=claims, expires_at=9999999999, csrf_token="platform-csrf",
)
self.app.oidc_client = oidc
self.app.tenant_management = FakeTenantManagement()
self.app.provisioning = FakeProvisioning()
page, html = invoke(self.app, "/platform", cookie="ue_session=platform")
self.assertEqual("200 OK", page["status"])
self.assertIn(b"First administrator", html)
denied, _ = invoke(
self.app, "/platform/tenants", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "wrong", "tenant": "tenant:friendly:browser",
"display_name": "Browser Tenant",
},
)
self.assertEqual("403 Forbidden", denied["status"])
created, html = invoke(
self.app, "/platform/tenants", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "tenant": "tenant:friendly:browser",
"display_name": "Browser Tenant", "admin_display_name": "First Admin",
"admin_email": "first-admin@browser.test",
},
)
self.assertEqual("200 OK", created["status"])
self.assertIn(b"awaiting onboarding", html)
memberships = self.app.service.store.memberships_for_tenant(
"tenant:friendly:browser"
)
self.assertEqual("tenant-admin", memberships[0].kind)
admin_page, html = invoke(
self.app, "/admin/tenant:friendly:browser", cookie="ue_session=platform"
)
self.assertEqual("200 OK", admin_page["status"])
self.assertIn(b"Lifecycle diagnostics", html)
self.assertIn(b"Recover identity", html)
recovered, _ = invoke(
self.app,
f"/admin/tenant:friendly:browser/users/{memberships[0].user_id}/recover",
method="POST", cookie="ue_session=platform",
form={"csrf_token": "platform-csrf"},
)
self.assertEqual("303 See Other", recovered["status"])
def test_admin_form_requires_csrf_and_supports_two_step_provisioning(self):
oidc = OIDCClient(
@ -218,6 +494,112 @@ class PortalApplicationTests(unittest.TestCase):
)
self.assertEqual("200 OK", page["status"])
self.assertIn(b"Create password setup link", html)
self.assertIn(b"Invite a user", html)
self.assertIn(b"Remove account", html)
def test_browser_invitation_acceptance_and_onboarding_status(self):
oidc = OIDCClient(
issuer="https://kc.example", client_id="portal",
redirect_uri="https://users.example/oidc/callback", audience="portal",
)
oidc.sessions["browser"] = BrowserSession(
claims=self.claims, expires_at=9999999999, csrf_token="csrf-test-token",
)
self.app.oidc_client = oidc
created, _ = invoke(
self.app, "/admin/tenant:friendly:binky/invitations", method="POST",
cookie="ue_session=browser", form={
"csrf_token": "csrf-test-token", "display_name": "Invitee",
"primary_email": "invitee@example.test", "role": "user",
},
)
self.assertEqual("303 See Other", created["status"])
invitation = next(iter(self.app.service.store.family_invitations.values()))
page, html = invoke(
self.app, f"/invitations/{invitation.invitation_id}",
cookie="ue_session=browser",
)
self.assertEqual("200 OK", page["status"])
self.assertIn(b"Accept invitation", html)
wrong, _ = invoke(
self.app, f"/invitations/{invitation.invitation_id}", method="POST",
cookie="ue_session=browser", form={"csrf_token": "csrf-test-token"},
)
self.assertEqual("403 Forbidden", wrong["status"])
invitee_claims = human_actor_claims(
subject="invitee", tenant="tenant:friendly:binky"
)
invitee_claims["email"] = "invitee@example.test"
oidc.sessions["browser"] = BrowserSession(
claims=invitee_claims, expires_at=9999999999,
csrf_token="csrf-test-token",
)
accepted, _ = invoke(
self.app, f"/invitations/{invitation.invitation_id}", method="POST",
cookie="ue_session=browser", form={"csrf_token": "csrf-test-token"},
)
self.assertEqual("303 See Other", accepted["status"])
self.assertEqual("/onboarding", accepted["headers"]["Location"])
own_journey = OnboardingJourney(
tenant="tenant:friendly:binky", user_id=invitation.user_id,
protocol_id="protocol-self", trigger_type=OnboardingTriggerType.INVITATION,
status=OnboardingJourneyStatus.IN_PROGRESS, active_step_key="profile-review",
steps=(OnboardingStep(
step_key="profile-review", title="Review your profile",
subsystem="user-engine", status=OnboardingStepStatus.IN_PROGRESS,
),),
)
provider_journey = OnboardingJourney(
tenant="tenant:friendly:binky", user_id=invitation.user_id,
protocol_id="protocol-provider", trigger_type=OnboardingTriggerType.INVITATION,
status=OnboardingJourneyStatus.BLOCKED, active_step_key="mfa",
steps=(OnboardingStep(
step_key="mfa", title="Enroll MFA", subsystem="key-cape",
status=OnboardingStepStatus.BLOCKED,
handoff=SubsystemHandoff(
subsystem="key-cape", status=OnboardingStepStatus.BLOCKED,
),
),),
)
self.app.service.store.save_onboarding_journey(own_journey)
self.app.service.store.save_onboarding_journey(provider_journey)
onboarding, html = invoke(
self.app, "/onboarding", cookie="ue_session=browser"
)
self.assertEqual("200 OK", onboarding["status"])
self.assertIn(b"Onboarding progress", html)
self.assertIn(b"tenant:friendly:binky", html)
self.assertIn(b"Profile and consent", html)
self.assertIn(b"Mark complete", html)
self.assertIn(b"provider-owned surface", html)
completed, _ = invoke(
self.app,
f"/onboarding/{own_journey.journey_id}/steps/profile-review/complete",
method="POST", cookie="ue_session=browser",
form={"csrf_token": "csrf-test-token"},
)
self.assertEqual("303 See Other", completed["status"])
provider_denied, _ = invoke(
self.app,
f"/onboarding/{provider_journey.journey_id}/steps/mfa/complete",
method="POST", cookie="ue_session=browser",
form={"csrf_token": "csrf-test-token"},
)
self.assertEqual("403 Forbidden", provider_denied["status"])
saved, _ = invoke(
self.app, "/onboarding/profile", method="POST",
cookie="ue_session=browser", form={
"csrf_token": "csrf-test-token", "display_name": "Updated Invitee",
"consent_accepted": "yes",
},
)
self.assertEqual("303 See Other", saved["status"])
user = self.app.service.store.user(invitation.user_id)
self.assertEqual("Updated Invitee", user.display_name)
self.assertEqual("invitee@example.test", user.primary_email)
self.assertEqual("portal-terms-v1", user.consent_version)
self.assertIsNotNone(user.profile_completed_at)
class FakeProvisioning:
@ -241,6 +623,26 @@ class FakeProvisioning:
self.actions.append(("reactivate", external_subject))
return ProvisioningResult("netkingdom-lldap", external_subject, "active")
def deprovision(self, *, external_subject, idempotency_key, correlation_id):
self.actions.append(("deprovision", external_subject))
return ProvisioningResult("netkingdom-lldap", external_subject, "removed")
def reconcile(self, request, *, external_subject, desired_status="active"):
self.actions.append(("reconcile", external_subject))
return IdentityDriftResult(
"netkingdom-lldap", external_subject, "in_sync", changed=("status",)
)
class FakeTenantManagement:
def create_tenant(self, *, tenant, display_name, idempotency_key, correlation_id):
return TenantProvisioningResult(tenant=tenant, status="created")
class FailingProvisioning(FakeProvisioning):
def provision(self, request):
raise RuntimeError("provider-secret must never escape")
def invoke_with_idempotency(app, path, claims, *, method="POST", body=None):
payload = json.dumps(body or {}).encode()

View file

@ -4,11 +4,11 @@ type: workplan
title: "Expand user-engine portal beyond the proven Binky MVP"
domain: communication
repo: user-engine
status: backlog
status: active
owner: codex
topic_slug: netkingdom
created: "2026-07-30"
updated: "2026-07-30"
updated: "2026-08-08"
depends_on:
- USER-WP-0020
state_hub_workstream_id: "ba217f48-5fa5-4178-9c79-73aa225f3f2e"
@ -23,7 +23,7 @@ holding the proven production MVP open. Activate according to tenant demand.
```task
id: USER-WP-0021-T01
status: todo
status: progress
priority: high
state_hub_task_id: "342299b8-d9a3-408d-bf0d-914496714d5f"
```
@ -32,11 +32,25 @@ Add invitation claim/resend/expiry, platform tenant management and recovery
routes, optimistic concurrency, complete OpenAPI schemas, and durable outbox
delivery/replay/dead-letter operations.
2026-08-08 increment: durable invitations now carry expiry and an optimistic
version. The API supports tenant create/list, version-gated resend/expire, and
authenticated claim with terminal-state and stale-version rejection. Outbox
state now covers attempts, bounded failures, delivery, replay, and dead-letter
through the store abstraction. OpenAPI 0.2 documents invitation and removal
operations. Platform recovery breadth and an outbox operator transport remain.
2026-08-08 follow-up: a provider-neutral `TenantManagementPort` now supports
platform-operator tenant creation with optional first-admin preparation. A
redacted recovery route recreates or reconciles provider identity state and
reactivates tenant lifecycle, while authenticated operator routes dispatch and
replay durable outbox events. Ordinary tenant users are denied these routes.
Broader tenant update/retirement operations remain.
## T02 - Expand self-service onboarding UX
```task
id: USER-WP-0021-T02
status: todo
status: done
priority: medium
state_hub_task_id: "dc548cfb-db7c-4cbd-864f-2effeebc3dbd"
```
@ -45,11 +59,34 @@ Add invitation acceptance, email-verification status, consent/profile,
tenant-selection, and fully resumable onboarding screens while keeping
password and MFA material on provider-owned surfaces.
2026-08-08 increment: invitation claim is now a first-class authenticated API
operation and preserves the provider-owned credential boundary. Dedicated
browser screens for the fully resumable journey remain.
2026-08-08 browser increment: authenticated users can review and accept an
invitation through a CSRF-protected flow, then land on a responsive onboarding
status screen showing provider verification, tenant memberships, and journey
state. Acceptance binds the verified OIDC email to the invitation address.
Consent/profile editing and tenant switching remain.
2026-08-08 completion increment: self-service profile completion and versioned
consent are now durable, audited, and emitted through the outbox; verified
email remains immutable. Membership-scoped tenant selection is available for
onboarding views, and a PKCE reauthentication handoff carries a tenant hint so
the identity provider—not browser state—changes authoritative tenant context.
Interactive completion of subsystem-owned journey steps remains.
2026-08-08 completion: users can complete active user-engine-owned onboarding
steps through API or CSRF-protected browser controls. Journey ownership is
checked before mutation. Password, MFA, and other subsystem-owned steps expose
handoff status only and cannot be completed from user-engine; their callbacks
resume the durable journey. This completes the self-service onboarding scope.
## T03 - Expand administration UX
```task
id: USER-WP-0021-T03
status: todo
status: done
priority: high
state_hub_task_id: "3e0b41ee-6159-46a4-a9fe-c5b6d714cc2a"
```
@ -57,11 +94,38 @@ state_hub_task_id: "3e0b41ee-6159-46a4-a9fe-c5b6d714cc2a"
Add platform tenant creation, first-admin bootstrap, invitation/recovery
management, account removal, and redacted lifecycle-gap diagnostics.
2026-08-08 increment: account removal now uses the provider-neutral
deprovisioning port and disables the tenant lifecycle record. Platform
creation/bootstrap and recovery UI remain.
2026-08-08 follow-up: platform APIs now create tenants through the tenant
authority, prepare the first administrator, and perform redacted identity
recovery. Browser administration screens for these operations remain.
2026-08-08 browser increment: tenant administrators can create, inspect,
resend, and expire invitations with versioned CSRF-protected forms, and remove
accounts through the provider-neutral deprovisioning boundary.
2026-08-08 platform-browser increment: platform operators have a responsive,
CSRF-protected tenant-creation screen with optional first-admin preparation
and a direct handoff to tenant administration. Redacted recovery and
lifecycle-gap browser diagnostics remain.
2026-08-08 recovery-browser increment: platform operators can inspect redacted
lifecycle-gap categories and invoke CSRF-protected identity recovery from the
tenant member view. Recovery recreates a missing provider link or reconciles
an existing identity, then restores the tenant lifecycle record without
showing credentials, factor evidence, or provider error bodies.
2026-08-08 completion: tenant and platform browser surfaces now cover every
listed administration operation while retaining tenant boundaries, CSRF,
provider-neutral lifecycle ports, and redacted diagnostics.
## T04 - Complete broad security and accessibility conformance
```task
id: USER-WP-0021-T04
status: todo
status: done
priority: high
state_hub_task_id: "fb59245e-6989-4bd9-b72a-68e53ea9f0af"
```
@ -71,6 +135,32 @@ outage, partial failure, audit-redaction, keyboard/screen-reader, and
mobile/desktop conformance. Preserve existing cross-tenant and escalation
denial gates.
2026-08-08 increment: conformance covers invitation conflicts, resend/expiry,
outbox provider failure, replay, dead-letter, successful recovery, and
provider-neutral account removal. Browser accessibility breadth remains.
2026-08-08 follow-up: conformance now also proves ordinary-user denial of the
platform surface and the positive platform tenant/bootstrap, recovery, and
outbox delivery workflow.
2026-08-08 browser increment: conformance proves CSRF enforcement, invitation
email binding, successful browser claim and onboarding redirect, responsive
viewport metadata, focus-visible controls, semantic landmarks, and invitation
and removal administration controls.
2026-08-08 self-service/recovery increment: conformance additionally covers
durable consent/profile completion, immutable verified email, tenant-hinted
OIDC reauthentication, platform recovery controls, and redacted lifecycle-gap
presentation.
2026-08-08 completion: the automated matrix covers duplicate, explicitly
expired, revoked, and replayed invitations; stale versions; expired sessions;
provider outage and unchanged lifecycle state; bounded error/audit surfaces;
cross-tenant and platform denial; semantic headings/landmarks; visible keyboard
focus; responsive viewport/mobile tables; provider handoff denial; and
desktop/browser positive flows. The full suite passes 115 tests with three
provider integration tests skipped when their external service is absent.
## T05 - Trigger enterprise federation planning only on demand
```task