From 8229c6dd3351ff1ac71f38fc1a1e5409be3ebc83 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 8 Aug 2026 23:09:23 +0200 Subject: [PATCH] Expand portal onboarding and administration --- WORK-RECORDS.md | 10 +- docs/configuration.md | 14 + openapi/portal-v1.yaml | 225 ++++++- src/user_engine/adapters/__init__.py | 2 + src/user_engine/adapters/local.py | 20 +- src/user_engine/adapters/postgres.py | 33 +- src/user_engine/adapters/tenant_management.py | 52 ++ src/user_engine/domain/models.py | 13 + src/user_engine/oidc.py | 9 +- src/user_engine/ports.py | 29 + src/user_engine/runtime.py | 8 + src/user_engine/service.py | 148 ++++- src/user_engine/web.py | 552 +++++++++++++++++- tests/test_family_dataspace_onboarding.py | 4 +- tests/test_oidc.py | 6 + tests/test_web.py | 406 ++++++++++++- .../USER-WP-0021-portal-product-expansion.md | 102 +++- 17 files changed, 1601 insertions(+), 32 deletions(-) create mode 100644 src/user_engine/adapters/tenant_management.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ae0350d..bc8160d 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/configuration.md b/docs/configuration.md index 7256422..57f75a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/openapi/portal-v1.yaml b/openapi/portal-v1.yaml index 14d5a86..aa4aa20 100644 --- a/openapi/portal-v1.yaml +++ b/openapi/portal-v1.yaml @@ -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"} diff --git a/src/user_engine/adapters/__init__.py b/src/user_engine/adapters/__init__.py index f8d4ff9..9e84843 100644 --- a/src/user_engine/adapters/__init__.py +++ b/src/user_engine/adapters/__init__.py @@ -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", ] diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index 879abdc..33428a4 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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 { diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index fe9ef0d..2b185ec 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -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: diff --git a/src/user_engine/adapters/tenant_management.py b/src/user_engine/adapters/tenant_management.py new file mode 100644 index 0000000..d06a9f7 --- /dev/null +++ b/src/user_engine/adapters/tenant_management.py @@ -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, + ) diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index 4c1e7c8..2ea3a9f 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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 diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py index 89a673b..53c920d 100644 --- a/src/user_engine/oidc.py +++ b/src/user_engine/oidc.py @@ -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) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 5e4b18b..8403c22 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index b1ffe6b..2f6a4c0 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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, ) diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 07e929b..87cb32d 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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: diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 2067c85..6e60883 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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"

Signed in as {escape(actor.preferred_username)}.

" + '

Continue onboarding

' if actor is not None else f'

Sign in with KeyCape

' ) @@ -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 'No members yet.' + invitation_rows = "".join( + self._invitation_admin_row(tenant, item, csrf_token) + for item in invitations + ) or 'No invitations yet.' + diagnostic_items = "".join( + f"
  • {escape(item.replace('_', ' '))}
  • " for item in diagnostics.issues + ) or "
  • No lifecycle gaps detected.
  • " return self._page_html( f"{tenant} users", f"""

    {escape(tenant)} users

    @@ -463,10 +873,35 @@ class PortalApplication: +

    Invite a user

    +
    + + + + +
    +

    Invitations

    {invitation_rows}
    EmailRoleStatusExpiresAction
    +

    Lifecycle diagnostics

    Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.

    Members

    {rows}
    UserEmailRoleStatusDirectoryAction
    """, ) - 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"""
    +
    +
    +
    """ + expires = invitation.expires_at.isoformat() if invitation.expires_at else "—" + return ( + f"{escape(invitation.primary_email)}{escape(invitation.role)}" + f"{escape(invitation.status.value)}{escape(expires)}{actions}" + ) + + 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: """ ) + action += f"""
    + +
    """ + if platform_operator: + action += f"""
    + +
    """ return ( f"{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}" f"{escape(user.primary_email or '') if user else ''}" @@ -497,6 +939,98 @@ class PortalApplication: f"{'linked' if directory else 'pending'}{action}" ) + def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str: + return self._page_html( + "Accept invitation", + f"""

    Join {escape(invitation.tenant)}

    +

    You were invited as {escape(invitation.role)}. The invitation expires at {escape(invitation.expires_at.isoformat() if invitation.expires_at else 'the tenant policy deadline')}.

    +
    + +
    +

    Your password and MFA remain on the identity-provider surface.

    """, + ) + + def _platform(self, csrf_token: str) -> str: + return self._page_html( + "Platform administration", + f"""

    Platform administration

    +

    Create tenant

    +
    + + + +
    First administrator (optional) + +
    +
    """, + ) + + def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str: + return self._page_html( + "Tenant created", + f"""

    Tenant {escape(result.status)}

    +

    {escape(tenant)} was processed by the tenant authority.

    +

    {'The first administrator is prepared and awaiting onboarding.' if admin_prepared else 'No first administrator was requested.'}

    +

    Open tenant administration

    +

    Return to platform administration

    """, + ) + + def _onboarding( + self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...], + selected_tenant: str, csrf_token: str, + ) -> str: + membership_items = "".join( + f"
  • {escape(item.tenant)} — {escape(item.kind)}
  • " + for item in memberships + ) or "
  • No tenant memberships yet.
  • " + journey_items = "".join( + self._onboarding_journey_item(item, csrf_token) for item in journeys + ) or "
  • No additional onboarding steps are required.
  • " + 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"""

    Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}

    +

    Email and sign-in

    {escape(verification)}

    Passwords and MFA are managed by your identity provider.

    +

    Profile and consent

    +
    + + +
    +

    Tenant access

    Viewing {escape(selected_tenant)}.

    +

    Reauthenticate in this tenant to change the authoritative login context.

    +

    Onboarding progress

    """, + ) + + @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"
  • {escape(journey.status.value)}" + f"
      {steps}
  • " + ) + + @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"""
    + +
    """ + elif step.handoff is not None or step.subsystem != "user-engine": + action = "Continue on the provider-owned surface; this page will resume after its callback." + gap = f" Support category: {escape(step.lifecycle_gap)}" if step.lifecycle_gap else "" + return ( + f"
  • {escape(step.title)} — {escape(step.status.value)}{gap}{action}
  • " + ) + 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") diff --git a/tests/test_family_dataspace_onboarding.py b/tests/test_family_dataspace_onboarding.py index 17d869b..b4dabd8 100644 --- a/tests/test_family_dataspace_onboarding.py +++ b/tests/test_family_dataspace_onboarding.py @@ -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", ) diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 54d7e80..6559334 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -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 diff --git a/tests/test_web.py b/tests/test_web.py index 4f99594..4086bab 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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"
    ", 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() diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index 0dba8d3..084e238 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -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