user-engine/src/user_engine/service.py

2193 lines
78 KiB
Python
Raw Normal View History

2026-05-22 21:20:19 +02:00
"""Headless application service for the isolated user-engine MVP."""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any, Mapping
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import (
Account,
AccountStatus,
Actor,
Application,
ApplicationBinding,
AttributeDefinition,
AuditRecord,
AuthorizationRequest,
2026-06-05 16:04:43 +02:00
CanonEntityReference,
CanonRelationshipReference,
2026-05-22 21:20:19 +02:00
Catalog,
CatalogLifecycle,
ExternalIdentity,
2026-06-05 18:51:47 +02:00
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
2026-05-22 21:28:40 +02:00
Membership,
2026-05-22 21:20:19 +02:00
Mutability,
OutboxEvent,
ProfileScope,
ProfileValue,
ProjectionType,
Sensitivity,
2026-05-22 21:28:40 +02:00
TenantAccount,
2026-05-22 21:20:19 +02:00
User,
Visibility,
new_id,
2026-06-05 18:51:47 +02:00
utc_now,
2026-05-22 21:20:19 +02:00
)
from user_engine.errors import (
AuthorizationDenied,
ConflictError,
NotFoundError,
ValidationError,
)
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
REDACTED = "<redacted>"
2026-05-22 21:28:40 +02:00
PLATFORM_TENANT = "platform:root"
PLATFORM_OPERATOR_ROLE = "platform-operator"
TENANT_ADMIN_ROLE = "tenant-admin"
2026-05-22 21:20:19 +02:00
@dataclass(frozen=True)
class HealthReport:
status: str
schema_version: str | None
@dataclass(frozen=True)
class ReadinessReport:
ready: bool
checks: Mapping[str, bool]
@dataclass(frozen=True)
class UserSession:
actor: Actor
user: User
account: Account
identities: tuple[ExternalIdentity, ...]
2026-05-22 21:28:40 +02:00
@dataclass(frozen=True)
class TenantContext:
tenant: str
actor: Actor
platform_operator: bool
2026-05-22 21:20:19 +02:00
@dataclass(frozen=True)
class EffectiveProfile:
user_id: str
application_id: str | None
values: Mapping[str, Any]
source_layers: Mapping[str, str]
trace: Mapping[str, tuple[str, ...]]
@dataclass(frozen=True)
class Projection:
user_id: str
projection_type: ProjectionType
application_id: str | None
values: Mapping[str, Any]
redactions: Mapping[str, str]
2026-06-05 16:04:43 +02:00
@dataclass(frozen=True)
class IdentityContext:
actor: Actor
user: User
account: Account
identities: tuple[ExternalIdentity, ...]
tenant: str
application_id: str | None
memberships: tuple[Membership, ...]
entity_refs: Mapping[str, CanonEntityReference]
relationship_refs: tuple[CanonRelationshipReference, ...]
grant_like_refs: tuple[CanonEntityReference, ...]
evidence_refs: tuple[CanonEntityReference, ...]
profile: EffectiveProfile | None = None
gaps: tuple[str, ...] = ()
2026-06-05 18:51:47 +02:00
@dataclass(frozen=True)
class FamilyMemberInvitation:
user: User
tenant_account: TenantAccount
membership: Membership
invitation: FamilyInvitation
@dataclass(frozen=True)
class FamilyDataspaceOnboarding:
tenant: str
family_scope_id: str
family_display_name: str
owner_session: UserSession
owner_membership: Membership
application: Application
binding: ApplicationBinding
catalog: Catalog
invitations: tuple[FamilyMemberInvitation, ...]
identity_context: IdentityContext
claims_projection: Projection
@dataclass(frozen=True)
class FamilyInvitationAcceptance:
session: UserSession
invitation: FamilyInvitation
identity_context: IdentityContext
claims_projection: Projection
2026-05-22 21:28:40 +02:00
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
checks: Mapping[str, bool]
issues: tuple[str, ...]
memberships: tuple[Membership, ...]
@dataclass(frozen=True)
class OutboxDiagnostics:
pending_count: int
event_types: Mapping[str, int]
oldest_correlation_id: str | None
@dataclass(frozen=True)
class OperabilitySnapshot:
ready: bool
checks: Mapping[str, bool]
metrics: Mapping[str, int]
issues: tuple[str, ...]
2026-05-22 21:20:19 +02:00
class UserEngineService:
"""Headless service API for isolated user and profile management."""
def __init__(
self,
*,
store: InMemoryUserEngineStore,
identity_adapter: IdentityClaimsAdapter,
authorization: AuthorizationCheckPort,
) -> None:
self.store = store
self.identity_adapter = identity_adapter
self.authorization = authorization
if not self.store.ready:
self.store.migrate()
def health(self) -> HealthReport:
return HealthReport(status="ok", schema_version=self.store.schema_version)
def readiness(self) -> ReadinessReport:
return ReadinessReport(
ready=self.store.ready,
checks={
"store_schema": self.store.ready,
"identity_adapter": self.identity_adapter is not None,
"authorization_port": self.authorization is not None,
},
)
2026-05-22 21:28:40 +02:00
def resolve_tenant_context(
self, actor: Actor, tenant: str | None = None
) -> TenantContext:
resolved_tenant = tenant or actor.tenant
if not resolved_tenant:
raise ValidationError("tenant context is required")
platform_operator = PLATFORM_OPERATOR_ROLE in actor.roles
if resolved_tenant == PLATFORM_TENANT and not platform_operator:
raise AuthorizationDenied("platform tenant requires platform operator role")
if resolved_tenant != actor.tenant and not platform_operator:
raise AuthorizationDenied("cross-tenant access denied")
return TenantContext(
tenant=resolved_tenant,
actor=actor,
platform_operator=platform_operator,
)
2026-05-22 21:20:19 +02:00
def me(
self,
claims: Mapping[str, Any],
*,
correlation_id: str | None = None,
) -> UserSession:
actor = self.identity_adapter.normalize(claims)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="me.read",
resource_type="user-engine:me",
resource_id=actor.subject,
tenant=actor.tenant,
correlation_id=correlation_id,
)
identity = self.store.find_identity(*actor.identity_key)
if identity is not None:
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
return self._session(actor, user, account)
user = User(
display_name=actor.preferred_username,
primary_email=_optional_claim(actor, "email"),
)
account = Account(
account_id=new_id("acct"),
user_id=user.user_id,
status=AccountStatus.ACTIVE,
)
2026-05-22 21:28:40 +02:00
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
2026-05-22 21:20:19 +02:00
identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
)
self.store.save_user(user)
self.store.save_account(account)
2026-05-22 21:28:40 +02:00
self.store.save_tenant_account(tenant_account)
2026-05-22 21:20:19 +02:00
self.store.save_identity(identity)
self._record_mutation(
actor,
action="user.create_from_identity",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"account_id": account.account_id,
"identity": {"issuer": actor.issuer, "subject": actor.subject},
},
)
return self._session(actor, user, account)
def create_user(
self,
actor: Actor,
*,
display_name: str | None,
primary_email: str | None,
correlation_id: str | None = None,
) -> User:
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="user.create",
resource_type="user-engine:user",
resource_id="new",
tenant=actor.tenant,
correlation_id=correlation_id,
)
user = User(display_name=display_name, primary_email=primary_email)
account = Account(account_id=new_id("acct"), user_id=user.user_id)
2026-05-22 21:28:40 +02:00
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
2026-05-22 21:20:19 +02:00
self.store.save_user(user)
self.store.save_account(account)
2026-05-22 21:28:40 +02:00
self.store.save_tenant_account(tenant_account)
2026-05-22 21:20:19 +02:00
self._record_mutation(
actor,
action="user.create",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={"user_id": user.user_id, "account_id": account.account_id},
)
return user
def set_account_status(
self,
actor: Actor,
user_id: str,
status: AccountStatus,
*,
correlation_id: str | None = None,
) -> Account:
account = self._require_account(user_id)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="account.update",
resource_type="user-engine:account",
resource_id=account.account_id,
tenant=actor.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
updated = replace(account, status=status)
self.store.save_account(updated)
self._record_mutation(
actor,
action="account.update",
subject=user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="account.status_changed",
aggregate_id=account.account_id,
payload={"user_id": user_id, "status": status},
)
return updated
2026-05-22 21:28:40 +02:00
def set_tenant_account_status(
self,
actor: Actor,
user_id: str,
status: AccountStatus,
*,
tenant: str,
correlation_id: str | None = None,
) -> TenantAccount:
self._require_user(user_id)
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
account = self.store.tenant_account(tenant_context.tenant, user_id)
if account is None:
account = TenantAccount(user_id=user_id, tenant=tenant_context.tenant)
decision = self._authorize(
actor,
action="tenant.account.update",
resource_type="user-engine:tenant-account",
resource_id=f"{tenant_context.tenant}:{user_id}",
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
updated = replace(account, status=status)
self.store.save_tenant_account(updated)
self._record_mutation(
actor,
action="tenant.account.update",
subject=user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="tenant_account.status_changed",
aggregate_id=user_id,
payload={
"user_id": user_id,
"tenant": tenant_context.tenant,
"status": status,
},
)
return updated
def add_membership(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
scope_type: str,
scope_id: str,
kind: str,
correlation_id: str | None = None,
) -> Membership:
self._require_user(user_id)
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="membership.write",
resource_type="user-engine:membership",
resource_id=f"{tenant_context.tenant}:{user_id}:{scope_type}:{scope_id}",
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
context={"scope_type": scope_type, "scope_id": scope_id, "kind": kind},
)
membership = Membership(
membership_id=new_id("mem"),
user_id=user_id,
tenant=tenant_context.tenant,
scope_type=scope_type,
scope_id=scope_id,
kind=kind,
freshness_version=correlation_id,
)
self.store.save_membership(membership)
self._record_mutation(
actor,
action="membership.write",
subject=user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="membership.added",
aggregate_id=membership.membership_id,
payload={
"membership_id": membership.membership_id,
"user_id": user_id,
"tenant": tenant_context.tenant,
"scope_type": scope_type,
"scope_id": scope_id,
"kind": kind,
},
)
return membership
2026-05-22 21:20:19 +02:00
def link_identity(
self,
actor: Actor,
user_id: str,
*,
issuer: str,
subject: str,
provider: str | None = None,
correlation_id: str | None = None,
) -> ExternalIdentity:
self._require_user(user_id)
existing = self.store.find_identity(issuer, subject)
if existing is not None and existing.user_id != user_id:
raise ConflictError("external identity is already linked")
if existing is not None:
return existing
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="identity.link",
resource_type="user-engine:identity",
resource_id=f"{issuer}:{subject}",
tenant=actor.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user_id,
issuer=issuer,
subject=subject,
provider=provider,
)
self.store.save_identity(identity)
self._record_mutation(
actor,
action="identity.link",
subject=user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="identity.linked",
aggregate_id=user_id,
payload={"issuer": issuer, "subject": subject, "user_id": user_id},
)
return identity
def register_application(
self,
actor: Actor,
application: Application,
*,
binding: ApplicationBinding | None = None,
correlation_id: str | None = None,
) -> Application:
if application.application_id in self.store.applications:
raise ConflictError("application already exists")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="application.register",
resource_type="user-engine:application",
resource_id=application.application_id,
tenant=actor.tenant,
correlation_id=correlation_id,
application_id=application.application_id,
)
self.store.save_application(application)
if binding is not None:
if binding.application_id != application.application_id:
raise ValidationError("binding application id must match application")
self.store.save_binding(binding)
self._record_mutation(
actor,
action="application.register",
subject=application.application_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="application.registered",
aggregate_id=application.application_id,
payload={"application_id": application.application_id},
application_id=application.application_id,
)
return application
def publish_catalog(
self,
actor: Actor,
catalog: Catalog,
*,
correlation_id: str | None = None,
) -> Catalog:
self._validate_catalog(catalog)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="catalog.publish",
resource_type="user-engine:catalog",
resource_id=catalog.catalog_id,
tenant=actor.tenant,
correlation_id=correlation_id,
application_id=catalog.owning_application_id,
)
self.store.save_catalog(catalog)
self._record_mutation(
actor,
action="catalog.publish",
subject=catalog.catalog_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="catalog.published",
aggregate_id=catalog.catalog_id,
payload={
"catalog_id": catalog.catalog_id,
"namespace": catalog.namespace,
"version": catalog.version,
},
application_id=catalog.owning_application_id,
)
return catalog
def set_profile_value(
self,
actor: Actor,
user_id: str,
attribute_key: str,
value: Any,
*,
scope: ProfileScope = ProfileScope.GLOBAL,
scope_id: str | None = None,
2026-05-22 21:28:40 +02:00
tenant: str | None = None,
2026-05-22 21:20:19 +02:00
application_id: str | None = None,
correlation_id: str | None = None,
) -> ProfileValue:
self._require_user(user_id)
definition = self._require_attribute(attribute_key)
self._validate_profile_scope(definition, scope, scope_id)
self._validate_value(definition, value)
2026-05-22 21:28:40 +02:00
operation_tenant = self._operation_tenant(actor, tenant, scope, scope_id)
2026-05-22 21:20:19 +02:00
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="profile.write",
resource_type="user-engine:profile",
resource_id=f"{user_id}:{attribute_key}",
2026-05-22 21:28:40 +02:00
tenant=operation_tenant,
2026-05-22 21:20:19 +02:00
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"scope": scope, "scope_id": scope_id},
)
profile_value = ProfileValue(
user_id=user_id,
attribute_key=attribute_key,
value=value,
scope=scope,
scope_id=scope_id,
source=f"{actor.issuer}:{actor.subject}",
)
self.store.save_profile_value(profile_value)
self._record_mutation(
actor,
action="profile.write",
subject=user_id,
2026-05-22 21:28:40 +02:00
tenant=operation_tenant,
2026-05-22 21:20:19 +02:00
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="profile.value_set",
aggregate_id=user_id,
payload={
"user_id": user_id,
"attribute_key": attribute_key,
"scope": scope,
"scope_id": scope_id,
},
application_id=application_id,
)
return profile_value
def effective_profile(
self,
actor: Actor,
user_id: str,
*,
application_id: str | None = None,
2026-05-22 21:28:40 +02:00
tenant: str | None = None,
2026-05-22 21:20:19 +02:00
correlation_id: str | None = None,
) -> EffectiveProfile:
2026-05-22 21:28:40 +02:00
tenant_context = self.resolve_tenant_context(actor, tenant)
2026-05-22 21:20:19 +02:00
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="profile.read",
resource_type="user-engine:profile",
resource_id=user_id,
2026-05-22 21:28:40 +02:00
tenant=tenant_context.tenant,
2026-05-22 21:20:19 +02:00
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
)
2026-05-22 21:28:40 +02:00
return self._resolve_effective_profile(user_id, application_id, tenant_context.tenant)
2026-05-22 21:20:19 +02:00
def projection(
self,
actor: Actor,
user_id: str,
projection_type: ProjectionType,
*,
application_id: str | None = None,
2026-05-22 21:28:40 +02:00
tenant: str | None = None,
2026-05-22 21:20:19 +02:00
correlation_id: str | None = None,
) -> Projection:
2026-05-22 21:33:38 +02:00
if _is_application_projection(projection_type) and application_id is None:
raise ValidationError("application projections require application_id")
2026-05-22 21:28:40 +02:00
tenant_context = self.resolve_tenant_context(actor, tenant)
2026-05-22 21:20:19 +02:00
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="projection.read",
resource_type="user-engine:projection",
resource_id=user_id,
2026-05-22 21:28:40 +02:00
tenant=tenant_context.tenant,
2026-05-22 21:20:19 +02:00
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"projection_type": projection_type},
)
2026-05-22 21:28:40 +02:00
effective = self._resolve_effective_profile(
user_id, application_id, tenant_context.tenant
)
2026-05-22 21:33:38 +02:00
owning_application_id = (
application_id if _is_application_projection(projection_type) else None
)
2026-05-22 21:20:19 +02:00
values: dict[str, Any] = {}
redactions: dict[str, str] = {}
2026-05-22 21:33:38 +02:00
for key, definition in self._active_attribute_definitions(
owning_application_id=owning_application_id
).items():
2026-05-22 21:20:19 +02:00
if key not in effective.values:
continue
if not _visible_in_projection(definition, projection_type):
continue
if _must_redact(definition, projection_type):
values[key] = REDACTED
redactions[key] = "sensitivity"
else:
values[key] = effective.values[key]
return Projection(
user_id=user_id,
projection_type=projection_type,
application_id=application_id,
values=values,
redactions=redactions,
)
2026-06-05 16:04:43 +02:00
def identity_context(
self,
actor: Actor,
*,
user_id: str | None = None,
tenant: str | None = None,
application_id: str | None = None,
include_profile: bool = False,
correlation_id: str | None = None,
) -> IdentityContext:
tenant_context = self.resolve_tenant_context(actor, tenant)
identity = self.store.find_identity(*actor.identity_key)
if user_id is None:
if identity is None:
raise NotFoundError("identity link not found")
user_id = identity.user_id
user = self._require_user(user_id)
account = self._require_account(user_id)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="identity_context.read",
resource_type="user-engine:identity-context",
resource_id=user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"include_profile": include_profile},
)
identities = tuple(
identity
for identity in self.store.identities.values()
if identity.user_id == user.user_id
)
memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant
)
evidence_refs = self._identity_evidence_refs(
user,
account,
memberships,
tenant_context.tenant,
)
entity_refs = self._identity_entity_refs(
actor,
user,
account,
identities,
memberships,
tenant_context.tenant,
application_id,
)
relationship_refs, grant_like_refs = self._identity_relationship_refs(
entity_refs,
memberships,
tenant_context.tenant,
evidence_refs[0].identifier if evidence_refs else None,
)
profile = (
self._resolve_effective_profile(
user.user_id, application_id, tenant_context.tenant
)
if include_profile
else None
)
gaps = () if evidence_refs else ("evidence:no-audit-records",)
return IdentityContext(
actor=actor,
user=user,
account=account,
identities=identities,
tenant=tenant_context.tenant,
application_id=application_id,
memberships=memberships,
entity_refs=entity_refs,
relationship_refs=relationship_refs,
grant_like_refs=grant_like_refs,
evidence_refs=evidence_refs,
profile=profile,
gaps=gaps,
)
2026-06-05 18:51:47 +02:00
def onboard_family_dataspace(
self,
actor: Actor,
request: FamilyDataspaceRequest,
*,
correlation_id: str | None = None,
) -> FamilyDataspaceOnboarding:
tenant_context = self.resolve_tenant_context(actor, request.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_dataspace.onboard",
resource_type="user-engine:family-dataspace",
resource_id=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
context={"family_scope_id": request.family_scope_id},
)
owner_session = self._ensure_actor_session(actor, correlation_id)
application, binding = self._ensure_family_dataspace_application(
actor, request, correlation_id
)
catalog = self._ensure_family_dataspace_catalog(
actor, request, correlation_id
)
owner_membership = self._ensure_membership(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=request.family_scope_id,
kind=FamilyRole.OWNER.value,
correlation_id=correlation_id,
)
owner_defaults = dict(request.owner_profile_defaults)
owner_defaults.setdefault(
"member_display_name",
owner_session.user.display_name
or actor.preferred_username
or owner_session.user.primary_email
or actor.subject,
)
self._apply_family_profile_defaults(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
values=owner_defaults,
correlation_id=correlation_id,
)
invitations = tuple(
self.invite_family_member(
actor,
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
member=member,
correlation_id=correlation_id,
)
for member in request.member_specs
)
self._record_mutation(
actor,
action="family_dataspace.onboard",
subject=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_dataspace.onboarded",
aggregate_id=request.family_scope_id,
payload={
"tenant": tenant_context.tenant,
"family_scope_id": request.family_scope_id,
"application_id": request.application_id,
"member_invitation_count": len(invitations),
},
application_id=request.application_id,
)
identity_context = self.identity_context(
actor,
user_id=owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
owner_session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=request.application_id,
correlation_id=correlation_id,
)
return FamilyDataspaceOnboarding(
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
family_display_name=request.family_display_name,
owner_session=owner_session,
owner_membership=owner_membership,
application=application,
binding=binding,
catalog=catalog,
invitations=invitations,
identity_context=identity_context,
claims_projection=claims_projection,
)
def invite_family_member(
self,
actor: Actor,
*,
tenant: str,
family_scope_id: str,
application_id: str,
member: FamilyMemberSpec,
catalog_namespace: str = "dataspace",
correlation_id: str | None = None,
) -> 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 member.issuer and member.subject:
existing = self.store.find_identity(member.issuer, member.subject)
if existing is not None:
raise ConflictError("external identity is already linked")
decision = self._authorize(
actor,
action="family_member.invite",
resource_type="user-engine:family-invitation",
resource_id=member.primary_email,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
context={
"family_scope_id": family_scope_id,
"role": role,
},
)
user = self.create_user(
actor,
display_name=member.display_name,
primary_email=member.primary_email,
correlation_id=correlation_id,
)
tenant_account = self.set_tenant_account_status(
actor,
user.user_id,
AccountStatus.INVITED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
membership = self._ensure_membership(
actor,
user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=family_scope_id,
kind=role,
correlation_id=correlation_id,
)
profile_defaults = dict(member.profile_defaults)
if member.display_name:
profile_defaults.setdefault("member_display_name", member.display_name)
self._apply_family_profile_defaults(
actor,
user.user_id,
tenant=tenant_context.tenant,
application_id=application_id,
catalog_namespace=catalog_namespace,
values=profile_defaults,
correlation_id=correlation_id,
)
if member.issuer and member.subject:
self.link_identity(
actor,
user.user_id,
issuer=member.issuer,
subject=member.subject,
provider=member.provider,
correlation_id=correlation_id,
)
invitation = FamilyInvitation(
invitation_id=new_id("inv"),
tenant=tenant_context.tenant,
family_scope_id=family_scope_id,
application_id=application_id,
user_id=user.user_id,
primary_email=member.primary_email,
role=role,
invited_by=actor.subject,
correlation_id=correlation_id,
last_sent_correlation_id=correlation_id,
)
self.store.save_family_invitation(invitation)
self._record_mutation(
actor,
action="family_member.invite",
subject=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_member.invited",
aggregate_id=invitation.invitation_id,
payload={
"invitation_id": invitation.invitation_id,
"user_id": user.user_id,
"tenant": tenant_context.tenant,
"family_scope_id": family_scope_id,
"application_id": application_id,
"role": role,
},
application_id=application_id,
)
return FamilyMemberInvitation(
user=user,
tenant_account=tenant_account,
membership=membership,
invitation=invitation,
)
def resend_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be resent")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.resend",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
updated = replace(
invitation,
resend_count=invitation.resend_count + 1,
last_sent_correlation_id=correlation_id,
updated_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.resend",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.resent",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"resend_count": updated.resend_count,
},
application_id=updated.application_id,
)
return updated
def revoke_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be revoked")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.revoke",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.DISABLED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
updated = replace(
invitation,
status=InvitationStatus.REVOKED,
updated_at=utc_now(),
revoked_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.revoke",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.revoked",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"status": updated.status,
},
application_id=updated.application_id,
)
return updated
def accept_family_invitation(
self,
claims: Mapping[str, Any],
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitationAcceptance:
invitation = self._require_family_invitation(invitation_id)
if invitation.status == InvitationStatus.REVOKED:
raise ValidationError("revoked invitations cannot be accepted")
if invitation.status == InvitationStatus.ACCEPTED:
raise ValidationError("invitation is already accepted")
actor = self.identity_adapter.normalize(claims)
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.accept",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
context={"family_scope_id": invitation.family_scope_id},
)
self.link_identity(
actor,
invitation.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
correlation_id=correlation_id,
)
account = self.set_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
correlation_id=correlation_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
accepted_at = utc_now()
accepted = replace(
invitation,
status=InvitationStatus.ACCEPTED,
updated_at=accepted_at,
accepted_at=accepted_at,
)
self.store.save_family_invitation(accepted)
self._record_mutation(
actor,
action="family_invitation.accept",
subject=accepted.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.accepted",
aggregate_id=accepted.invitation_id,
payload={
"invitation_id": accepted.invitation_id,
"user_id": accepted.user_id,
"tenant": accepted.tenant,
"family_scope_id": accepted.family_scope_id,
"application_id": accepted.application_id,
"status": accepted.status,
},
application_id=accepted.application_id,
)
session = self._session(actor, self._require_user(accepted.user_id), account)
identity_context = self.identity_context(
actor,
user_id=accepted.user_id,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
accepted.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
correlation_id=correlation_id,
)
return FamilyInvitationAcceptance(
session=session,
invitation=accepted,
identity_context=identity_context,
claims_projection=claims_projection,
)
2026-05-22 21:28:40 +02:00
def tenant_diagnostics(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> TenantDiagnostics:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="tenant.diagnostics.read",
resource_type="user-engine:tenant",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
memberships = self.store.memberships_for_tenant(tenant_context.tenant)
checks = {
"store_schema": self.store.ready,
"authorization_port": self.authorization is not None,
"membership_facts": bool(memberships),
"catalog_scopes": bool(self._active_attribute_definitions()),
"audit_ready": self.store.ready,
}
issues = tuple(key for key, passed in checks.items() if not passed)
return TenantDiagnostics(
tenant=tenant_context.tenant,
checks=checks,
issues=issues,
memberships=memberships,
)
2026-05-22 21:20:19 +02:00
def audit_records(self) -> tuple[AuditRecord, ...]:
return tuple(self.store.audit_records)
def outbox_events(self) -> tuple[OutboxEvent, ...]:
return tuple(self.store.outbox_events)
def outbox_diagnostics(self) -> OutboxDiagnostics:
event_types: dict[str, int] = {}
for event in self.store.outbox_events:
event_types[event.event_type] = event_types.get(event.event_type, 0) + 1
oldest = self.store.outbox_events[0].correlation_id if self.store.outbox_events else None
return OutboxDiagnostics(
pending_count=len(self.store.outbox_events),
event_types=event_types,
oldest_correlation_id=oldest,
)
def operability_snapshot(self) -> OperabilitySnapshot:
audit_correlation_ok = all(
bool(record.correlation_id) for record in self.store.audit_records
)
checks = {
"ready": self.readiness().ready,
"audit_correlation": audit_correlation_ok,
"outbox_diagnostics": self.outbox_diagnostics().pending_count >= 0,
}
metrics = {
"users": len(self.store.users),
"accounts": len(self.store.accounts),
"tenant_accounts": len(self.store.tenant_accounts),
"memberships": len(self.store.memberships),
"applications": len(self.store.applications),
"catalogs": len(self.store.catalogs),
2026-06-05 18:51:47 +02:00
"family_invitations": len(self.store.family_invitations),
"profile_values": len(self.store.profile_values),
"audit_records": len(self.store.audit_records),
"pending_outbox_events": len(self.store.outbox_events),
}
issues = tuple(key for key, passed in checks.items() if not passed)
return OperabilitySnapshot(
ready=all(checks.values()),
checks=checks,
metrics=metrics,
issues=issues,
)
def structured_log_context(
self,
*,
correlation_id: str,
tenant: str,
actor: Actor | None = None,
) -> Mapping[str, str]:
context = {
"correlation_id": correlation_id,
"tenant": tenant,
}
if actor is not None:
context["actor_issuer"] = actor.issuer
context["actor_subject"] = actor.subject
return context
2026-06-05 18:51:47 +02:00
def _ensure_actor_session(
self, actor: Actor, correlation_id: str
) -> UserSession:
identity = self.store.find_identity(*actor.identity_key)
if identity is not None:
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
return self._session(actor, user, account)
decision = self._authorize(
actor,
action="me.read",
resource_type="user-engine:me",
resource_id=actor.subject,
tenant=actor.tenant,
correlation_id=correlation_id,
)
user = User(
display_name=actor.preferred_username,
primary_email=_optional_claim(actor, "email"),
)
account = Account(
account_id=new_id("acct"),
user_id=user.user_id,
status=AccountStatus.ACTIVE,
)
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
external_identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
)
self.store.save_user(user)
self.store.save_account(account)
self.store.save_tenant_account(tenant_account)
self.store.save_identity(external_identity)
self._record_mutation(
actor,
action="user.create_from_identity",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"account_id": account.account_id,
"identity": {"issuer": actor.issuer, "subject": actor.subject},
},
)
return self._session(actor, user, account)
def _ensure_family_dataspace_application(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> tuple[Application, ApplicationBinding]:
binding = _family_dataspace_binding(request)
application = self.store.applications.get(request.application_id)
if application is not None:
if request.application_id not in self.store.bindings:
decision = self._authorize(
actor,
action="application.bind",
resource_type="user-engine:application",
resource_id=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
)
self.store.save_binding(binding)
self._record_mutation(
actor,
action="application.bind",
subject=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="application.bound",
aggregate_id=request.application_id,
payload={
"application_id": request.application_id,
"catalog_namespaces": binding.catalog_namespaces,
},
application_id=request.application_id,
)
return application, self.store.bindings[request.application_id]
application = Application(
application_id=request.application_id,
display_name=request.application_display_name,
owner=request.family_scope_id,
allowed_profile_scopes=(
ProfileScope.GLOBAL,
ProfileScope.TENANT,
ProfileScope.APPLICATION,
ProfileScope.MEMBERSHIP,
),
allowed_projection_types=(
ProjectionType.APPLICATION_RUNTIME,
ProjectionType.CLAIMS_ENRICHMENT,
),
)
return (
self.register_application(
actor,
application,
binding=binding,
correlation_id=correlation_id,
),
binding,
)
def _ensure_family_dataspace_catalog(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> Catalog:
catalog_id = f"{request.application_id}.profile"
existing = self.store.catalogs.get(catalog_id)
if existing is not None:
return existing
catalog = Catalog(
catalog_id=catalog_id,
namespace=request.catalog_namespace,
version="0.1.0",
owning_application_id=request.application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
AttributeDefinition(
key=f"{request.catalog_namespace}.family_display_name",
value_type="string",
scope=ProfileScope.TENANT,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.ADMIN,),
default=request.family_display_name,
),
AttributeDefinition(
key=f"{request.catalog_namespace}.member_display_name",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.USER, Mutability.ADMIN),
),
AttributeDefinition(
key=f"{request.catalog_namespace}.home_view",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.USER, Visibility.APPLICATION),
mutability=(Mutability.USER,),
default="family",
validation={"enum": ["family", "personal"]},
),
),
)
return self.publish_catalog(
actor,
catalog,
correlation_id=correlation_id,
)
def _ensure_membership(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
scope_type: str,
scope_id: str,
kind: str,
correlation_id: str,
) -> Membership:
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
if (
membership.scope_type == scope_type
and membership.scope_id == scope_id
and membership.kind == kind
):
return membership
return self.add_membership(
actor,
user_id,
tenant=tenant,
scope_type=scope_type,
scope_id=scope_id,
kind=kind,
correlation_id=correlation_id,
)
def _apply_family_profile_defaults(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
application_id: str,
catalog_namespace: str,
values: Mapping[str, Any],
correlation_id: str,
) -> None:
for key, value in values.items():
self.set_profile_value(
actor,
user_id,
_family_profile_key(catalog_namespace, key),
value,
scope=ProfileScope.APPLICATION,
scope_id=application_id,
tenant=tenant,
application_id=application_id,
correlation_id=correlation_id,
)
def _require_family_invitation(self, invitation_id: str) -> FamilyInvitation:
invitation = self.store.family_invitation(invitation_id)
if invitation is None:
raise NotFoundError("family invitation not found")
return invitation
2026-06-05 16:04:43 +02:00
def _identity_entity_refs(
self,
actor: Actor,
user: User,
account: Account,
identities: tuple[ExternalIdentity, ...],
memberships: tuple[Membership, ...],
tenant: str,
application_id: str | None,
) -> Mapping[str, CanonEntityReference]:
actor_identifier = f"{actor.issuer}:{actor.subject}"
refs: dict[str, CanonEntityReference] = {
"actor": CanonEntityReference(
concept="Actor",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type=actor.principal_type.value,
),
"user": CanonEntityReference(
concept="User",
identifier=user.user_id,
scope=tenant,
local_type="user-engine-user-record",
),
"account": CanonEntityReference(
concept="Account",
identifier=account.account_id,
scope=tenant,
local_type=account.status.value,
),
"authenticated_subject": CanonEntityReference(
concept="Authenticated Subject",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type="issuer-subject",
),
"authorization_principal": CanonEntityReference(
concept="Authorization Principal",
identifier=f"{tenant}:{actor_identifier}",
scope=tenant,
local_type=actor.principal_type.value,
),
"tenant": CanonEntityReference(
concept="Tenant",
identifier=tenant,
scope=tenant,
local_type="isolation-scope",
),
}
if application_id is not None:
refs["application_scope"] = CanonEntityReference(
concept="Scope",
identifier=application_id,
scope=tenant,
local_type="application",
)
for identity in identities:
refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference(
concept="Identity Record",
identifier=identity.identity_id,
scope=identity.issuer,
source_system=identity.issuer,
local_type=identity.provider,
)
refs[f"identifier:{identity.identity_id}"] = CanonEntityReference(
concept="Scoped Identifier",
identifier=f"{identity.issuer}:{identity.subject}",
scope=identity.issuer,
source_system=identity.issuer,
local_type="issuer-subject",
)
for membership in memberships:
scope_key = f"{membership.scope_type}:{membership.scope_id}"
refs.setdefault(
scope_key,
CanonEntityReference(
concept=_scope_concept(membership.scope_type),
identifier=membership.scope_id,
scope=tenant,
local_type=membership.scope_type,
),
)
refs[f"membership:{membership.membership_id}"] = CanonEntityReference(
concept="Membership Relationship",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
)
refs[f"role:{membership.membership_id}"] = CanonEntityReference(
concept="Role",
identifier=f"{membership.scope_id}:{membership.kind}",
scope=membership.scope_id,
local_type=membership.kind,
)
return refs
def _identity_relationship_refs(
self,
entity_refs: Mapping[str, CanonEntityReference],
memberships: tuple[Membership, ...],
tenant: str,
evidence_id: str | None,
) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]:
account_ref = entity_refs["account"]
subject_ref = entity_refs["authenticated_subject"]
principal_ref = entity_refs["authorization_principal"]
user_ref = entity_refs["user"]
tenant_ref = entity_refs["tenant"]
relationships = [
CanonRelationshipReference(
relationship_type="belongs_to_tenant",
source=user_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="authenticates_as",
source=account_ref,
target=subject_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="evaluated_as",
source=subject_ref,
target=principal_ref,
scope=tenant,
evidence_id=evidence_id,
),
]
grant_like_refs: list[CanonEntityReference] = []
for key, ref in entity_refs.items():
if key.startswith("identity_record:"):
relationships.append(
CanonRelationshipReference(
relationship_type="identity_link",
source=ref,
target=user_ref,
scope=ref.scope,
evidence_id=evidence_id,
)
)
for membership in memberships:
membership_ref = entity_refs[f"membership:{membership.membership_id}"]
role_ref = entity_refs[f"role:{membership.membership_id}"]
scope_ref = entity_refs[f"{membership.scope_type}:{membership.scope_id}"]
grant_ref = CanonEntityReference(
concept="Access Grant",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
evidence_id=evidence_id,
)
grant_like_refs.append(grant_ref)
relationships.extend(
(
CanonRelationshipReference(
relationship_type="member_of",
source=user_ref,
target=scope_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="role_label",
source=membership_ref,
target=role_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="scoped_to",
source=grant_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
)
)
return tuple(relationships), tuple(grant_like_refs)
def _identity_evidence_refs(
self,
user: User,
account: Account,
memberships: tuple[Membership, ...],
tenant: str,
) -> tuple[CanonEntityReference, ...]:
membership_ids = {membership.membership_id for membership in memberships}
subjects = {user.user_id, account.account_id, *membership_ids}
evidence = []
for record in self.store.audit_records:
if record.tenant != tenant:
continue
if record.subject not in subjects and record.summary != "membership.added":
continue
evidence.append(
CanonEntityReference(
concept="Evidence Source",
identifier=record.audit_id,
scope=tenant,
source_system="user-engine:audit",
local_type=record.summary or record.action,
)
)
return tuple(evidence)
2026-05-22 21:20:19 +02:00
def _session(self, actor: Actor, user: User, account: Account) -> UserSession:
identities = tuple(
identity
for identity in self.store.identities.values()
if identity.user_id == user.user_id
)
return UserSession(actor=actor, user=user, account=account, identities=identities)
def _authorize(
self,
actor: Actor,
*,
action: str,
resource_type: str,
resource_id: str,
tenant: str,
correlation_id: str,
application_id: str | None = None,
target_user_id: str | None = None,
context: Mapping[str, Any] | None = None,
):
request = AuthorizationRequest(
actor=actor,
resource_type=resource_type,
resource_id=resource_id,
action=action,
tenant=tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=target_user_id,
2026-05-22 21:28:40 +02:00
context=self._authorization_context(
actor,
target_user_id=target_user_id,
context=context,
tenant=tenant,
),
2026-05-22 21:20:19 +02:00
)
decision = self.authorization.check(request)
if not decision.allowed:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=resource_id,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
application_id=application_id,
summary="authorization denied",
)
)
raise AuthorizationDenied(decision.reason or "authorization denied")
return decision
def _record_mutation(
self,
actor: Actor,
*,
action: str,
subject: str,
tenant: str,
correlation_id: str,
decision_id: str | None,
event_type: str,
aggregate_id: str,
payload: Mapping[str, Any],
application_id: str | None = None,
) -> None:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=subject,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision_id,
application_id=application_id,
summary=event_type,
)
)
self.store.append_outbox(
OutboxEvent(
event_id=new_id("evt"),
event_type=event_type,
aggregate_id=aggregate_id,
payload=dict(payload),
tenant=tenant,
correlation_id=correlation_id,
)
)
def _resolve_effective_profile(
2026-05-22 21:28:40 +02:00
self, user_id: str, application_id: str | None, tenant: str | None
2026-05-22 21:20:19 +02:00
) -> EffectiveProfile:
self._require_user(user_id)
values: dict[str, Any] = {}
source_layers: dict[str, str] = {}
trace: dict[str, list[str]] = {}
definitions = self._active_attribute_definitions()
for key, definition in definitions.items():
if definition.default is not None:
values[key] = definition.default
source_layers[key] = "catalog_default"
trace[key] = ["catalog_default"]
profile_values = self.store.values_for_user(user_id)
for profile_value in profile_values:
if profile_value.attribute_key not in definitions:
continue
if profile_value.scope != ProfileScope.GLOBAL:
continue
values[profile_value.attribute_key] = profile_value.value
source_layers[profile_value.attribute_key] = "global"
trace.setdefault(profile_value.attribute_key, []).append("global")
2026-05-22 21:28:40 +02:00
for profile_value in profile_values:
if profile_value.attribute_key not in definitions:
continue
if profile_value.scope != ProfileScope.TENANT:
continue
if profile_value.scope_id != tenant:
continue
values[profile_value.attribute_key] = profile_value.value
layer = f"tenant:{tenant}"
source_layers[profile_value.attribute_key] = layer
trace.setdefault(profile_value.attribute_key, []).append(layer)
2026-05-22 21:20:19 +02:00
for profile_value in profile_values:
if profile_value.attribute_key not in definitions:
continue
if profile_value.scope != ProfileScope.APPLICATION:
continue
if profile_value.scope_id != application_id:
continue
values[profile_value.attribute_key] = profile_value.value
layer = f"application:{application_id}"
source_layers[profile_value.attribute_key] = layer
trace.setdefault(profile_value.attribute_key, []).append(layer)
return EffectiveProfile(
user_id=user_id,
application_id=application_id,
values=values,
source_layers=source_layers,
trace={key: tuple(layers) for key, layers in trace.items()},
)
2026-05-22 21:28:40 +02:00
def _authorization_context(
self,
actor: Actor,
*,
target_user_id: str | None,
context: Mapping[str, Any] | None,
tenant: str,
) -> Mapping[str, Any]:
facts: dict[str, Any] = {
"actor_roles": actor.roles,
"actor_scopes": actor.scopes,
"assurance": dict(actor.assurance),
}
if target_user_id is not None:
memberships = self.store.memberships_for_user(target_user_id, tenant=tenant)
facts["target_memberships"] = tuple(
{
"membership_id": membership.membership_id,
"scope_type": membership.scope_type,
"scope_id": membership.scope_id,
"kind": membership.kind,
"freshness_version": membership.freshness_version,
}
for membership in memberships
)
if context:
facts.update(context)
return facts
def _operation_tenant(
self,
actor: Actor,
tenant: str | None,
scope: ProfileScope,
scope_id: str | None,
) -> str:
if scope == ProfileScope.TENANT:
return self.resolve_tenant_context(actor, scope_id).tenant
return self.resolve_tenant_context(actor, tenant).tenant
2026-05-22 21:20:19 +02:00
def _validate_catalog(self, catalog: Catalog) -> None:
if catalog.owning_application_id not in self.store.applications:
raise ValidationError("catalog owning application is not registered")
if catalog.lifecycle != CatalogLifecycle.ACTIVE:
raise ValidationError("only active catalogs can be published")
if not catalog.attributes:
raise ValidationError("catalog must define at least one attribute")
keys = [attribute.key for attribute in catalog.attributes]
if len(keys) != len(set(keys)):
raise ValidationError("catalog attribute keys must be unique")
binding = self.store.bindings.get(catalog.owning_application_id)
if binding and catalog.namespace not in binding.catalog_namespaces:
raise ValidationError("catalog namespace is not bound to application")
2026-05-22 21:33:38 +02:00
existing_catalog = self.store.catalogs.get(catalog.catalog_id)
if existing_catalog is not None:
self._validate_catalog_update(existing_catalog, catalog)
2026-05-22 21:20:19 +02:00
active_definitions = self._active_attribute_definitions(
excluding_catalog_id=catalog.catalog_id
)
2026-05-22 21:33:38 +02:00
active_catalogs = (
existing
for existing in self.store.catalogs.values()
if existing.catalog_id != catalog.catalog_id
and existing.lifecycle == CatalogLifecycle.ACTIVE
)
for existing in active_catalogs:
if (
existing.namespace == catalog.namespace
and existing.owning_application_id != catalog.owning_application_id
):
raise ConflictError("active catalog namespace is already owned")
2026-05-22 21:20:19 +02:00
for attribute in catalog.attributes:
if not attribute.key.startswith(f"{catalog.namespace}."):
raise ValidationError("attribute keys must use the catalog namespace")
if attribute.key in active_definitions:
raise ConflictError("active catalog already owns attribute key")
if not attribute.visibility:
raise ValidationError("attribute must declare visibility")
if not attribute.mutability:
raise ValidationError("attribute must declare mutability")
if Mutability.READ_ONLY in attribute.mutability and len(attribute.mutability) > 1:
raise ValidationError("read-only mutability cannot be combined")
if attribute.default is not None:
self._validate_value(attribute, attribute.default)
2026-05-22 21:33:38 +02:00
def _validate_catalog_update(self, previous: Catalog, catalog: Catalog) -> None:
if previous.namespace != catalog.namespace:
raise ValidationError("catalog namespace cannot change")
if previous.owning_application_id != catalog.owning_application_id:
raise ValidationError("catalog owner cannot change")
if _version_tuple(catalog.version) < _version_tuple(previous.version):
raise ValidationError("catalog version cannot move backwards")
previous_attributes = {
attribute.key: attribute for attribute in previous.attributes
}
for attribute in catalog.attributes:
previous_attribute = previous_attributes.get(attribute.key)
if previous_attribute is None:
continue
if _sensitivity_rank(attribute.sensitivity) < _sensitivity_rank(
previous_attribute.sensitivity
):
raise ValidationError("catalog update cannot downgrade sensitivity")
2026-05-22 21:20:19 +02:00
def _validate_profile_scope(
self,
definition: AttributeDefinition,
scope: ProfileScope,
scope_id: str | None,
) -> None:
2026-05-22 21:28:40 +02:00
allowed_scopes = {
ProfileScope.GLOBAL,
ProfileScope.TENANT,
ProfileScope.APPLICATION,
definition.scope,
}
2026-05-22 21:20:19 +02:00
if scope not in allowed_scopes:
raise ValidationError("profile value scope is not allowed for attribute")
if scope == ProfileScope.APPLICATION:
if scope_id is None:
raise ValidationError("application profile values require scope_id")
if scope_id not in self.store.applications:
raise ValidationError("application profile scope_id is not registered")
2026-05-22 21:28:40 +02:00
elif scope == ProfileScope.TENANT:
if scope_id is None:
raise ValidationError("tenant profile values require scope_id")
2026-05-22 21:20:19 +02:00
elif scope_id is not None:
2026-05-22 21:28:40 +02:00
raise ValidationError(
"only tenant or application scoped values may set scope_id"
)
2026-05-22 21:20:19 +02:00
def _validate_value(self, definition: AttributeDefinition, value: Any) -> None:
if value is None:
return
validators = {
"string": lambda item: isinstance(item, str),
"integer": lambda item: isinstance(item, int) and not isinstance(item, bool),
"number": lambda item: isinstance(item, (int, float))
and not isinstance(item, bool),
"boolean": lambda item: isinstance(item, bool),
"object": lambda item: isinstance(item, Mapping),
"array": lambda item: isinstance(item, (list, tuple)),
}
validator = validators.get(definition.value_type)
if validator is None:
raise ValidationError(f"unsupported attribute value_type: {definition.value_type}")
if not validator(value):
raise ValidationError(f"{definition.key} must be {definition.value_type}")
enum_values = definition.validation.get("enum")
if enum_values is not None and value not in enum_values:
raise ValidationError(f"{definition.key} is not an allowed value")
def _require_user(self, user_id: str) -> User:
user = self.store.users.get(user_id)
if user is None:
raise NotFoundError("user not found")
return user
def _require_account(self, user_id: str) -> Account:
account = self.store.user_account(user_id)
if account is None:
raise NotFoundError("account not found")
return account
def _require_attribute(self, attribute_key: str) -> AttributeDefinition:
definition = self._active_attribute_definitions().get(attribute_key)
if definition is None:
raise NotFoundError("active attribute definition not found")
return definition
def _active_attribute_definitions(
2026-05-22 21:33:38 +02:00
self,
*,
excluding_catalog_id: str | None = None,
owning_application_id: str | None = None,
2026-05-22 21:20:19 +02:00
) -> dict[str, AttributeDefinition]:
definitions: dict[str, AttributeDefinition] = {}
for catalog in self.store.catalogs.values():
if catalog.catalog_id == excluding_catalog_id:
continue
2026-05-22 21:33:38 +02:00
if (
owning_application_id is not None
and catalog.owning_application_id != owning_application_id
):
continue
2026-05-22 21:20:19 +02:00
if catalog.lifecycle != CatalogLifecycle.ACTIVE:
continue
for attribute in catalog.attributes:
definitions[attribute.key] = attribute
return dict(sorted(definitions.items()))
def _optional_claim(actor: Actor, key: str) -> str | None:
value = actor.claims.get(key)
if value is None:
return None
return str(value)
2026-06-05 16:04:43 +02:00
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",
"tenant": "Tenant",
"application": "Scope",
2026-06-05 18:51:47 +02:00
"family": "Group",
2026-06-05 16:04:43 +02:00
"group": "Group",
}
return concepts.get(scope_type, "Scope")
2026-06-05 18:51:47 +02:00
def _family_dataspace_binding(
request: FamilyDataspaceRequest,
) -> ApplicationBinding:
event_source = request.event_source or request.application_id
return ApplicationBinding(
application_id=request.application_id,
oidc_client_id=request.oidc_client_id,
protected_system_id=request.protected_system_id,
catalog_namespaces=(request.catalog_namespace,),
event_source=event_source,
deployment_ref=request.deployment_ref,
)
def _family_profile_key(catalog_namespace: str, key: str) -> str:
if key.startswith(f"{catalog_namespace}."):
return key
return f"{catalog_namespace}.{key}"
def _family_role_value(role: FamilyRole | str) -> str:
try:
return FamilyRole(str(role)).value
except ValueError as exc:
raise ValidationError(f"unsupported family role: {role}") from exc
2026-05-22 21:20:19 +02:00
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:
if projection_type == ProjectionType.AUDIT:
return True
visibility = {
ProjectionType.SELF_SERVICE: Visibility.USER,
ProjectionType.ADMIN: Visibility.ADMIN,
ProjectionType.APPLICATION_RUNTIME: Visibility.APPLICATION,
ProjectionType.AGENT_CONTEXT: Visibility.APPLICATION,
ProjectionType.CLAIMS_ENRICHMENT: Visibility.APPLICATION,
}.get(projection_type)
if visibility is None:
return False
return visibility in definition.visibility
2026-05-22 21:33:38 +02:00
def _is_application_projection(projection_type: ProjectionType) -> bool:
return projection_type in {
ProjectionType.APPLICATION_RUNTIME,
ProjectionType.AGENT_CONTEXT,
ProjectionType.CLAIMS_ENRICHMENT,
}
2026-05-22 21:20:19 +02:00
def _must_redact(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:
if projection_type in {
ProjectionType.ADMIN,
ProjectionType.AUDIT,
ProjectionType.SELF_SERVICE,
}:
return False
return definition.sensitivity in {Sensitivity.SENSITIVE, Sensitivity.SECRET}
2026-05-22 21:33:38 +02:00
def _version_tuple(version: str) -> tuple[int, int, int]:
parts = version.split(".")
if len(parts) != 3 or any(not part.isdigit() for part in parts):
raise ValidationError("catalog version must be semantic MAJOR.MINOR.PATCH")
return tuple(int(part) for part in parts)
def _sensitivity_rank(sensitivity: Sensitivity) -> int:
return {
Sensitivity.PUBLIC: 0,
Sensitivity.INTERNAL: 1,
Sensitivity.PERSONAL: 2,
Sensitivity.SENSITIVE: 3,
Sensitivity.SECRET: 4,
}[sensitivity]