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

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

View file

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

View file

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

View file

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

View file

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