generated from coulomb/repo-seed
Add tenant-aware user-engine behavior
This commit is contained in:
@@ -52,3 +52,12 @@ registration, catalog publication, profile writes, effective profile
|
|||||||
resolution, projections, audit inspection, and outbox inspection. The first
|
resolution, projections, audit inspection, and outbox inspection. The first
|
||||||
store is `InMemoryUserEngineStore`, which carries an explicit schema version
|
store is `InMemoryUserEngineStore`, which carries an explicit schema version
|
||||||
and migration hook so later database-backed stores have a contract to match.
|
and migration hook so later database-backed stores have a contract to match.
|
||||||
|
|
||||||
|
## Tenant Surface
|
||||||
|
|
||||||
|
Tenant-aware operations resolve an explicit `TenantContext` before mutating
|
||||||
|
tenant-scoped state. Tenant admins can operate inside their own tenant, while
|
||||||
|
platform-root and cross-tenant operations require the `platform-operator`
|
||||||
|
role. Tenant account state, memberships, tenant profile layers, authorization
|
||||||
|
facts, audit records, outbox events, and diagnostics all carry the resolved
|
||||||
|
tenant.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Headless user-domain and profile engine."""
|
"""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"
|
__version__ = "0.0.0"
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ from user_engine.domain import (
|
|||||||
AuthorizationRequest,
|
AuthorizationRequest,
|
||||||
Catalog,
|
Catalog,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ class InMemoryUserEngineStore:
|
|||||||
users: dict[str, User] = field(default_factory=dict)
|
users: dict[str, User] = field(default_factory=dict)
|
||||||
accounts: dict[str, Account] = field(default_factory=dict)
|
accounts: dict[str, Account] = field(default_factory=dict)
|
||||||
identities: dict[tuple[str, str], ExternalIdentity] = 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)
|
applications: dict[str, Application] = field(default_factory=dict)
|
||||||
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
||||||
catalogs: dict[str, Catalog] = 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:
|
def save_identity(self, identity: ExternalIdentity) -> None:
|
||||||
self.identities[identity.identity_key] = identity
|
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:
|
def save_application(self, application: Application) -> None:
|
||||||
self.applications[application.application_id] = application
|
self.applications[application.application_id] = application
|
||||||
|
|
||||||
@@ -83,6 +93,26 @@ class InMemoryUserEngineStore:
|
|||||||
def user_account(self, user_id: str) -> Account | None:
|
def user_account(self, user_id: str) -> Account | None:
|
||||||
return self.accounts.get(user_id)
|
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, ...]:
|
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
|
||||||
return tuple(
|
return tuple(
|
||||||
value for value in self.profile_values.values() if value.user_id == user_id
|
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,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
Sensitivity,
|
Sensitivity,
|
||||||
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
Visibility,
|
Visibility,
|
||||||
new_id,
|
new_id,
|
||||||
@@ -52,6 +53,7 @@ __all__ = [
|
|||||||
"ProfileValue",
|
"ProfileValue",
|
||||||
"ProjectionType",
|
"ProjectionType",
|
||||||
"Sensitivity",
|
"Sensitivity",
|
||||||
|
"TenantAccount",
|
||||||
"User",
|
"User",
|
||||||
"Visibility",
|
"Visibility",
|
||||||
"new_id",
|
"new_id",
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ class Account:
|
|||||||
created_at: datetime = field(default_factory=utc_now)
|
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)
|
@dataclass(frozen=True)
|
||||||
class ExternalIdentity:
|
class ExternalIdentity:
|
||||||
identity_id: str
|
identity_id: str
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ from user_engine.domain import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
CatalogLifecycle,
|
CatalogLifecycle,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
Membership,
|
||||||
Mutability,
|
Mutability,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
Sensitivity,
|
Sensitivity,
|
||||||
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
Visibility,
|
Visibility,
|
||||||
new_id,
|
new_id,
|
||||||
@@ -37,6 +39,9 @@ from user_engine.errors import (
|
|||||||
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
|
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
|
||||||
|
|
||||||
REDACTED = "<redacted>"
|
REDACTED = "<redacted>"
|
||||||
|
PLATFORM_TENANT = "platform:root"
|
||||||
|
PLATFORM_OPERATOR_ROLE = "platform-operator"
|
||||||
|
TENANT_ADMIN_ROLE = "tenant-admin"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -59,6 +64,13 @@ class UserSession:
|
|||||||
identities: tuple[ExternalIdentity, ...]
|
identities: tuple[ExternalIdentity, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantContext:
|
||||||
|
tenant: str
|
||||||
|
actor: Actor
|
||||||
|
platform_operator: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EffectiveProfile:
|
class EffectiveProfile:
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -77,6 +89,14 @@ class Projection:
|
|||||||
redactions: Mapping[str, str]
|
redactions: Mapping[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantDiagnostics:
|
||||||
|
tenant: str
|
||||||
|
checks: Mapping[str, bool]
|
||||||
|
issues: tuple[str, ...]
|
||||||
|
memberships: tuple[Membership, ...]
|
||||||
|
|
||||||
|
|
||||||
class UserEngineService:
|
class UserEngineService:
|
||||||
"""Headless service API for isolated user and profile management."""
|
"""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(
|
def me(
|
||||||
self,
|
self,
|
||||||
claims: Mapping[str, Any],
|
claims: Mapping[str, Any],
|
||||||
@@ -137,6 +174,7 @@ class UserEngineService:
|
|||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
status=AccountStatus.ACTIVE,
|
status=AccountStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
|
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
|
||||||
identity = ExternalIdentity(
|
identity = ExternalIdentity(
|
||||||
identity_id=new_id("idn"),
|
identity_id=new_id("idn"),
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
@@ -146,6 +184,7 @@ class UserEngineService:
|
|||||||
)
|
)
|
||||||
self.store.save_user(user)
|
self.store.save_user(user)
|
||||||
self.store.save_account(account)
|
self.store.save_account(account)
|
||||||
|
self.store.save_tenant_account(tenant_account)
|
||||||
self.store.save_identity(identity)
|
self.store.save_identity(identity)
|
||||||
self._record_mutation(
|
self._record_mutation(
|
||||||
actor,
|
actor,
|
||||||
@@ -183,8 +222,10 @@ class UserEngineService:
|
|||||||
)
|
)
|
||||||
user = User(display_name=display_name, primary_email=primary_email)
|
user = User(display_name=display_name, primary_email=primary_email)
|
||||||
account = Account(account_id=new_id("acct"), user_id=user.user_id)
|
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_user(user)
|
||||||
self.store.save_account(account)
|
self.store.save_account(account)
|
||||||
|
self.store.save_tenant_account(tenant_account)
|
||||||
self._record_mutation(
|
self._record_mutation(
|
||||||
actor,
|
actor,
|
||||||
action="user.create",
|
action="user.create",
|
||||||
@@ -232,6 +273,103 @@ class UserEngineService:
|
|||||||
)
|
)
|
||||||
return updated
|
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(
|
def link_identity(
|
||||||
self,
|
self,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@@ -365,6 +503,7 @@ class UserEngineService:
|
|||||||
*,
|
*,
|
||||||
scope: ProfileScope = ProfileScope.GLOBAL,
|
scope: ProfileScope = ProfileScope.GLOBAL,
|
||||||
scope_id: str | None = None,
|
scope_id: str | None = None,
|
||||||
|
tenant: str | None = None,
|
||||||
application_id: str | None = None,
|
application_id: str | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> ProfileValue:
|
) -> ProfileValue:
|
||||||
@@ -372,13 +511,14 @@ class UserEngineService:
|
|||||||
definition = self._require_attribute(attribute_key)
|
definition = self._require_attribute(attribute_key)
|
||||||
self._validate_profile_scope(definition, scope, scope_id)
|
self._validate_profile_scope(definition, scope, scope_id)
|
||||||
self._validate_value(definition, value)
|
self._validate_value(definition, value)
|
||||||
|
operation_tenant = self._operation_tenant(actor, tenant, scope, scope_id)
|
||||||
correlation_id = correlation_id or new_id("corr")
|
correlation_id = correlation_id or new_id("corr")
|
||||||
decision = self._authorize(
|
decision = self._authorize(
|
||||||
actor,
|
actor,
|
||||||
action="profile.write",
|
action="profile.write",
|
||||||
resource_type="user-engine:profile",
|
resource_type="user-engine:profile",
|
||||||
resource_id=f"{user_id}:{attribute_key}",
|
resource_id=f"{user_id}:{attribute_key}",
|
||||||
tenant=actor.tenant,
|
tenant=operation_tenant,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
application_id=application_id,
|
application_id=application_id,
|
||||||
target_user_id=user_id,
|
target_user_id=user_id,
|
||||||
@@ -397,7 +537,7 @@ class UserEngineService:
|
|||||||
actor,
|
actor,
|
||||||
action="profile.write",
|
action="profile.write",
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
tenant=actor.tenant,
|
tenant=operation_tenant,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
decision_id=decision.decision_id,
|
decision_id=decision.decision_id,
|
||||||
event_type="profile.value_set",
|
event_type="profile.value_set",
|
||||||
@@ -418,20 +558,22 @@ class UserEngineService:
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
*,
|
*,
|
||||||
application_id: str | None = None,
|
application_id: str | None = None,
|
||||||
|
tenant: str | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> EffectiveProfile:
|
) -> EffectiveProfile:
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
correlation_id = correlation_id or new_id("corr")
|
correlation_id = correlation_id or new_id("corr")
|
||||||
self._authorize(
|
self._authorize(
|
||||||
actor,
|
actor,
|
||||||
action="profile.read",
|
action="profile.read",
|
||||||
resource_type="user-engine:profile",
|
resource_type="user-engine:profile",
|
||||||
resource_id=user_id,
|
resource_id=user_id,
|
||||||
tenant=actor.tenant,
|
tenant=tenant_context.tenant,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
application_id=application_id,
|
application_id=application_id,
|
||||||
target_user_id=user_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(
|
def projection(
|
||||||
self,
|
self,
|
||||||
@@ -440,21 +582,25 @@ class UserEngineService:
|
|||||||
projection_type: ProjectionType,
|
projection_type: ProjectionType,
|
||||||
*,
|
*,
|
||||||
application_id: str | None = None,
|
application_id: str | None = None,
|
||||||
|
tenant: str | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> Projection:
|
) -> Projection:
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
correlation_id = correlation_id or new_id("corr")
|
correlation_id = correlation_id or new_id("corr")
|
||||||
self._authorize(
|
self._authorize(
|
||||||
actor,
|
actor,
|
||||||
action="projection.read",
|
action="projection.read",
|
||||||
resource_type="user-engine:projection",
|
resource_type="user-engine:projection",
|
||||||
resource_id=user_id,
|
resource_id=user_id,
|
||||||
tenant=actor.tenant,
|
tenant=tenant_context.tenant,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
application_id=application_id,
|
application_id=application_id,
|
||||||
target_user_id=user_id,
|
target_user_id=user_id,
|
||||||
context={"projection_type": projection_type},
|
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] = {}
|
values: dict[str, Any] = {}
|
||||||
redactions: dict[str, str] = {}
|
redactions: dict[str, str] = {}
|
||||||
for key, definition in self._active_attribute_definitions().items():
|
for key, definition in self._active_attribute_definitions().items():
|
||||||
@@ -475,6 +621,39 @@ class UserEngineService:
|
|||||||
redactions=redactions,
|
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, ...]:
|
def audit_records(self) -> tuple[AuditRecord, ...]:
|
||||||
return tuple(self.store.audit_records)
|
return tuple(self.store.audit_records)
|
||||||
|
|
||||||
@@ -511,7 +690,12 @@ class UserEngineService:
|
|||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
application_id=application_id,
|
application_id=application_id,
|
||||||
target_user_id=target_user_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)
|
decision = self.authorization.check(request)
|
||||||
if not decision.allowed:
|
if not decision.allowed:
|
||||||
@@ -570,7 +754,7 @@ class UserEngineService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_effective_profile(
|
def _resolve_effective_profile(
|
||||||
self, user_id: str, application_id: str | None
|
self, user_id: str, application_id: str | None, tenant: str | None
|
||||||
) -> EffectiveProfile:
|
) -> EffectiveProfile:
|
||||||
self._require_user(user_id)
|
self._require_user(user_id)
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
@@ -593,6 +777,18 @@ class UserEngineService:
|
|||||||
source_layers[profile_value.attribute_key] = "global"
|
source_layers[profile_value.attribute_key] = "global"
|
||||||
trace.setdefault(profile_value.attribute_key, []).append("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:
|
for profile_value in profile_values:
|
||||||
if profile_value.attribute_key not in definitions:
|
if profile_value.attribute_key not in definitions:
|
||||||
continue
|
continue
|
||||||
@@ -613,6 +809,46 @@ class UserEngineService:
|
|||||||
trace={key: tuple(layers) for key, layers in trace.items()},
|
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:
|
def _validate_catalog(self, catalog: Catalog) -> None:
|
||||||
if catalog.owning_application_id not in self.store.applications:
|
if catalog.owning_application_id not in self.store.applications:
|
||||||
raise ValidationError("catalog owning application is not registered")
|
raise ValidationError("catalog owning application is not registered")
|
||||||
@@ -650,7 +886,12 @@ class UserEngineService:
|
|||||||
scope: ProfileScope,
|
scope: ProfileScope,
|
||||||
scope_id: str | None,
|
scope_id: str | None,
|
||||||
) -> 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:
|
if scope not in allowed_scopes:
|
||||||
raise ValidationError("profile value scope is not allowed for attribute")
|
raise ValidationError("profile value scope is not allowed for attribute")
|
||||||
if scope == ProfileScope.APPLICATION:
|
if scope == ProfileScope.APPLICATION:
|
||||||
@@ -658,8 +899,13 @@ class UserEngineService:
|
|||||||
raise ValidationError("application profile values require scope_id")
|
raise ValidationError("application profile values require scope_id")
|
||||||
if scope_id not in self.store.applications:
|
if scope_id not in self.store.applications:
|
||||||
raise ValidationError("application profile scope_id is not registered")
|
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:
|
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:
|
def _validate_value(self, definition: AttributeDefinition, value: Any) -> None:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
218
tests/test_multi_tenancy.py
Normal file
218
tests/test_multi_tenancy.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from user_engine.adapters.local import (
|
||||||
|
InMemoryUserEngineStore,
|
||||||
|
LocalAuthorizationCheckPort,
|
||||||
|
)
|
||||||
|
from user_engine.domain import AccountStatus, ProfileScope
|
||||||
|
from user_engine.errors import AuthorizationDenied
|
||||||
|
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||||
|
from user_engine.testing.fixtures import (
|
||||||
|
FixtureIdentityClaimsAdapter,
|
||||||
|
human_actor_claims,
|
||||||
|
sample_application,
|
||||||
|
sample_application_binding,
|
||||||
|
sample_catalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTenancyTests(unittest.TestCase):
|
||||||
|
def test_tenant_context_denies_cross_tenant_and_platform_root(self):
|
||||||
|
service, _, _ = _service()
|
||||||
|
session = service.me(_claims(), correlation_id="corr-me")
|
||||||
|
|
||||||
|
with self.assertRaises(AuthorizationDenied):
|
||||||
|
service.resolve_tenant_context(session.actor, "tenant:faraday")
|
||||||
|
|
||||||
|
with self.assertRaises(AuthorizationDenied):
|
||||||
|
service.resolve_tenant_context(session.actor, PLATFORM_TENANT)
|
||||||
|
|
||||||
|
def test_tenant_account_membership_and_diagnostics(self):
|
||||||
|
service, _, _ = _service()
|
||||||
|
session = _bootstrap(service)
|
||||||
|
|
||||||
|
account = service.set_tenant_account_status(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
AccountStatus.ACTIVE,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-tenant-account",
|
||||||
|
)
|
||||||
|
membership = service.add_membership(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
scope_type="team",
|
||||||
|
scope_id="team:demo",
|
||||||
|
kind="admin",
|
||||||
|
correlation_id="corr-membership",
|
||||||
|
)
|
||||||
|
diagnostics = service.tenant_diagnostics(
|
||||||
|
session.actor,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-diagnostics",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(account.tenant, "tenant:coulomb")
|
||||||
|
self.assertEqual(membership.tenant, "tenant:coulomb")
|
||||||
|
self.assertTrue(diagnostics.checks["membership_facts"])
|
||||||
|
self.assertNotIn("membership_facts", diagnostics.issues)
|
||||||
|
self.assertIn(
|
||||||
|
"membership.added",
|
||||||
|
[event.event_type for event in service.outbox_events()],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tenant_profile_precedes_global_and_app_precedes_tenant(self):
|
||||||
|
service, _, _ = _service()
|
||||||
|
session = _bootstrap(service)
|
||||||
|
|
||||||
|
service.set_profile_value(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
"demo.display_density",
|
||||||
|
"compact",
|
||||||
|
correlation_id="corr-global",
|
||||||
|
)
|
||||||
|
service.set_profile_value(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
"demo.display_density",
|
||||||
|
"comfortable",
|
||||||
|
scope=ProfileScope.TENANT,
|
||||||
|
scope_id="tenant:coulomb",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-tenant-profile",
|
||||||
|
)
|
||||||
|
tenant_profile = service.effective_profile(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-effective-tenant",
|
||||||
|
)
|
||||||
|
service.set_profile_value(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
"demo.display_density",
|
||||||
|
"compact",
|
||||||
|
scope=ProfileScope.APPLICATION,
|
||||||
|
scope_id="app.demo",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
application_id="app.demo",
|
||||||
|
correlation_id="corr-app-profile",
|
||||||
|
)
|
||||||
|
app_profile = service.effective_profile(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
application_id="app.demo",
|
||||||
|
correlation_id="corr-effective-app",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(tenant_profile.values["demo.display_density"], "comfortable")
|
||||||
|
self.assertEqual(
|
||||||
|
tenant_profile.source_layers["demo.display_density"],
|
||||||
|
"tenant:tenant:coulomb",
|
||||||
|
)
|
||||||
|
self.assertEqual(app_profile.values["demo.display_density"], "compact")
|
||||||
|
self.assertEqual(
|
||||||
|
app_profile.source_layers["demo.display_density"],
|
||||||
|
"application:app.demo",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authorization_context_includes_membership_and_assurance_facts(self):
|
||||||
|
service, _, authz = _service()
|
||||||
|
session = _bootstrap(service)
|
||||||
|
service.add_membership(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
scope_type="team",
|
||||||
|
scope_id="team:demo",
|
||||||
|
kind="member",
|
||||||
|
correlation_id="corr-membership",
|
||||||
|
)
|
||||||
|
|
||||||
|
service.set_profile_value(
|
||||||
|
session.actor,
|
||||||
|
session.user.user_id,
|
||||||
|
"demo.display_density",
|
||||||
|
"compact",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
request = authz.requests[-1]
|
||||||
|
self.assertEqual(request.tenant, "tenant:coulomb")
|
||||||
|
self.assertIn("tenant-admin", request.context["actor_roles"])
|
||||||
|
self.assertTrue(request.context["assurance"]["mfa"])
|
||||||
|
self.assertEqual(len(request.context["target_memberships"]), 1)
|
||||||
|
|
||||||
|
def test_platform_operator_can_manage_another_tenant(self):
|
||||||
|
service, _, _ = _service()
|
||||||
|
platform_session = service.me(
|
||||||
|
_claims(
|
||||||
|
subject="platform-operator",
|
||||||
|
tenant=PLATFORM_TENANT,
|
||||||
|
roles=("platform-operator",),
|
||||||
|
),
|
||||||
|
correlation_id="corr-platform-me",
|
||||||
|
)
|
||||||
|
managed_user = service.create_user(
|
||||||
|
platform_session.actor,
|
||||||
|
display_name="Managed User",
|
||||||
|
primary_email="managed@example.test",
|
||||||
|
correlation_id="corr-create-managed",
|
||||||
|
)
|
||||||
|
|
||||||
|
account = service.set_tenant_account_status(
|
||||||
|
platform_session.actor,
|
||||||
|
managed_user.user_id,
|
||||||
|
AccountStatus.ACTIVE,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-platform-tenant-account",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(account.tenant, "tenant:coulomb")
|
||||||
|
self.assertEqual(account.status, AccountStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def _service():
|
||||||
|
store = InMemoryUserEngineStore()
|
||||||
|
authz = LocalAuthorizationCheckPort()
|
||||||
|
service = UserEngineService(
|
||||||
|
store=store,
|
||||||
|
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||||
|
authorization=authz,
|
||||||
|
)
|
||||||
|
return service, store, authz
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap(service: UserEngineService):
|
||||||
|
session = service.me(_claims(), correlation_id="corr-me")
|
||||||
|
service.register_application(
|
||||||
|
session.actor,
|
||||||
|
sample_application(),
|
||||||
|
binding=sample_application_binding(),
|
||||||
|
correlation_id="corr-app",
|
||||||
|
)
|
||||||
|
service.publish_catalog(
|
||||||
|
session.actor,
|
||||||
|
sample_catalog(),
|
||||||
|
correlation_id="corr-catalog",
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _claims(
|
||||||
|
*,
|
||||||
|
subject: str = "tenant-admin",
|
||||||
|
tenant: str = "tenant:coulomb",
|
||||||
|
roles: tuple[str, ...] = ("tenant-admin",),
|
||||||
|
):
|
||||||
|
claims = human_actor_claims(subject=subject, tenant=tenant)
|
||||||
|
claims["roles"] = list(roles)
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "User Engine Multi-Tenancy"
|
title: "User Engine Multi-Tenancy"
|
||||||
domain: netkingdom
|
domain: netkingdom
|
||||||
repo: user-engine
|
repo: user-engine
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -28,7 +28,7 @@ and tenant isolation tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T1
|
id: USER-WP-0003-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3b6d67cc-be4d-4da3-b08c-f5919c1cb167"
|
state_hub_task_id: "3b6d67cc-be4d-4da3-b08c-f5919c1cb167"
|
||||||
```
|
```
|
||||||
@@ -37,7 +37,7 @@ Implement tenant identifiers, tenant context resolution, and request validation.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T2
|
id: USER-WP-0003-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9b8cb25a-eae5-4c6d-abdb-87fa73ba2cc6"
|
state_hub_task_id: "9b8cb25a-eae5-4c6d-abdb-87fa73ba2cc6"
|
||||||
```
|
```
|
||||||
@@ -47,7 +47,7 @@ constraints.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T3
|
id: USER-WP-0003-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a7abd6b0-c35a-4b3a-ae60-1d7db41398f8"
|
state_hub_task_id: "a7abd6b0-c35a-4b3a-ae60-1d7db41398f8"
|
||||||
```
|
```
|
||||||
@@ -57,7 +57,7 @@ tenant admins.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T4
|
id: USER-WP-0003-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9deb9f46-d214-4311-9b19-7f61d75b4aaa"
|
state_hub_task_id: "9deb9f46-d214-4311-9b19-7f61d75b4aaa"
|
||||||
```
|
```
|
||||||
@@ -67,7 +67,7 @@ and scope facts.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T5
|
id: USER-WP-0003-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ea8d4127-7ef1-4a7a-80fb-11c8f00c25c3"
|
state_hub_task_id: "ea8d4127-7ef1-4a7a-80fb-11c8f00c25c3"
|
||||||
```
|
```
|
||||||
@@ -76,7 +76,7 @@ Add tenant-aware audit records and outbox events.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T6
|
id: USER-WP-0003-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7d1071a2-c85f-4a21-9842-fcb826c0172d"
|
state_hub_task_id: "7d1071a2-c85f-4a21-9842-fcb826c0172d"
|
||||||
```
|
```
|
||||||
@@ -86,7 +86,7 @@ platform-root denial, tenant profile precedence, and tenant membership changes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0003-T7
|
id: USER-WP-0003-T7
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "6c9e6b82-9a8f-4017-96c3-5df9f3185154"
|
state_hub_task_id: "6c9e6b82-9a8f-4017-96c3-5df9f3185154"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user