Implement role-based account journeys with database and browser acceptance suites
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s
Account journey acceptance / journeys (push) Failing after 0s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 12:20:02 +02:00
parent 75750c0036
commit 1127f852dd
24 changed files with 1554 additions and 148 deletions

View file

@ -1837,7 +1837,69 @@ class UserEngineService:
)
return updated
def set_tenant_account_status(
def authorize_tenant_member_action(
self, actor: Actor, user_id: str, *, tenant: str, correlation_id: str,
) -> User:
"""Authorize before any credential-provider side effect or target readout."""
self.resolve_tenant_context(actor, tenant)
if not {"tenant-admin", PLATFORM_OPERATOR_ROLE}.intersection(actor.roles):
raise AuthorizationDenied("tenant administrator role required")
memberships = self.store.memberships_for_user(user_id, tenant=tenant)
if not any(m.scope_type == "tenant" and m.scope_id == tenant for m in memberships):
raise NotFoundError("account is not a member of this tenant")
self._authorize(actor, action="tenant.account.update",
resource_type="user-engine:tenant-account",
resource_id=f"{tenant}:{user_id}", tenant=tenant,
correlation_id=correlation_id, target_user_id=user_id)
return self._require_user(user_id)
def validate_tenant_role_change(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
self.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
if role not in {"user", "tenant-admin"}:
raise ValidationError("Choose User or Tenant administrator.")
self._authorize(actor, action="membership.write", resource_type="user-engine:membership",
resource_id=f"{tenant}:{user_id}:tenant:{tenant}", tenant=tenant,
correlation_id=correlation_id, target_user_id=user_id,
context={"scope_type":"tenant", "scope_id":tenant, "kind":role})
if role != "tenant-admin":
self.require_admin_successor(user_id, tenant=tenant)
def set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
with self.store.tenant_lifecycle_guard(tenant):
self._set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
def _set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
self.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
with self.store.transaction():
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
if membership.scope_type == "tenant" and membership.scope_id == tenant:
self.store.save_membership(replace(membership, kind=role, freshness_version=correlation_id))
self._record_mutation(actor, action="membership.write", subject=user_id, tenant=tenant,
correlation_id=correlation_id, decision_id=None,
event_type="membership.role_changed", aggregate_id=user_id,
payload={"user_id":user_id, "tenant":tenant, "role":role})
def require_admin_successor(self, user_id: str, *, tenant: str) -> None:
"""Do not deactivate the last active tenant administrator."""
members = self.store.memberships_for_tenant(tenant)
admins = {m.user_id for m in members if m.scope_type == "tenant"
and m.scope_id == tenant and m.kind == "tenant-admin"}
if user_id not in admins:
return
account = self.store.tenant_account(tenant, user_id)
if account is not None and account.status != AccountStatus.ACTIVE:
return
for other in admins - {user_id}:
state = self.store.tenant_account(tenant, other)
if state is not None and state.status == AccountStatus.ACTIVE:
return
raise ConflictError("Assign another active tenant administrator before disabling this account.")
def set_tenant_account_status(self, actor: Actor, user_id: str, status: AccountStatus, *, tenant: str, correlation_id: str | None = None) -> TenantAccount:
with self.store.tenant_lifecycle_guard(tenant):
return self._set_tenant_account_status(actor, user_id, status, tenant=tenant, correlation_id=correlation_id)
def _set_tenant_account_status(
self,
actor: Actor,
user_id: str,
@ -1863,6 +1925,8 @@ class UserEngineService:
)
updated = replace(account, status=status)
with self.store.transaction():
if status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
self.require_admin_successor(user_id, tenant=tenant_context.tenant)
self.store.save_tenant_account(updated)
self._record_mutation(
actor,