user-engine/src/user_engine/service.py

1144 lines
40 KiB
Python
Raw Normal View History

2026-05-22 21:20:19 +02:00
"""Headless application service for the isolated user-engine MVP."""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any, Mapping
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import (
Account,
AccountStatus,
Actor,
Application,
ApplicationBinding,
AttributeDefinition,
AuditRecord,
AuthorizationRequest,
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, ...]
@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)
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]