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,
|
|
|
|
|
Catalog,
|
|
|
|
|
CatalogLifecycle,
|
|
|
|
|
ExternalIdentity,
|
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,
|
|
|
|
|
)
|
|
|
|
|
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-05-22 21:28:40 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class TenantDiagnostics:
|
|
|
|
|
tenant: str
|
|
|
|
|
checks: Mapping[str, bool]
|
|
|
|
|
issues: tuple[str, ...]
|
|
|
|
|
memberships: tuple[Membership, ...]
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 21:45:30 +02:00
|
|
|
@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-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)
|
|
|
|
|
|
2026-05-22 21:45:30 +02:00
|
|
|
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),
|
|
|
|
|
"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-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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|