From 1440a597dfca6973385f75ecf1418bde23fbe8ee Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 21:33:38 +0200 Subject: [PATCH] Add multi-application catalog support --- README.md | 4 +- docs/examples.md | 68 ++++ src/user_engine/__init__.py | 8 +- src/user_engine/projections.py | 49 +++ src/user_engine/service.py | 81 ++++- tests/test_multi_application_catalogs.py | 312 ++++++++++++++++++ ...USER-WP-0004-multi-application-catalogs.md | 16 +- 7 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 docs/examples.md create mode 100644 src/user_engine/projections.py create mode 100644 tests/test_multi_application_catalogs.py diff --git a/README.md b/README.md index 56bed5d..77a5b2f 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,5 @@ Headless multi-application, multi-tenant user management engine. make test ``` -See `docs/development.md` and `docs/configuration.md` for the initial -implementation boundaries. +See `docs/development.md`, `docs/configuration.md`, and `docs/examples.md` +for implementation boundaries and local usage examples. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..ed91c58 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,68 @@ +# Examples + +## Register An Application And Catalog + +```python +from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort +from user_engine.service import UserEngineService +from user_engine.testing.fixtures import ( + FixtureIdentityClaimsAdapter, + human_actor_claims, + sample_application, + sample_application_binding, + sample_catalog, +) + +service = UserEngineService( + store=InMemoryUserEngineStore(), + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort(), +) + +session = service.me(human_actor_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-cat") +``` + +## Request An Application Projection + +```python +from user_engine.domain import ProfileScope, ProjectionType + +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-profile", +) + +projection = service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-projection", +) +``` + +## Handle Profile Change Events + +Profile mutations append audit records and outbox events in the same service +operation. Outbox consumers should treat `event_id` as the delivery id and +`correlation_id` as the cross-system trace key. + +```python +for event in service.outbox_events(): + print(event.event_type, event.aggregate_id, event.correlation_id) +``` diff --git a/src/user_engine/__init__.py b/src/user_engine/__init__.py index 19be2c4..cbfeeae 100644 --- a/src/user_engine/__init__.py +++ b/src/user_engine/__init__.py @@ -1,7 +1,13 @@ """Headless user-domain and profile engine.""" +from user_engine.projections import ClaimsEnrichmentProjectionCache from user_engine.service import PLATFORM_TENANT, UserEngineService -__all__ = ["PLATFORM_TENANT", "UserEngineService", "__version__"] +__all__ = [ + "ClaimsEnrichmentProjectionCache", + "PLATFORM_TENANT", + "UserEngineService", + "__version__", +] __version__ = "0.0.0" diff --git a/src/user_engine/projections.py b/src/user_engine/projections.py new file mode 100644 index 0000000..6fa017f --- /dev/null +++ b/src/user_engine/projections.py @@ -0,0 +1,49 @@ +"""Projection helpers that do not make user-engine a token issuer.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from user_engine.domain import Actor, ProjectionType +from user_engine.service import Projection, UserEngineService + + +@dataclass +class ClaimsEnrichmentProjectionCache: + """Cache claims-enrichment projections for external token adapters. + + The adapter caches profile material only. A caller that issues tokens must + still own token minting, signing, lifetimes, and issuer-specific policy. + """ + + _cache: dict[tuple[str, str, str, str], Projection] = field(default_factory=dict) + + def get( + self, + service: UserEngineService, + actor: Actor, + *, + user_id: str, + tenant: str, + application_id: str, + correlation_id: str, + ) -> Projection: + key = (tenant, application_id, user_id, actor.subject) + cached = self._cache.get(key) + if cached is not None: + return cached + projection = service.projection( + actor, + user_id, + ProjectionType.CLAIMS_ENRICHMENT, + tenant=tenant, + application_id=application_id, + correlation_id=correlation_id, + ) + self._cache[key] = projection + return projection + + def invalidate_user(self, user_id: str) -> None: + for key in tuple(self._cache): + if key[2] == user_id: + del self._cache[key] diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 2f67f4c..7a0a083 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -585,6 +585,8 @@ class UserEngineService: tenant: str | None = None, correlation_id: str | None = None, ) -> Projection: + if _is_application_projection(projection_type) and application_id is None: + raise ValidationError("application projections require application_id") tenant_context = self.resolve_tenant_context(actor, tenant) correlation_id = correlation_id or new_id("corr") self._authorize( @@ -601,9 +603,14 @@ class UserEngineService: effective = self._resolve_effective_profile( user_id, application_id, tenant_context.tenant ) + owning_application_id = ( + application_id if _is_application_projection(projection_type) else None + ) values: dict[str, Any] = {} redactions: dict[str, str] = {} - for key, definition in self._active_attribute_definitions().items(): + for key, definition in self._active_attribute_definitions( + owning_application_id=owning_application_id + ).items(): if key not in effective.values: continue if not _visible_in_projection(definition, projection_type): @@ -863,9 +870,26 @@ class UserEngineService: if binding and catalog.namespace not in binding.catalog_namespaces: raise ValidationError("catalog namespace is not bound to application") + existing_catalog = self.store.catalogs.get(catalog.catalog_id) + if existing_catalog is not None: + self._validate_catalog_update(existing_catalog, catalog) + active_definitions = self._active_attribute_definitions( excluding_catalog_id=catalog.catalog_id ) + active_catalogs = ( + existing + for existing in self.store.catalogs.values() + if existing.catalog_id != catalog.catalog_id + and existing.lifecycle == CatalogLifecycle.ACTIVE + ) + for existing in active_catalogs: + if ( + existing.namespace == catalog.namespace + and existing.owning_application_id != catalog.owning_application_id + ): + raise ConflictError("active catalog namespace is already owned") + for attribute in catalog.attributes: if not attribute.key.startswith(f"{catalog.namespace}."): raise ValidationError("attribute keys must use the catalog namespace") @@ -880,6 +904,26 @@ class UserEngineService: if attribute.default is not None: self._validate_value(attribute, attribute.default) + def _validate_catalog_update(self, previous: Catalog, catalog: Catalog) -> None: + if previous.namespace != catalog.namespace: + raise ValidationError("catalog namespace cannot change") + if previous.owning_application_id != catalog.owning_application_id: + raise ValidationError("catalog owner cannot change") + if _version_tuple(catalog.version) < _version_tuple(previous.version): + raise ValidationError("catalog version cannot move backwards") + + previous_attributes = { + attribute.key: attribute for attribute in previous.attributes + } + for attribute in catalog.attributes: + previous_attribute = previous_attributes.get(attribute.key) + if previous_attribute is None: + continue + if _sensitivity_rank(attribute.sensitivity) < _sensitivity_rank( + previous_attribute.sensitivity + ): + raise ValidationError("catalog update cannot downgrade sensitivity") + def _validate_profile_scope( self, definition: AttributeDefinition, @@ -947,12 +991,20 @@ class UserEngineService: return definition def _active_attribute_definitions( - self, *, excluding_catalog_id: str | None = None + self, + *, + excluding_catalog_id: str | None = None, + owning_application_id: str | None = None, ) -> dict[str, AttributeDefinition]: definitions: dict[str, AttributeDefinition] = {} for catalog in self.store.catalogs.values(): if catalog.catalog_id == excluding_catalog_id: continue + if ( + owning_application_id is not None + and catalog.owning_application_id != owning_application_id + ): + continue if catalog.lifecycle != CatalogLifecycle.ACTIVE: continue for attribute in catalog.attributes: @@ -984,6 +1036,14 @@ def _visible_in_projection( return visibility in definition.visibility +def _is_application_projection(projection_type: ProjectionType) -> bool: + return projection_type in { + ProjectionType.APPLICATION_RUNTIME, + ProjectionType.AGENT_CONTEXT, + ProjectionType.CLAIMS_ENRICHMENT, + } + + def _must_redact( definition: AttributeDefinition, projection_type: ProjectionType ) -> bool: @@ -994,3 +1054,20 @@ def _must_redact( }: return False return definition.sensitivity in {Sensitivity.SENSITIVE, Sensitivity.SECRET} + + +def _version_tuple(version: str) -> tuple[int, int, int]: + parts = version.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + raise ValidationError("catalog version must be semantic MAJOR.MINOR.PATCH") + return tuple(int(part) for part in parts) + + +def _sensitivity_rank(sensitivity: Sensitivity) -> int: + return { + Sensitivity.PUBLIC: 0, + Sensitivity.INTERNAL: 1, + Sensitivity.PERSONAL: 2, + Sensitivity.SENSITIVE: 3, + Sensitivity.SECRET: 4, + }[sensitivity] diff --git a/tests/test_multi_application_catalogs.py b/tests/test_multi_application_catalogs.py new file mode 100644 index 0000000..bb51a2e --- /dev/null +++ b/tests/test_multi_application_catalogs.py @@ -0,0 +1,312 @@ +import unittest + +from user_engine.adapters.local import ( + InMemoryUserEngineStore, + LocalAuthorizationCheckPort, +) +from user_engine.domain import ( + Application, + ApplicationBinding, + AttributeDefinition, + AuthorizationEffect, + Catalog, + CatalogLifecycle, + Mutability, + ProfileScope, + ProjectionType, + Sensitivity, + Visibility, +) +from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError +from user_engine.projections import ClaimsEnrichmentProjectionCache +from user_engine.service import UserEngineService +from user_engine.testing.fixtures import ( + FixtureIdentityClaimsAdapter, + human_actor_claims, + sample_application, + sample_application_binding, + sample_catalog, +) + + +class MultiApplicationCatalogTests(unittest.TestCase): + def test_application_runtime_projection_is_filtered_by_catalog_owner(self): + service, _, _ = _service() + session = _bootstrap_two_apps(service) + + demo_projection = service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-demo-projection", + ) + reporting_projection = service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + tenant="tenant:coulomb", + application_id="app.reporting", + correlation_id="corr-reporting-projection", + ) + + self.assertIn("demo.display_density", demo_projection.values) + self.assertNotIn("report.timezone", demo_projection.values) + self.assertIn("report.timezone", reporting_projection.values) + self.assertNotIn("demo.display_density", reporting_projection.values) + + def test_catalog_namespace_collision_is_rejected(self): + service, _, _ = _service() + session = _bootstrap_demo_app(service) + service.publish_catalog( + session.actor, + sample_catalog(), + correlation_id="corr-demo-catalog", + ) + service.register_application( + session.actor, + Application( + application_id="app.other", + display_name="Other", + owner="team:other", + allowed_profile_scopes=(ProfileScope.GLOBAL,), + allowed_projection_types=(ProjectionType.APPLICATION_RUNTIME,), + ), + binding=ApplicationBinding( + application_id="app.other", + catalog_namespaces=("demo",), + oidc_client_id="other-client", + ), + correlation_id="corr-other-app", + ) + + with self.assertRaises(ConflictError): + service.publish_catalog( + session.actor, + Catalog( + catalog_id="other-demo", + namespace="demo", + version="0.1.0", + owning_application_id="app.other", + lifecycle=CatalogLifecycle.ACTIVE, + attributes=( + AttributeDefinition( + key="demo.other", + value_type="string", + scope=ProfileScope.GLOBAL, + sensitivity=Sensitivity.INTERNAL, + visibility=(Visibility.APPLICATION,), + mutability=(Mutability.ADMIN,), + ), + ), + ), + correlation_id="corr-conflict", + ) + + def test_catalog_migration_rejects_backwards_version_and_sensitivity_downgrade(self): + service, _, _ = _service() + session = _bootstrap_demo_app(service) + service.publish_catalog( + session.actor, + sample_catalog(), + correlation_id="corr-demo-catalog", + ) + + with self.assertRaises(ValidationError): + service.publish_catalog( + session.actor, + _demo_catalog(version="0.0.9", sensitivity=Sensitivity.INTERNAL), + correlation_id="corr-backwards", + ) + + with self.assertRaises(ValidationError): + service.publish_catalog( + session.actor, + _demo_catalog(version="0.2.0", sensitivity=Sensitivity.PUBLIC), + correlation_id="corr-downgrade", + ) + + def test_draft_catalog_and_projection_without_application_are_rejected(self): + service, _, _ = _service() + session = _bootstrap_demo_app(service) + + with self.assertRaises(ValidationError): + service.publish_catalog( + session.actor, + _demo_catalog(lifecycle=CatalogLifecycle.DRAFT), + correlation_id="corr-draft", + ) + + with self.assertRaises(ValidationError): + service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + tenant="tenant:coulomb", + correlation_id="corr-missing-app", + ) + + def test_projection_denial_does_not_emit_outbox_event(self): + service, _, _ = _service(action_effects={"projection.read": AuthorizationEffect.DENY}) + session = _bootstrap_two_apps(service) + before_outbox = len(service.outbox_events()) + + with self.assertRaises(AuthorizationDenied): + service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-denied-projection", + ) + + self.assertEqual(len(service.outbox_events()), before_outbox) + + def test_claims_enrichment_cache_reuses_projection_without_issuing_tokens(self): + service, _, _ = _service() + session = _bootstrap_two_apps(service) + cache = ClaimsEnrichmentProjectionCache() + + first = cache.get( + service, + session.actor, + user_id=session.user.user_id, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-claims-1", + ) + second = cache.get( + service, + session.actor, + user_id=session.user.user_id, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-claims-2", + ) + + self.assertIs(first, second) + self.assertEqual(first.projection_type, ProjectionType.CLAIMS_ENRICHMENT) + self.assertFalse(hasattr(cache, "issue_token")) + + +def _service( + *, + action_effects: dict[str, AuthorizationEffect] | None = None, +): + store = InMemoryUserEngineStore() + authz = LocalAuthorizationCheckPort(action_effects=action_effects) + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=authz, + ) + return service, store, authz + + +def _bootstrap_demo_app(service: UserEngineService): + claims = human_actor_claims(subject="app-admin", tenant="tenant:coulomb") + claims["roles"] = ["tenant-admin"] + session = service.me(claims, correlation_id="corr-me") + service.register_application( + session.actor, + sample_application(), + binding=sample_application_binding(), + correlation_id="corr-demo-app", + ) + return session + + +def _bootstrap_two_apps(service: UserEngineService): + session = _bootstrap_demo_app(service) + service.publish_catalog( + session.actor, + sample_catalog(), + correlation_id="corr-demo-catalog", + ) + service.register_application( + session.actor, + _reporting_application(), + binding=_reporting_binding(), + correlation_id="corr-reporting-app", + ) + service.publish_catalog( + session.actor, + _reporting_catalog(), + correlation_id="corr-reporting-catalog", + ) + return session + + +def _reporting_application() -> Application: + return Application( + application_id="app.reporting", + display_name="Reporting", + owner="team:reporting", + allowed_profile_scopes=(ProfileScope.GLOBAL, ProfileScope.APPLICATION), + allowed_projection_types=(ProjectionType.APPLICATION_RUNTIME,), + ) + + +def _reporting_binding() -> ApplicationBinding: + return ApplicationBinding( + application_id="app.reporting", + oidc_client_id="reporting-client", + protected_system_id="reporting.demo", + catalog_namespaces=("report",), + event_source="reporting.demo", + deployment_ref="local", + ) + + +def _reporting_catalog() -> Catalog: + return Catalog( + catalog_id="report-profile", + namespace="report", + version="0.1.0", + owning_application_id="app.reporting", + lifecycle=CatalogLifecycle.ACTIVE, + attributes=( + AttributeDefinition( + key="report.timezone", + value_type="string", + scope=ProfileScope.APPLICATION, + sensitivity=Sensitivity.INTERNAL, + visibility=(Visibility.APPLICATION,), + mutability=(Mutability.USER,), + default="UTC", + ), + ), + ) + + +def _demo_catalog( + *, + version: str = "0.1.0", + sensitivity: Sensitivity = Sensitivity.INTERNAL, + lifecycle: CatalogLifecycle = CatalogLifecycle.ACTIVE, +) -> Catalog: + return Catalog( + catalog_id="demo-profile", + namespace="demo", + version=version, + owning_application_id="app.demo", + lifecycle=lifecycle, + attributes=( + AttributeDefinition( + key="demo.display_density", + value_type="string", + scope=ProfileScope.APPLICATION, + sensitivity=sensitivity, + visibility=(Visibility.USER, Visibility.APPLICATION), + mutability=(Mutability.USER,), + default="comfortable", + validation={"enum": ["compact", "comfortable"]}, + ), + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/USER-WP-0004-multi-application-catalogs.md b/workplans/USER-WP-0004-multi-application-catalogs.md index f41e8eb..33376b4 100644 --- a/workplans/USER-WP-0004-multi-application-catalogs.md +++ b/workplans/USER-WP-0004-multi-application-catalogs.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Multi-Application And Catalog Support" domain: netkingdom repo: user-engine -status: active +status: finished owner: codex topic_slug: netkingdom planning_priority: high @@ -28,7 +28,7 @@ runtime projections without attribute collisions or data leakage. ```task id: USER-WP-0004-T1 -status: todo +status: done priority: high state_hub_task_id: "a9952b07-35e0-4b6a-921e-321c55fee011" ``` @@ -38,7 +38,7 @@ protected-system, catalog namespace, event identity, and deployment metadata. ```task id: USER-WP-0004-T2 -status: todo +status: done priority: high state_hub_task_id: "d4ada2e3-9859-489c-b2a9-529b0d9e03fb" ``` @@ -48,7 +48,7 @@ compatibility checks, and sensitivity downgrade prevention. ```task id: USER-WP-0004-T3 -status: todo +status: done priority: high state_hub_task_id: "add3cbdb-f7ec-4362-a257-93ab874a2093" ``` @@ -58,7 +58,7 @@ rules. ```task id: USER-WP-0004-T4 -status: todo +status: done priority: high state_hub_task_id: "d5ef60ee-c007-4efd-86e4-3244e92c555a" ``` @@ -68,7 +68,7 @@ mutability, sensitivity, and redaction rules. ```task id: USER-WP-0004-T5 -status: todo +status: done priority: medium state_hub_task_id: "c824c44d-24f5-4f28-91fd-4d739b5fa254" ``` @@ -78,7 +78,7 @@ adapter that does not make user-engine a token issuer. ```task id: USER-WP-0004-T6 -status: todo +status: done priority: high state_hub_task_id: "8a506280-d8d6-4fbd-9d05-831a76e3e8be" ``` @@ -89,7 +89,7 @@ denial, and catalog migration checks. ```task id: USER-WP-0004-T7 -status: todo +status: done priority: medium state_hub_task_id: "c8fd8760-ccc4-4df4-85c7-c1c4950b82d9" ```