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
|
||||
store is `InMemoryUserEngineStore`, which carries an explicit schema version
|
||||
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."""
|
||||
|
||||
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:
|
||||
|
||||
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"
|
||||
domain: netkingdom
|
||||
repo: user-engine
|
||||
status: active
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
@@ -28,7 +28,7 @@ and tenant isolation tests.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3b6d67cc-be4d-4da3-b08c-f5919c1cb167"
|
||||
```
|
||||
@@ -37,7 +37,7 @@ Implement tenant identifiers, tenant context resolution, and request validation.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T2
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "9b8cb25a-eae5-4c6d-abdb-87fa73ba2cc6"
|
||||
```
|
||||
@@ -47,7 +47,7 @@ constraints.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a7abd6b0-c35a-4b3a-ae60-1d7db41398f8"
|
||||
```
|
||||
@@ -57,7 +57,7 @@ tenant admins.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "9deb9f46-d214-4311-9b19-7f61d75b4aaa"
|
||||
```
|
||||
@@ -67,7 +67,7 @@ and scope facts.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T5
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "ea8d4127-7ef1-4a7a-80fb-11c8f00c25c3"
|
||||
```
|
||||
@@ -76,7 +76,7 @@ Add tenant-aware audit records and outbox events.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7d1071a2-c85f-4a21-9842-fcb826c0172d"
|
||||
```
|
||||
@@ -86,7 +86,7 @@ platform-root denial, tenant profile precedence, and tenant membership changes.
|
||||
|
||||
```task
|
||||
id: USER-WP-0003-T7
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "6c9e6b82-9a8f-4017-96c3-5df9f3185154"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user