generated from coulomb/repo-seed
Add tenant-aware user-engine behavior
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user