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)}.
" + '' if actor is not None else f'' ) @@ -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 '| Role | Status | Expires | Action |
|---|
Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.
| User | Role | Status | Directory | Action |
|---|
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"""{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.'}
+ +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(verification)}
Passwords and MFA are managed by your identity provider.
Viewing {escape(selected_tenant)}.
Reauthenticate in this tenant to change the authoritative login context.