"""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, CanonEntityReference, CanonRelationshipReference, Catalog, CatalogLifecycle, ExternalIdentity, FamilyDataspaceRequest, FamilyInvitation, FamilyMemberSpec, FamilyRole, InvitationStatus, Membership, Mutability, OutboxEvent, ProfileScope, ProfileValue, ProjectionType, Sensitivity, TenantAccount, User, Visibility, new_id, utc_now, ) from user_engine.errors import ( AuthorizationDenied, ConflictError, NotFoundError, ValidationError, ) from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter REDACTED = "" PLATFORM_TENANT = "platform:root" PLATFORM_OPERATOR_ROLE = "platform-operator" TENANT_ADMIN_ROLE = "tenant-admin" @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, ...] @dataclass(frozen=True) class TenantContext: tenant: str actor: Actor platform_operator: bool @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] @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, ...] = () @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 @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, ...] 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, }, ) 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, ) 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, ) tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant) 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(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) tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant) self.store.save_user(user) self.store.save_account(account) self.store.save_tenant_account(tenant_account) 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 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 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, tenant: str | None = None, 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) operation_tenant = self._operation_tenant(actor, tenant, scope, scope_id) 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}", tenant=operation_tenant, 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, tenant=operation_tenant, 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, tenant: str | None = None, correlation_id: str | None = None, ) -> EffectiveProfile: tenant_context = self.resolve_tenant_context(actor, tenant) correlation_id = correlation_id or new_id("corr") self._authorize( actor, action="profile.read", resource_type="user-engine:profile", resource_id=user_id, tenant=tenant_context.tenant, correlation_id=correlation_id, application_id=application_id, target_user_id=user_id, ) return self._resolve_effective_profile(user_id, application_id, tenant_context.tenant) def projection( self, actor: Actor, user_id: str, projection_type: ProjectionType, *, application_id: str | None = None, tenant: str | None = None, correlation_id: str | None = None, ) -> Projection: if _is_application_projection(projection_type) and application_id is None: raise ValidationError("application projections require application_id") tenant_context = self.resolve_tenant_context(actor, tenant) correlation_id = correlation_id or new_id("corr") self._authorize( actor, action="projection.read", resource_type="user-engine:projection", resource_id=user_id, tenant=tenant_context.tenant, correlation_id=correlation_id, application_id=application_id, target_user_id=user_id, context={"projection_type": projection_type}, ) effective = self._resolve_effective_profile( user_id, application_id, tenant_context.tenant ) owning_application_id = ( application_id if _is_application_projection(projection_type) else None ) values: dict[str, Any] = {} redactions: dict[str, str] = {} for key, definition in self._active_attribute_definitions( owning_application_id=owning_application_id ).items(): 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, ) 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, ) 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, ) 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, ) 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), "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 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 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) 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, context=self._authorization_context( actor, target_user_id=target_user_id, context=context, tenant=tenant, ), ) 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( self, user_id: str, application_id: str | None, tenant: str | None ) -> 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") 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) 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()}, ) 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 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") existing_catalog = self.store.catalogs.get(catalog.catalog_id) if existing_catalog is not None: self._validate_catalog_update(existing_catalog, catalog) active_definitions = self._active_attribute_definitions( excluding_catalog_id=catalog.catalog_id ) 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") 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) 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") def _validate_profile_scope( self, definition: AttributeDefinition, scope: ProfileScope, scope_id: str | None, ) -> None: allowed_scopes = { ProfileScope.GLOBAL, ProfileScope.TENANT, ProfileScope.APPLICATION, definition.scope, } 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") elif scope == ProfileScope.TENANT: if scope_id is None: raise ValidationError("tenant profile values require scope_id") elif scope_id is not None: raise ValidationError( "only tenant or application scoped values may set scope_id" ) 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( self, *, excluding_catalog_id: str | None = None, owning_application_id: str | None = None, ) -> dict[str, AttributeDefinition]: definitions: dict[str, AttributeDefinition] = {} for catalog in self.store.catalogs.values(): if catalog.catalog_id == excluding_catalog_id: continue if ( owning_application_id is not None and catalog.owning_application_id != owning_application_id ): continue 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) def _scope_concept(scope_type: str) -> str: concepts = { "team": "Team", "tenant": "Tenant", "application": "Scope", "family": "Group", "group": "Group", } return concepts.get(scope_type, "Scope") 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 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 def _is_application_projection(projection_type: ProjectionType) -> bool: return projection_type in { ProjectionType.APPLICATION_RUNTIME, ProjectionType.AGENT_CONTEXT, ProjectionType.CLAIMS_ENRICHMENT, } 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} 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]