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

@@ -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.

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:

218
tests/test_multi_tenancy.py Normal file
View 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()

View File

@@ -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"
```