From 2f9272f39d1f22ebac9da31350b4671991acba76 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 21:28:40 +0200 Subject: [PATCH] Add tenant-aware user-engine behavior --- docs/development.md | 9 + src/user_engine/__init__.py | 4 +- src/user_engine/adapters/local.py | 30 +++ src/user_engine/domain/__init__.py | 2 + src/user_engine/domain/models.py | 8 + src/user_engine/service.py | 266 +++++++++++++++++++++++- tests/test_multi_tenancy.py | 218 +++++++++++++++++++ workplans/USER-WP-0003-multi-tenancy.md | 16 +- 8 files changed, 533 insertions(+), 20 deletions(-) create mode 100644 tests/test_multi_tenancy.py diff --git a/docs/development.md b/docs/development.md index c40e690..395e22e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/src/user_engine/__init__.py b/src/user_engine/__init__.py index 00bed49..19be2c4 100644 --- a/src/user_engine/__init__.py +++ b/src/user_engine/__init__.py @@ -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" diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index ae71c8a..b4882c0 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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 diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index ba80be2..e956da5 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -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", diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index df61ae0..96caff1 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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 diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 3247f7c..2f67f4c 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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 = "" +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: diff --git a/tests/test_multi_tenancy.py b/tests/test_multi_tenancy.py new file mode 100644 index 0000000..e3687ab --- /dev/null +++ b/tests/test_multi_tenancy.py @@ -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() diff --git a/workplans/USER-WP-0003-multi-tenancy.md b/workplans/USER-WP-0003-multi-tenancy.md index fc9b49f..303affb 100644 --- a/workplans/USER-WP-0003-multi-tenancy.md +++ b/workplans/USER-WP-0003-multi-tenancy.md @@ -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" ```