Expand portal onboarding and administration
This commit is contained in:
parent
3d334479e9
commit
8229c6dd33
17 changed files with 1601 additions and 32 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
52
src/user_engine/adapters/tenant_management.py
Normal file
52
src/user_engine/adapters/tenant_management.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""HTTP adapter for the provider-neutral tenant authority."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from user_engine.ports import TenantProvisioningResult
|
||||
|
||||
|
||||
class HTTPTenantManagementAdapter:
|
||||
def __init__(self, *, base_url: str, bearer_token: str, timeout: float = 10) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.bearer_token = bearer_token.strip()
|
||||
if not self.bearer_token:
|
||||
raise ValueError("bearer token must not be empty")
|
||||
self.timeout = timeout
|
||||
|
||||
def create_tenant(
|
||||
self, *, tenant: str, display_name: str, idempotency_key: str,
|
||||
correlation_id: str,
|
||||
) -> TenantProvisioningResult:
|
||||
request = Request(
|
||||
self.base_url + "/v1/tenants",
|
||||
data=json.dumps({
|
||||
"tenant": tenant, "display_name": display_name,
|
||||
"idempotency_key": idempotency_key,
|
||||
"correlation_id": correlation_id,
|
||||
}).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.bearer_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotency_key,
|
||||
"X-Request-ID": correlation_id,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
result = json.loads(response.read())
|
||||
except HTTPError as exc:
|
||||
exc.read(4096)
|
||||
raise RuntimeError(f"tenant authority failed ({exc.code})") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError("tenant authority unavailable") from exc
|
||||
return TenantProvisioningResult(
|
||||
tenant=str(result.get("tenant") or tenant),
|
||||
status=str(result["status"]),
|
||||
resumed=bool(result.get("resumed", False)),
|
||||
external_ref=str(result["external_ref"]) if result.get("external_ref") else None,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import secrets
|
|||
from typing import Any, Callable, Iterable, Mapping
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from user_engine.domain import AccountStatus
|
||||
from user_engine.domain import AccountStatus, FamilyMemberSpec
|
||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||
from user_engine.oidc import OIDCClient, cookie_value
|
||||
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest, TenantManagementPort
|
||||
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||
|
||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||
|
||||
|
|
@ -52,6 +52,8 @@ class PortalApplication:
|
|||
public_registration: bool = True,
|
||||
oidc_client: OIDCClient | None = None,
|
||||
provisioning: IdentityProvisioningPort | None = None,
|
||||
tenant_management: TenantManagementPort | None = None,
|
||||
outbox_delivery: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
if len(trusted_proxy_secret) < 24:
|
||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||
|
|
@ -61,12 +63,16 @@ class PortalApplication:
|
|||
self.public_registration = public_registration
|
||||
self.oidc_client = oidc_client
|
||||
self.provisioning = provisioning
|
||||
self.tenant_management = tenant_management
|
||||
self.outbox_delivery = outbox_delivery
|
||||
|
||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
||||
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||
try:
|
||||
return self._dispatch(environ, start_response, str(correlation_id))
|
||||
except (ValidationError, ConflictError, ValueError) as exc:
|
||||
except ConflictError as exc:
|
||||
return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
|
||||
except RuntimeError:
|
||||
return self._error(
|
||||
|
|
@ -97,7 +103,14 @@ class PortalApplication:
|
|||
raise AuthorizationDenied("metrics require the trusted workload marker")
|
||||
return self._metrics(start_response, correlation_id)
|
||||
if path in {"/login", "/oidc/start"}:
|
||||
location = self.oidc_client.begin() if self.oidc_client else self.login_url
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
tenant_hint = query.get("tenant_hint", [None])[0]
|
||||
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
||||
raise ValidationError("tenant_hint must be a tenant identifier")
|
||||
location = (
|
||||
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None)
|
||||
if self.oidc_client else self.login_url
|
||||
)
|
||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
return [b""]
|
||||
if path == "/oidc/callback":
|
||||
|
|
@ -133,6 +146,196 @@ class PortalApplication:
|
|||
actor = self._actor(environ)
|
||||
if path == "/api/v1/me" and method == "GET":
|
||||
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
|
||||
if path == "/api/v1/me/profile" and method == "PATCH":
|
||||
self._idempotency_key(environ)
|
||||
self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
body = self._body(environ)
|
||||
updated = self.service.update_self_service_profile(
|
||||
actor, display_name=str(body.get("display_name", "")),
|
||||
consent_accepted=bool(body.get("consent_accepted", False)),
|
||||
consent_version=str(body.get("consent_version") or "portal-terms-v1"),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
|
||||
if path == "/onboarding" and method == "GET":
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
memberships = self.service.store.memberships_for_user(session.user.user_id)
|
||||
journeys = self.service.store.onboarding_journeys_for_user(session.user.user_id)
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
selected_tenant = query.get("tenant", [session.actor.tenant])[0]
|
||||
allowed_tenants = {item.tenant for item in memberships} | {session.actor.tenant}
|
||||
if selected_tenant not in allowed_tenants:
|
||||
raise AuthorizationDenied("tenant selection is not a membership")
|
||||
return self._html(
|
||||
start_response,
|
||||
self._onboarding(
|
||||
session, memberships, journeys, str(selected_tenant),
|
||||
self._csrf_token(environ),
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
if path == "/onboarding/profile" and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
self.service.update_self_service_profile(
|
||||
actor, display_name=str(body.get("display_name", "")),
|
||||
consent_accepted=body.get("consent_accepted") == "yes",
|
||||
consent_version="portal-terms-v1",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
parts = path.split("/")
|
||||
journey_id, step_key = parts[2], parts[4]
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
journey = self.service.store.onboarding_journey(journey_id)
|
||||
if journey is None or journey.user_id != session.user.user_id:
|
||||
raise NotFoundError("onboarding journey not found")
|
||||
step = next((item for item in journey.steps if item.step_key == step_key), None)
|
||||
if step is None:
|
||||
raise NotFoundError("onboarding step not found")
|
||||
if step.subsystem != "user-engine" or step.handoff is not None:
|
||||
raise AuthorizationDenied("subsystem-owned steps require their handoff")
|
||||
self.service.complete_onboarding_step(
|
||||
actor, journey_id, step_key, correlation_id=correlation_id
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
if path.startswith("/invitations/") and method == "GET":
|
||||
invitation = self.service.store.family_invitation(path.split("/")[2])
|
||||
if invitation is None:
|
||||
raise NotFoundError("invitation not found")
|
||||
self.service.resolve_tenant_context(actor, invitation.tenant)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._invitation_acceptance(invitation, self._csrf_token(environ)),
|
||||
correlation_id,
|
||||
)
|
||||
if path.startswith("/invitations/") and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
self.service.accept_family_invitation(
|
||||
self._claims(environ), path.split("/")[2], correlation_id=correlation_id
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
if path == "/api/v1/platform/tenants" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.tenant_management is None:
|
||||
raise ValidationError("tenant management is unavailable")
|
||||
idempotency_key = self._idempotency_key(environ)
|
||||
body = self._body(environ)
|
||||
tenant = str(body.get("tenant") or "")
|
||||
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
|
||||
raise ValidationError("a non-platform tenant identifier is required")
|
||||
result = self.tenant_management.create_tenant(
|
||||
tenant=tenant,
|
||||
display_name=str(body.get("display_name") or tenant),
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
admin = body.get("first_admin")
|
||||
bootstrap = None
|
||||
if admin is not None:
|
||||
if not isinstance(admin, Mapping):
|
||||
raise ValidationError("first_admin must be an object")
|
||||
user = self.service.create_user(
|
||||
actor, display_name=admin.get("display_name"),
|
||||
primary_email=admin.get("primary_email"),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.INVITED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
membership = self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind="tenant-admin",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
bootstrap = {"user": user, "tenant_account": account, "membership": membership}
|
||||
return self._json(start_response, "201 Created", {
|
||||
"tenant": _jsonable(result), "first_admin": _jsonable(bootstrap),
|
||||
}, correlation_id)
|
||||
if path == "/api/v1/platform/outbox/deliver" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.outbox_delivery is None:
|
||||
raise ValidationError("outbox delivery is unavailable")
|
||||
body = self._body(environ)
|
||||
events = self.service.deliver_outbox(
|
||||
actor, self.outbox_delivery,
|
||||
worker_id=str(body.get("worker_id") or "portal-operator"),
|
||||
max_attempts=int(body.get("max_attempts") or 3),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {"items": _jsonable(events)}, correlation_id)
|
||||
if path.startswith("/api/v1/platform/outbox/") and path.endswith("/replay") and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
event = self.service.replay_outbox(
|
||||
actor, path.split("/")[5], correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(event), correlation_id)
|
||||
if path.startswith("/api/v1/platform/tenants/") and path.endswith("/recover") and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[5], parts[7]
|
||||
idempotency_key = self._idempotency_key(environ)
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
request = ProvisioningRequest(
|
||||
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
|
||||
display_name=user.display_name, idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
|
||||
)
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is None:
|
||||
provisioned = self.provisioning.provision(request)
|
||||
self.service.link_identity(
|
||||
actor, user_id, issuer="urn:netkingdom:directory",
|
||||
subject=provisioned.external_subject, provider=provisioned.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
recovery = {"status": provisioned.status, "changed": ("identity",)}
|
||||
else:
|
||||
reconciled = self.provisioning.reconcile(
|
||||
request, external_subject=identity.subject, desired_status="active"
|
||||
)
|
||||
recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed}
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"recovery": _jsonable(recovery), "tenant_account": _jsonable(account),
|
||||
}, correlation_id)
|
||||
if path.startswith("/api/v1/invitations/") and path.endswith("/claim") and method == "POST":
|
||||
invitation_id = path.split("/")[4]
|
||||
accepted = self.service.accept_family_invitation(
|
||||
self._claims(environ), invitation_id, correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(accepted), correlation_id)
|
||||
if path.startswith("/api/v1/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
|
||||
self._idempotency_key(environ)
|
||||
parts = path.split("/")
|
||||
journey_id, step_key = parts[4], parts[6]
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
journey = self.service.store.onboarding_journey(journey_id)
|
||||
if journey is None or journey.user_id != session.user.user_id:
|
||||
raise NotFoundError("onboarding journey not found")
|
||||
step = next((item for item in journey.steps if item.step_key == step_key), None)
|
||||
if step is None:
|
||||
raise NotFoundError("onboarding step not found")
|
||||
if step.subsystem != "user-engine" or step.handoff is not None:
|
||||
raise AuthorizationDenied("subsystem-owned steps require their handoff")
|
||||
updated = self.service.complete_onboarding_step(
|
||||
actor, journey_id, step_key, correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
|
||||
if path == "/api/v1/registrations" and method == "POST":
|
||||
if not self.public_registration:
|
||||
raise AuthorizationDenied("public registration disabled")
|
||||
|
|
@ -198,6 +401,45 @@ class PortalApplication:
|
|||
"membership": _jsonable(membership),
|
||||
"provisioning_status": "pending",
|
||||
}, correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and path.endswith("/invitations"):
|
||||
tenant = path.split("/")[4]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
if method == "GET":
|
||||
items = self.service.store.family_invitations_for_tenant(tenant)
|
||||
return self._json(start_response, "200 OK", {"items": _jsonable(items)}, correlation_id)
|
||||
if method == "POST":
|
||||
body = self._body(environ)
|
||||
invited = self.service.invite_family_member(
|
||||
actor,
|
||||
tenant=tenant,
|
||||
family_scope_id=str(body.get("scope_id") or tenant),
|
||||
application_id=str(body.get("application_id") or "app.user-portal"),
|
||||
member=FamilyMemberSpec(
|
||||
primary_email=str(body.get("primary_email") or ""),
|
||||
display_name=body.get("display_name"),
|
||||
role=str(body.get("role") or "user"),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "201 Created", _jsonable(invited), correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST":
|
||||
parts = path.split("/")
|
||||
tenant, invitation_id, action = parts[4], parts[6], parts[7]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
expected = self._expected_version(environ)
|
||||
if action == "resend":
|
||||
value = self.service.resend_family_invitation(
|
||||
actor, invitation_id, correlation_id=correlation_id,
|
||||
expected_version=expected,
|
||||
)
|
||||
elif action == "expire":
|
||||
value = self.service.revoke_family_invitation(
|
||||
actor, invitation_id, correlation_id=correlation_id,
|
||||
expected_version=expected,
|
||||
)
|
||||
else:
|
||||
raise NotFoundError("invitation action not found")
|
||||
return self._json(start_response, "200 OK", _jsonable(value), correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and path.endswith("/provision") and method == "POST":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
|
|
@ -250,13 +492,80 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "DELETE":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[4], parts[6]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(idempotency_key) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is not None:
|
||||
self.provisioning.deprovision(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.DISABLED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"status": "removed", "tenant_account": _jsonable(account),
|
||||
"provider_identity_removed": identity is not None,
|
||||
}, correlation_id)
|
||||
if path == "/platform" and method == "GET":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
return self._html(
|
||||
start_response, self._platform(self._csrf_token(environ)), correlation_id
|
||||
)
|
||||
if path == "/platform/tenants" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.tenant_management is None:
|
||||
raise ValidationError("tenant management is unavailable")
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
tenant = str(body.get("tenant", ""))
|
||||
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
|
||||
raise ValidationError("a non-platform tenant identifier is required")
|
||||
result = self.tenant_management.create_tenant(
|
||||
tenant=tenant, display_name=str(body.get("display_name") or tenant),
|
||||
idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id,
|
||||
)
|
||||
email = str(body.get("admin_email", ""))
|
||||
if email:
|
||||
user = self.service.create_user(
|
||||
actor, display_name=body.get("admin_display_name"),
|
||||
primary_email=email, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.INVITED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
|
||||
)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._platform_result(result, tenant, bool(email)), correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "GET":
|
||||
tenant = path.split("/")[2]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
memberships = self.service.store.memberships_for_tenant(tenant)
|
||||
invitations = self.service.store.family_invitations_for_tenant(tenant)
|
||||
diagnostics = self.service.tenant_diagnostics(
|
||||
actor, tenant=tenant, correlation_id=correlation_id
|
||||
)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._admin(tenant, memberships, self._csrf_token(environ)),
|
||||
self._admin(
|
||||
tenant, memberships, invitations, diagnostics,
|
||||
"platform-operator" in actor.roles, self._csrf_token(environ),
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "POST":
|
||||
|
|
@ -282,6 +591,33 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 4 and parts[3] == "invitations":
|
||||
self.service.invite_family_member(
|
||||
actor, tenant=tenant, family_scope_id=tenant,
|
||||
application_id="app.user-portal",
|
||||
member=FamilyMemberSpec(
|
||||
primary_email=str(body.get("primary_email", "")),
|
||||
display_name=body.get("display_name"),
|
||||
role=str(body.get("role", "user")),
|
||||
), correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "invitations":
|
||||
invitation_id, action = parts[4], parts[5]
|
||||
version = int(body.get("version", "0"))
|
||||
if action == "resend":
|
||||
self.service.resend_family_invitation(
|
||||
actor, invitation_id, expected_version=version,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
elif action == "expire":
|
||||
self.service.revoke_family_invitation(
|
||||
actor, invitation_id, expected_version=version,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
raise NotFoundError("invitation action not found")
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
|
|
@ -327,6 +663,54 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
user_id = parts[4]
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is not None:
|
||||
self.provisioning.deprovision(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=f"portal-remove-{tenant}-{user_id}",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.DISABLED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
user_id = parts[4]
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
request = ProvisioningRequest(
|
||||
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
|
||||
display_name=user.display_name,
|
||||
idempotency_key=f"portal-recover-{tenant}-{user_id}",
|
||||
correlation_id=correlation_id,
|
||||
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
|
||||
)
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is None:
|
||||
result = self.provisioning.provision(request)
|
||||
self.service.link_identity(
|
||||
actor, user_id, issuer="urn:netkingdom:directory",
|
||||
subject=result.external_subject, provider=result.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
self.provisioning.reconcile(
|
||||
request, external_subject=identity.subject, desired_status="active"
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id)
|
||||
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
||||
|
||||
def _change_status(
|
||||
|
|
@ -435,9 +819,24 @@ class PortalApplication:
|
|||
limit = max(1, min(100, int(query.get("limit", ["25"])[0])))
|
||||
return offset, limit
|
||||
|
||||
@staticmethod
|
||||
def _expected_version(environ: Mapping[str, Any]) -> int:
|
||||
value = str(environ.get("HTTP_IF_MATCH", "")).strip().strip('"')
|
||||
if not value.isdigit():
|
||||
raise ValidationError("If-Match invitation version is required")
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(environ: Mapping[str, Any]) -> str:
|
||||
value = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(value) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
return value
|
||||
|
||||
def _home(self, actor: Any | None) -> str:
|
||||
identity = (
|
||||
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
|
||||
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
|
||||
if actor is not None
|
||||
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
|
||||
)
|
||||
|
|
@ -448,11 +847,22 @@ class PortalApplication:
|
|||
+ identity,
|
||||
)
|
||||
|
||||
def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
|
||||
def _admin(
|
||||
self, tenant: str, memberships: tuple[Any, ...],
|
||||
invitations: tuple[Any, ...], diagnostics: Any,
|
||||
platform_operator: bool, csrf_token: str,
|
||||
) -> str:
|
||||
rows = "".join(
|
||||
self._admin_row(tenant, item, csrf_token)
|
||||
self._admin_row(tenant, item, platform_operator, csrf_token)
|
||||
for item in memberships
|
||||
) or '<tr><td colspan="6">No members yet.</td></tr>'
|
||||
invitation_rows = "".join(
|
||||
self._invitation_admin_row(tenant, item, csrf_token)
|
||||
for item in invitations
|
||||
) or '<tr><td colspan="5">No invitations yet.</td></tr>'
|
||||
diagnostic_items = "".join(
|
||||
f"<li>{escape(item.replace('_', ' '))}</li>" for item in diagnostics.issues
|
||||
) or "<li>No lifecycle gaps detected.</li>"
|
||||
return self._page_html(
|
||||
f"{tenant} users",
|
||||
f"""<h1>{escape(tenant)} users</h1>
|
||||
|
|
@ -463,10 +873,35 @@ class PortalApplication:
|
|||
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
|
||||
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
|
||||
<button type="submit">Add user</button></form></section>
|
||||
<section><h2>Invite a user</h2>
|
||||
<form method="post" action="/admin/{escape(tenant)}/invitations">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" autocomplete="name"></label>
|
||||
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
|
||||
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
|
||||
<button type="submit">Send invitation</button></form></section>
|
||||
<section><h2>Invitations</h2><table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Expires</th><th>Action</th></tr></thead><tbody>{invitation_rows}</tbody></table></section>
|
||||
<section aria-labelledby="lifecycle-gaps"><h2 id="lifecycle-gaps">Lifecycle diagnostics</h2><p>Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.</p><ul>{diagnostic_items}</ul></section>
|
||||
<section><h2>Members</h2><table><thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Directory</th><th>Action</th></tr></thead><tbody>{rows}</tbody></table></section>""",
|
||||
)
|
||||
|
||||
def _admin_row(self, tenant: str, membership: Any, csrf_token: str) -> str:
|
||||
def _invitation_admin_row(self, tenant: str, invitation: Any, csrf_token: str) -> str:
|
||||
actions = ""
|
||||
if invitation.status.value == "pending":
|
||||
actions = f"""<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/resend">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Resend</button></form>
|
||||
<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/expire">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Expire</button></form>"""
|
||||
expires = invitation.expires_at.isoformat() if invitation.expires_at else "—"
|
||||
return (
|
||||
f"<tr><td>{escape(invitation.primary_email)}</td><td>{escape(invitation.role)}</td>"
|
||||
f"<td>{escape(invitation.status.value)}</td><td>{escape(expires)}</td><td>{actions}</td></tr>"
|
||||
)
|
||||
|
||||
def _admin_row(
|
||||
self, tenant: str, membership: Any,
|
||||
platform_operator: bool, csrf_token: str,
|
||||
) -> str:
|
||||
user = self.service.store.user(membership.user_id)
|
||||
identities = self.service.store.identities_for_user(membership.user_id)
|
||||
directory = next(
|
||||
|
|
@ -489,6 +924,13 @@ class PortalApplication:
|
|||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Create login</button></form>"""
|
||||
)
|
||||
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/remove">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Remove account</button></form>"""
|
||||
if platform_operator:
|
||||
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/recover">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Recover identity</button></form>"""
|
||||
return (
|
||||
f"<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>"
|
||||
f"<td>{escape(user.primary_email or '') if user else ''}</td>"
|
||||
|
|
@ -497,6 +939,98 @@ class PortalApplication:
|
|||
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
|
||||
)
|
||||
|
||||
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
|
||||
return self._page_html(
|
||||
"Accept invitation",
|
||||
f"""<h1>Join {escape(invitation.tenant)}</h1>
|
||||
<p>You were invited as <strong>{escape(invitation.role)}</strong>. The invitation expires at {escape(invitation.expires_at.isoformat() if invitation.expires_at else 'the tenant policy deadline')}.</p>
|
||||
<form method="post" action="/invitations/{escape(invitation.invitation_id)}">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Accept invitation</button></form>
|
||||
<p>Your password and MFA remain on the identity-provider surface.</p>""",
|
||||
)
|
||||
|
||||
def _platform(self, csrf_token: str) -> str:
|
||||
return self._page_html(
|
||||
"Platform administration",
|
||||
f"""<h1>Platform administration</h1>
|
||||
<section aria-labelledby="create-tenant"><h2 id="create-tenant">Create tenant</h2>
|
||||
<form method="post" action="/platform/tenants">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
|
||||
<label>Display name <input name="display_name" required></label>
|
||||
<fieldset><legend>First administrator (optional)</legend>
|
||||
<label>Name <input name="admin_display_name" autocomplete="name"></label>
|
||||
<label>Email <input name="admin_email" type="email" autocomplete="email"></label></fieldset>
|
||||
<button type="submit">Create tenant</button></form></section>""",
|
||||
)
|
||||
|
||||
def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str:
|
||||
return self._page_html(
|
||||
"Tenant created",
|
||||
f"""<h1>Tenant {escape(result.status)}</h1>
|
||||
<p><strong>{escape(tenant)}</strong> was processed by the tenant authority.</p>
|
||||
<p>{'The first administrator is prepared and awaiting onboarding.' if admin_prepared else 'No first administrator was requested.'}</p>
|
||||
<p><a class="button" href="/admin/{escape(tenant)}">Open tenant administration</a></p>
|
||||
<p><a href="/platform">Return to platform administration</a></p>""",
|
||||
)
|
||||
|
||||
def _onboarding(
|
||||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||
selected_tenant: str, csrf_token: str,
|
||||
) -> str:
|
||||
membership_items = "".join(
|
||||
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
|
||||
for item in memberships
|
||||
) or "<li>No tenant memberships yet.</li>"
|
||||
journey_items = "".join(
|
||||
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
||||
) or "<li>No additional onboarding steps are required.</li>"
|
||||
verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
|
||||
consent_checked = " checked" if session.user.consented_at else ""
|
||||
return self._page_html(
|
||||
"Onboarding",
|
||||
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
|
||||
<section aria-labelledby="verification"><h2 id="verification">Email and sign-in</h2><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
|
||||
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
|
||||
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
|
||||
<label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label>
|
||||
<button type="submit">Save profile</button></form></section>
|
||||
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
|
||||
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
|
||||
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
|
||||
steps = "".join(
|
||||
PortalApplication._onboarding_step_item(journey.journey_id, step, csrf_token)
|
||||
for step in journey.steps
|
||||
)
|
||||
return (
|
||||
f"<li><strong>{escape(journey.status.value)}</strong>"
|
||||
f"<ol>{steps}</ol></li>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _onboarding_step_item(journey_id: str, step: Any, csrf_token: str) -> str:
|
||||
action = ""
|
||||
if (
|
||||
step.status.value == "in_progress"
|
||||
and step.subsystem == "user-engine"
|
||||
and step.handoff is None
|
||||
):
|
||||
action = f"""<form method="post" action="/onboarding/{escape(journey_id)}/steps/{escape(step.step_key)}/complete">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Mark complete</button></form>"""
|
||||
elif step.handoff is not None or step.subsystem != "user-engine":
|
||||
action = "<span>Continue on the provider-owned surface; this page will resume after its callback.</span>"
|
||||
gap = f" <span>Support category: {escape(step.lifecycle_gap)}</span>" if step.lifecycle_gap else ""
|
||||
return (
|
||||
f"<li><span>{escape(step.title)} — {escape(step.status.value)}</span>{gap}{action}</li>"
|
||||
)
|
||||
|
||||
def _password_setup_handoff(self, setup_url: str, tenant: str) -> str:
|
||||
if not setup_url.startswith("https://"):
|
||||
raise ValidationError("password setup handoff must use HTTPS")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import io
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
|
||||
from user_engine.domain import (
|
||||
OnboardingJourney, OnboardingJourneyStatus, OnboardingStep,
|
||||
OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff,
|
||||
)
|
||||
from user_engine.oidc import BrowserSession, OIDCClient
|
||||
from user_engine.ports import ProvisioningResult
|
||||
from user_engine.ports import IdentityDriftResult, ProvisioningResult, TenantProvisioningResult
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
||||
from user_engine.web import PortalApplication
|
||||
from user_engine.domain import utc_now
|
||||
|
||||
|
||||
SECRET = "test-proxy-secret-with-adequate-length"
|
||||
|
|
@ -16,7 +23,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
|
|||
|
||||
def invoke(
|
||||
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
|
||||
form=None, cookie=None,
|
||||
form=None, cookie=None, headers=None,
|
||||
):
|
||||
payload = (
|
||||
urlencode(form).encode()
|
||||
|
|
@ -38,6 +45,7 @@ def invoke(
|
|||
if claims is not None:
|
||||
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
|
||||
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
|
||||
environ.update(headers or {})
|
||||
captured = {}
|
||||
|
||||
def start_response(status, headers):
|
||||
|
|
@ -64,6 +72,11 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
)
|
||||
self.claims = human_actor_claims(tenant="tenant:friendly:binky")
|
||||
|
||||
def platform_claims(self):
|
||||
claims = human_actor_claims(subject="platform-operator", tenant="platform:root")
|
||||
claims["roles"] = ["platform-operator"]
|
||||
return claims
|
||||
|
||||
def test_public_health_and_home(self):
|
||||
health, payload = invoke(self.app, "/healthz")
|
||||
self.assertEqual("200 OK", health["status"])
|
||||
|
|
@ -72,6 +85,9 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
home, html = invoke(self.app, "/")
|
||||
self.assertEqual("200 OK", home["status"])
|
||||
self.assertIn(b"Sign in with KeyCape", html)
|
||||
self.assertIn(b'name="viewport"', html)
|
||||
self.assertIn(b"focus-visible", html)
|
||||
self.assertIn(b"<main>", html)
|
||||
|
||||
def test_metrics_expose_only_bounded_aggregate_state(self):
|
||||
denied, _ = invoke(self.app, "/metrics", claims={}, marker="")
|
||||
|
|
@ -93,12 +109,61 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
self.assertEqual("403 Forbidden", result["status"])
|
||||
self.assertNotIn(b"attacker", payload)
|
||||
|
||||
def test_expired_browser_session_and_provider_outage_fail_closed(self):
|
||||
oidc = OIDCClient(
|
||||
issuer="https://kc.example", client_id="portal",
|
||||
redirect_uri="https://users.example/oidc/callback", audience="portal",
|
||||
)
|
||||
oidc.sessions["expired"] = BrowserSession(
|
||||
claims=self.claims, expires_at=0, csrf_token="expired-csrf"
|
||||
)
|
||||
self.app.oidc_client = oidc
|
||||
expired, _ = invoke(
|
||||
self.app, "/onboarding", cookie="ue_session=expired"
|
||||
)
|
||||
self.assertEqual("403 Forbidden", expired["status"])
|
||||
|
||||
self.app.oidc_client = None
|
||||
self.app.provisioning = FailingProvisioning()
|
||||
created, payload = invoke(
|
||||
self.app, "/api/v1/tenants/tenant:friendly:binky/users",
|
||||
method="POST", claims=self.claims,
|
||||
body={"primary_email": "failure@example.test", "role": "user"},
|
||||
)
|
||||
user_id = json.loads(payload)["user"]["user_id"]
|
||||
before = self.app.service.store.tenant_account(
|
||||
"tenant:friendly:binky", user_id
|
||||
).status
|
||||
failed, payload = invoke_with_idempotency(
|
||||
self.app,
|
||||
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision",
|
||||
self.claims,
|
||||
)
|
||||
self.assertEqual("502 Bad Gateway", failed["status"])
|
||||
self.assertNotIn(b"provider-secret", payload)
|
||||
self.assertEqual(
|
||||
before,
|
||||
self.app.service.store.tenant_account(
|
||||
"tenant:friendly:binky", user_id
|
||||
).status,
|
||||
)
|
||||
|
||||
def test_verified_claims_create_current_user(self):
|
||||
result, payload = invoke(self.app, "/api/v1/me", claims=self.claims)
|
||||
self.assertEqual("200 OK", result["status"])
|
||||
decoded = json.loads(payload)
|
||||
self.assertEqual("tenant:friendly:binky", decoded["actor"]["tenant"])
|
||||
|
||||
updated, payload = invoke_with_idempotency(
|
||||
self.app, "/api/v1/me/profile", self.claims, method="PATCH",
|
||||
body={
|
||||
"display_name": "Sample Person", "consent_accepted": True,
|
||||
"consent_version": "portal-terms-v1",
|
||||
},
|
||||
)
|
||||
self.assertEqual("200 OK", updated["status"])
|
||||
self.assertEqual("Sample Person", json.loads(payload)["display_name"])
|
||||
|
||||
def test_registration_api_is_correlated(self):
|
||||
result, payload = invoke(
|
||||
self.app,
|
||||
|
|
@ -151,6 +216,217 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
self.assertEqual("200 OK", changed["status"])
|
||||
self.assertEqual("suspended", json.loads(payload)["status"])
|
||||
self.assertIn(("suspend", "ada"), self.app.provisioning.actions)
|
||||
removed, payload = invoke_with_idempotency(
|
||||
self.app,
|
||||
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}",
|
||||
self.claims, method="DELETE",
|
||||
)
|
||||
self.assertEqual("200 OK", removed["status"])
|
||||
self.assertEqual("removed", json.loads(payload)["status"])
|
||||
self.assertIn(("deprovision", "ada"), self.app.provisioning.actions)
|
||||
|
||||
def test_invitation_lifecycle_is_versioned_and_replay_safe(self):
|
||||
created, payload = invoke(
|
||||
self.app,
|
||||
"/api/v1/tenants/tenant:friendly:binky/invitations",
|
||||
method="POST", claims=self.claims,
|
||||
body={"primary_email": "invitee@example.test", "role": "user"},
|
||||
)
|
||||
self.assertEqual("201 Created", created["status"])
|
||||
invitation = json.loads(payload)["invitation"]
|
||||
invitation_id = invitation["invitation_id"]
|
||||
self.assertEqual(1, invitation["version"])
|
||||
self.assertIsNotNone(invitation["expires_at"])
|
||||
|
||||
duplicate, _ = invoke(
|
||||
self.app,
|
||||
"/api/v1/tenants/tenant:friendly:binky/invitations",
|
||||
method="POST", claims=self.claims,
|
||||
body={"primary_email": "INVITEE@example.test", "role": "user"},
|
||||
)
|
||||
self.assertEqual("409 Conflict", duplicate["status"])
|
||||
|
||||
listed, payload = invoke(
|
||||
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
|
||||
claims=self.claims,
|
||||
)
|
||||
self.assertEqual("200 OK", listed["status"])
|
||||
self.assertEqual(1, len(json.loads(payload)["items"]))
|
||||
|
||||
resent, payload = invoke(
|
||||
self.app,
|
||||
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/resend",
|
||||
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"1"'},
|
||||
)
|
||||
self.assertEqual("200 OK", resent["status"])
|
||||
self.assertEqual(2, json.loads(payload)["version"])
|
||||
conflict, _ = invoke(
|
||||
self.app,
|
||||
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/expire",
|
||||
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"1"'},
|
||||
)
|
||||
self.assertEqual("409 Conflict", conflict["status"])
|
||||
expired, payload = invoke(
|
||||
self.app,
|
||||
f"/api/v1/tenants/tenant:friendly:binky/invitations/{invitation_id}/expire",
|
||||
method="POST", claims=self.claims, headers={"HTTP_IF_MATCH": '"2"'},
|
||||
)
|
||||
self.assertEqual("200 OK", expired["status"])
|
||||
self.assertEqual("revoked", json.loads(payload)["status"])
|
||||
|
||||
claimable, payload = invoke(
|
||||
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
|
||||
method="POST", claims=self.claims,
|
||||
body={"primary_email": "sample.user@example.test", "role": "user"},
|
||||
)
|
||||
self.assertEqual("201 Created", claimable["status"])
|
||||
claim_id = json.loads(payload)["invitation"]["invitation_id"]
|
||||
claimed, _ = invoke(
|
||||
self.app, f"/api/v1/invitations/{claim_id}/claim",
|
||||
method="POST", claims=self.claims,
|
||||
)
|
||||
self.assertEqual("200 OK", claimed["status"])
|
||||
replayed, _ = invoke(
|
||||
self.app, f"/api/v1/invitations/{claim_id}/claim",
|
||||
method="POST", claims=self.claims,
|
||||
)
|
||||
self.assertEqual("400 Bad Request", replayed["status"])
|
||||
|
||||
timed, payload = invoke(
|
||||
self.app, "/api/v1/tenants/tenant:friendly:binky/invitations",
|
||||
method="POST", claims=self.claims,
|
||||
body={"primary_email": "expired@example.test", "role": "user"},
|
||||
)
|
||||
timed_id = json.loads(payload)["invitation"]["invitation_id"]
|
||||
timed_invitation = self.app.service.store.family_invitation(timed_id)
|
||||
self.app.service.store.save_family_invitation(replace(
|
||||
timed_invitation, expires_at=utc_now() - timedelta(seconds=1)
|
||||
))
|
||||
expired_claims = dict(self.claims)
|
||||
expired_claims["email"] = "expired@example.test"
|
||||
timed_out, _ = invoke(
|
||||
self.app, f"/api/v1/invitations/{timed_id}/claim",
|
||||
method="POST", claims=expired_claims,
|
||||
)
|
||||
self.assertEqual("400 Bad Request", timed_out["status"])
|
||||
|
||||
def test_outbox_delivery_replay_and_dead_letter(self):
|
||||
invoke(self.app, "/api/v1/me", claims=self.claims)
|
||||
actor = self.app.service.identity_adapter.normalize(self.claims)
|
||||
event = self.app.service.outbox_events()[0]
|
||||
|
||||
def unavailable(_event):
|
||||
raise RuntimeError("notification provider unavailable; token=redacted")
|
||||
|
||||
first = self.app.service.deliver_outbox(
|
||||
actor, unavailable, worker_id="worker-a", max_attempts=2
|
||||
)[0]
|
||||
self.assertEqual(1, first.delivery_attempts)
|
||||
self.assertIsNotNone(first.failed_at)
|
||||
self.assertIsNone(first.dead_lettered_at)
|
||||
replayed = self.app.service.replay_outbox(actor, event.event_id)
|
||||
self.assertIsNone(replayed.failure_reason)
|
||||
second = self.app.service.deliver_outbox(
|
||||
actor, unavailable, worker_id="worker-a", max_attempts=2
|
||||
)[0]
|
||||
self.assertIsNotNone(second.dead_lettered_at)
|
||||
self.assertLessEqual(len(second.failure_reason), 200)
|
||||
|
||||
replayed = self.app.service.replay_outbox(actor, event.event_id)
|
||||
delivered = self.app.service.deliver_outbox(
|
||||
actor, lambda _event: None, worker_id="worker-b"
|
||||
)[0]
|
||||
self.assertEqual(replayed.event_id, delivered.event_id)
|
||||
self.assertIsNotNone(delivered.delivered_at)
|
||||
self.assertNotIn(delivered, self.app.service.outbox_events())
|
||||
|
||||
def test_platform_tenant_bootstrap_recovery_and_outbox_transport(self):
|
||||
self.app.tenant_management = FakeTenantManagement()
|
||||
self.app.provisioning = FakeProvisioning()
|
||||
claims = self.platform_claims()
|
||||
denied, _ = invoke_with_idempotency(
|
||||
self.app, "/api/v1/platform/tenants", self.claims,
|
||||
body={"tenant": "tenant:friendly:new", "display_name": "New"},
|
||||
)
|
||||
self.assertEqual("403 Forbidden", denied["status"])
|
||||
created, payload = invoke_with_idempotency(
|
||||
self.app, "/api/v1/platform/tenants", claims,
|
||||
body={
|
||||
"tenant": "tenant:friendly:new", "display_name": "New Tenant",
|
||||
"first_admin": {"primary_email": "admin@new.test", "display_name": "Admin"},
|
||||
},
|
||||
)
|
||||
self.assertEqual("201 Created", created["status"])
|
||||
decoded = json.loads(payload)
|
||||
self.assertEqual("created", decoded["tenant"]["status"])
|
||||
self.assertEqual("tenant-admin", decoded["first_admin"]["membership"]["kind"])
|
||||
user_id = decoded["first_admin"]["user"]["user_id"]
|
||||
recovered, payload = invoke_with_idempotency(
|
||||
self.app,
|
||||
f"/api/v1/platform/tenants/tenant:friendly:new/users/{user_id}/recover",
|
||||
claims,
|
||||
)
|
||||
self.assertEqual("200 OK", recovered["status"])
|
||||
self.assertEqual("active", json.loads(payload)["tenant_account"]["status"])
|
||||
|
||||
self.app.outbox_delivery = lambda _event: None
|
||||
delivered, payload = invoke(
|
||||
self.app, "/api/v1/platform/outbox/deliver", method="POST",
|
||||
claims=claims, body={"worker_id": "test-worker"},
|
||||
)
|
||||
self.assertEqual("200 OK", delivered["status"])
|
||||
self.assertTrue(json.loads(payload)["items"])
|
||||
|
||||
def test_platform_browser_tenant_and_first_admin_bootstrap(self):
|
||||
claims = self.platform_claims()
|
||||
oidc = OIDCClient(
|
||||
issuer="https://kc.example", client_id="portal",
|
||||
redirect_uri="https://users.example/oidc/callback", audience="portal",
|
||||
)
|
||||
oidc.sessions["platform"] = BrowserSession(
|
||||
claims=claims, expires_at=9999999999, csrf_token="platform-csrf",
|
||||
)
|
||||
self.app.oidc_client = oidc
|
||||
self.app.tenant_management = FakeTenantManagement()
|
||||
self.app.provisioning = FakeProvisioning()
|
||||
page, html = invoke(self.app, "/platform", cookie="ue_session=platform")
|
||||
self.assertEqual("200 OK", page["status"])
|
||||
self.assertIn(b"First administrator", html)
|
||||
denied, _ = invoke(
|
||||
self.app, "/platform/tenants", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "wrong", "tenant": "tenant:friendly:browser",
|
||||
"display_name": "Browser Tenant",
|
||||
},
|
||||
)
|
||||
self.assertEqual("403 Forbidden", denied["status"])
|
||||
created, html = invoke(
|
||||
self.app, "/platform/tenants", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "platform-csrf", "tenant": "tenant:friendly:browser",
|
||||
"display_name": "Browser Tenant", "admin_display_name": "First Admin",
|
||||
"admin_email": "first-admin@browser.test",
|
||||
},
|
||||
)
|
||||
self.assertEqual("200 OK", created["status"])
|
||||
self.assertIn(b"awaiting onboarding", html)
|
||||
memberships = self.app.service.store.memberships_for_tenant(
|
||||
"tenant:friendly:browser"
|
||||
)
|
||||
self.assertEqual("tenant-admin", memberships[0].kind)
|
||||
admin_page, html = invoke(
|
||||
self.app, "/admin/tenant:friendly:browser", cookie="ue_session=platform"
|
||||
)
|
||||
self.assertEqual("200 OK", admin_page["status"])
|
||||
self.assertIn(b"Lifecycle diagnostics", html)
|
||||
self.assertIn(b"Recover identity", html)
|
||||
recovered, _ = invoke(
|
||||
self.app,
|
||||
f"/admin/tenant:friendly:browser/users/{memberships[0].user_id}/recover",
|
||||
method="POST", cookie="ue_session=platform",
|
||||
form={"csrf_token": "platform-csrf"},
|
||||
)
|
||||
self.assertEqual("303 See Other", recovered["status"])
|
||||
|
||||
def test_admin_form_requires_csrf_and_supports_two_step_provisioning(self):
|
||||
oidc = OIDCClient(
|
||||
|
|
@ -218,6 +494,112 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual("200 OK", page["status"])
|
||||
self.assertIn(b"Create password setup link", html)
|
||||
self.assertIn(b"Invite a user", html)
|
||||
self.assertIn(b"Remove account", html)
|
||||
|
||||
def test_browser_invitation_acceptance_and_onboarding_status(self):
|
||||
oidc = OIDCClient(
|
||||
issuer="https://kc.example", client_id="portal",
|
||||
redirect_uri="https://users.example/oidc/callback", audience="portal",
|
||||
)
|
||||
oidc.sessions["browser"] = BrowserSession(
|
||||
claims=self.claims, expires_at=9999999999, csrf_token="csrf-test-token",
|
||||
)
|
||||
self.app.oidc_client = oidc
|
||||
created, _ = invoke(
|
||||
self.app, "/admin/tenant:friendly:binky/invitations", method="POST",
|
||||
cookie="ue_session=browser", form={
|
||||
"csrf_token": "csrf-test-token", "display_name": "Invitee",
|
||||
"primary_email": "invitee@example.test", "role": "user",
|
||||
},
|
||||
)
|
||||
self.assertEqual("303 See Other", created["status"])
|
||||
invitation = next(iter(self.app.service.store.family_invitations.values()))
|
||||
page, html = invoke(
|
||||
self.app, f"/invitations/{invitation.invitation_id}",
|
||||
cookie="ue_session=browser",
|
||||
)
|
||||
self.assertEqual("200 OK", page["status"])
|
||||
self.assertIn(b"Accept invitation", html)
|
||||
|
||||
wrong, _ = invoke(
|
||||
self.app, f"/invitations/{invitation.invitation_id}", method="POST",
|
||||
cookie="ue_session=browser", form={"csrf_token": "csrf-test-token"},
|
||||
)
|
||||
self.assertEqual("403 Forbidden", wrong["status"])
|
||||
invitee_claims = human_actor_claims(
|
||||
subject="invitee", tenant="tenant:friendly:binky"
|
||||
)
|
||||
invitee_claims["email"] = "invitee@example.test"
|
||||
oidc.sessions["browser"] = BrowserSession(
|
||||
claims=invitee_claims, expires_at=9999999999,
|
||||
csrf_token="csrf-test-token",
|
||||
)
|
||||
accepted, _ = invoke(
|
||||
self.app, f"/invitations/{invitation.invitation_id}", method="POST",
|
||||
cookie="ue_session=browser", form={"csrf_token": "csrf-test-token"},
|
||||
)
|
||||
self.assertEqual("303 See Other", accepted["status"])
|
||||
self.assertEqual("/onboarding", accepted["headers"]["Location"])
|
||||
own_journey = OnboardingJourney(
|
||||
tenant="tenant:friendly:binky", user_id=invitation.user_id,
|
||||
protocol_id="protocol-self", trigger_type=OnboardingTriggerType.INVITATION,
|
||||
status=OnboardingJourneyStatus.IN_PROGRESS, active_step_key="profile-review",
|
||||
steps=(OnboardingStep(
|
||||
step_key="profile-review", title="Review your profile",
|
||||
subsystem="user-engine", status=OnboardingStepStatus.IN_PROGRESS,
|
||||
),),
|
||||
)
|
||||
provider_journey = OnboardingJourney(
|
||||
tenant="tenant:friendly:binky", user_id=invitation.user_id,
|
||||
protocol_id="protocol-provider", trigger_type=OnboardingTriggerType.INVITATION,
|
||||
status=OnboardingJourneyStatus.BLOCKED, active_step_key="mfa",
|
||||
steps=(OnboardingStep(
|
||||
step_key="mfa", title="Enroll MFA", subsystem="key-cape",
|
||||
status=OnboardingStepStatus.BLOCKED,
|
||||
handoff=SubsystemHandoff(
|
||||
subsystem="key-cape", status=OnboardingStepStatus.BLOCKED,
|
||||
),
|
||||
),),
|
||||
)
|
||||
self.app.service.store.save_onboarding_journey(own_journey)
|
||||
self.app.service.store.save_onboarding_journey(provider_journey)
|
||||
onboarding, html = invoke(
|
||||
self.app, "/onboarding", cookie="ue_session=browser"
|
||||
)
|
||||
self.assertEqual("200 OK", onboarding["status"])
|
||||
self.assertIn(b"Onboarding progress", html)
|
||||
self.assertIn(b"tenant:friendly:binky", html)
|
||||
self.assertIn(b"Profile and consent", html)
|
||||
self.assertIn(b"Mark complete", html)
|
||||
self.assertIn(b"provider-owned surface", html)
|
||||
completed, _ = invoke(
|
||||
self.app,
|
||||
f"/onboarding/{own_journey.journey_id}/steps/profile-review/complete",
|
||||
method="POST", cookie="ue_session=browser",
|
||||
form={"csrf_token": "csrf-test-token"},
|
||||
)
|
||||
self.assertEqual("303 See Other", completed["status"])
|
||||
provider_denied, _ = invoke(
|
||||
self.app,
|
||||
f"/onboarding/{provider_journey.journey_id}/steps/mfa/complete",
|
||||
method="POST", cookie="ue_session=browser",
|
||||
form={"csrf_token": "csrf-test-token"},
|
||||
)
|
||||
self.assertEqual("403 Forbidden", provider_denied["status"])
|
||||
saved, _ = invoke(
|
||||
self.app, "/onboarding/profile", method="POST",
|
||||
cookie="ue_session=browser", form={
|
||||
"csrf_token": "csrf-test-token", "display_name": "Updated Invitee",
|
||||
"consent_accepted": "yes",
|
||||
},
|
||||
)
|
||||
self.assertEqual("303 See Other", saved["status"])
|
||||
user = self.app.service.store.user(invitation.user_id)
|
||||
self.assertEqual("Updated Invitee", user.display_name)
|
||||
self.assertEqual("invitee@example.test", user.primary_email)
|
||||
self.assertEqual("portal-terms-v1", user.consent_version)
|
||||
self.assertIsNotNone(user.profile_completed_at)
|
||||
|
||||
|
||||
class FakeProvisioning:
|
||||
|
|
@ -241,6 +623,26 @@ class FakeProvisioning:
|
|||
self.actions.append(("reactivate", external_subject))
|
||||
return ProvisioningResult("netkingdom-lldap", external_subject, "active")
|
||||
|
||||
def deprovision(self, *, external_subject, idempotency_key, correlation_id):
|
||||
self.actions.append(("deprovision", external_subject))
|
||||
return ProvisioningResult("netkingdom-lldap", external_subject, "removed")
|
||||
|
||||
def reconcile(self, request, *, external_subject, desired_status="active"):
|
||||
self.actions.append(("reconcile", external_subject))
|
||||
return IdentityDriftResult(
|
||||
"netkingdom-lldap", external_subject, "in_sync", changed=("status",)
|
||||
)
|
||||
|
||||
|
||||
class FakeTenantManagement:
|
||||
def create_tenant(self, *, tenant, display_name, idempotency_key, correlation_id):
|
||||
return TenantProvisioningResult(tenant=tenant, status="created")
|
||||
|
||||
|
||||
class FailingProvisioning(FakeProvisioning):
|
||||
def provision(self, request):
|
||||
raise RuntimeError("provider-secret must never escape")
|
||||
|
||||
|
||||
def invoke_with_idempotency(app, path, claims, *, method="POST", body=None):
|
||||
payload = json.dumps(body or {}).encode()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue