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

@ -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: