Add tenant-aware user-engine behavior

This commit is contained in:
2026-05-22 21:28:40 +02:00
parent c1b02b8bba
commit 2f9272f39d
8 changed files with 533 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
"""Headless user-domain and profile engine."""
from user_engine.service import UserEngineService
from user_engine.service import PLATFORM_TENANT, UserEngineService
__all__ = ["UserEngineService", "__version__"]
__all__ = ["PLATFORM_TENANT", "UserEngineService", "__version__"]
__version__ = "0.0.0"

View File

@@ -15,9 +15,11 @@ from user_engine.domain import (
AuthorizationRequest,
Catalog,
ExternalIdentity,
Membership,
OutboxEvent,
ProfileScope,
ProfileValue,
TenantAccount,
User,
)
@@ -37,6 +39,8 @@ class InMemoryUserEngineStore:
users: dict[str, User] = field(default_factory=dict)
accounts: dict[str, Account] = field(default_factory=dict)
identities: dict[tuple[str, str], ExternalIdentity] = field(default_factory=dict)
tenant_accounts: dict[tuple[str, str], TenantAccount] = field(default_factory=dict)
memberships: dict[str, Membership] = field(default_factory=dict)
applications: dict[str, Application] = field(default_factory=dict)
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
catalogs: dict[str, Catalog] = field(default_factory=dict)
@@ -63,6 +67,12 @@ class InMemoryUserEngineStore:
def save_identity(self, identity: ExternalIdentity) -> None:
self.identities[identity.identity_key] = identity
def save_tenant_account(self, account: TenantAccount) -> None:
self.tenant_accounts[(account.tenant, account.user_id)] = account
def save_membership(self, membership: Membership) -> None:
self.memberships[membership.membership_id] = membership
def save_application(self, application: Application) -> None:
self.applications[application.application_id] = application
@@ -83,6 +93,26 @@ class InMemoryUserEngineStore:
def user_account(self, user_id: str) -> Account | None:
return self.accounts.get(user_id)
def tenant_account(self, tenant: str, user_id: str) -> TenantAccount | None:
return self.tenant_accounts.get((tenant, user_id))
def memberships_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[Membership, ...]:
return tuple(
membership
for membership in self.memberships.values()
if membership.user_id == user_id
and (tenant is None or membership.tenant == tenant)
)
def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]:
return tuple(
membership
for membership in self.memberships.values()
if membership.tenant == tenant
)
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
return tuple(
value for value in self.profile_values.values() if value.user_id == user_id

View File

@@ -23,6 +23,7 @@ from user_engine.domain.models import (
ProfileValue,
ProjectionType,
Sensitivity,
TenantAccount,
User,
Visibility,
new_id,
@@ -52,6 +53,7 @@ __all__ = [
"ProfileValue",
"ProjectionType",
"Sensitivity",
"TenantAccount",
"User",
"Visibility",
"new_id",

View File

@@ -137,6 +137,14 @@ class Account:
created_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class TenantAccount:
user_id: str
tenant: str
status: AccountStatus = AccountStatus.ACTIVE
created_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class ExternalIdentity:
identity_id: str

View File

@@ -18,12 +18,14 @@ from user_engine.domain import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
Membership,
Mutability,
OutboxEvent,
ProfileScope,
ProfileValue,
ProjectionType,
Sensitivity,
TenantAccount,
User,
Visibility,
new_id,
@@ -37,6 +39,9 @@ from user_engine.errors import (
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
REDACTED = "<redacted>"
PLATFORM_TENANT = "platform:root"
PLATFORM_OPERATOR_ROLE = "platform-operator"
TENANT_ADMIN_ROLE = "tenant-admin"
@dataclass(frozen=True)
@@ -59,6 +64,13 @@ class UserSession:
identities: tuple[ExternalIdentity, ...]
@dataclass(frozen=True)
class TenantContext:
tenant: str
actor: Actor
platform_operator: bool
@dataclass(frozen=True)
class EffectiveProfile:
user_id: str
@@ -77,6 +89,14 @@ class Projection:
redactions: Mapping[str, str]
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
checks: Mapping[str, bool]
issues: tuple[str, ...]
memberships: tuple[Membership, ...]
class UserEngineService:
"""Headless service API for isolated user and profile management."""
@@ -106,6 +126,23 @@ class UserEngineService:
},
)
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],
@@ -137,6 +174,7 @@ class UserEngineService:
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,
@@ -146,6 +184,7 @@ class UserEngineService:
)
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,
@@ -183,8 +222,10 @@ class UserEngineService:
)
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",
@@ -232,6 +273,103 @@ class UserEngineService:
)
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,
@@ -365,6 +503,7 @@ class UserEngineService:
*,
scope: ProfileScope = ProfileScope.GLOBAL,
scope_id: str | None = None,
tenant: str | None = None,
application_id: str | None = None,
correlation_id: str | None = None,
) -> ProfileValue:
@@ -372,13 +511,14 @@ class UserEngineService:
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=actor.tenant,
tenant=operation_tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
@@ -397,7 +537,7 @@ class UserEngineService:
actor,
action="profile.write",
subject=user_id,
tenant=actor.tenant,
tenant=operation_tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="profile.value_set",
@@ -418,20 +558,22 @@ class UserEngineService:
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=actor.tenant,
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)
return self._resolve_effective_profile(user_id, application_id, tenant_context.tenant)
def projection(
self,
@@ -440,21 +582,25 @@ class UserEngineService:
projection_type: ProjectionType,
*,
application_id: str | None = None,
tenant: str | None = None,
correlation_id: str | None = None,
) -> Projection:
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=actor.tenant,
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)
effective = self._resolve_effective_profile(
user_id, application_id, tenant_context.tenant
)
values: dict[str, Any] = {}
redactions: dict[str, str] = {}
for key, definition in self._active_attribute_definitions().items():
@@ -475,6 +621,39 @@ class UserEngineService:
redactions=redactions,
)
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)
@@ -511,7 +690,12 @@ class UserEngineService:
correlation_id=correlation_id,
application_id=application_id,
target_user_id=target_user_id,
context=context or {},
context=self._authorization_context(
actor,
target_user_id=target_user_id,
context=context,
tenant=tenant,
),
)
decision = self.authorization.check(request)
if not decision.allowed:
@@ -570,7 +754,7 @@ class UserEngineService:
)
def _resolve_effective_profile(
self, user_id: str, application_id: str | None
self, user_id: str, application_id: str | None, tenant: str | None
) -> EffectiveProfile:
self._require_user(user_id)
values: dict[str, Any] = {}
@@ -593,6 +777,18 @@ class UserEngineService:
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
@@ -613,6 +809,46 @@ class UserEngineService:
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")
@@ -650,7 +886,12 @@ class UserEngineService:
scope: ProfileScope,
scope_id: str | None,
) -> None:
allowed_scopes = {ProfileScope.GLOBAL, ProfileScope.APPLICATION, definition.scope}
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:
@@ -658,8 +899,13 @@ class UserEngineService:
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 application scoped values may set scope_id")
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: