generated from coulomb/repo-seed
Add multi-application catalog support
This commit is contained in:
@@ -6,5 +6,5 @@ Headless multi-application, multi-tenant user management engine.
|
|||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/development.md` and `docs/configuration.md` for the initial
|
See `docs/development.md`, `docs/configuration.md`, and `docs/examples.md`
|
||||||
implementation boundaries.
|
for implementation boundaries and local usage examples.
|
||||||
|
|||||||
68
docs/examples.md
Normal file
68
docs/examples.md
Normal file
@@ -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)
|
||||||
|
```
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
"""Headless user-domain and profile engine."""
|
"""Headless user-domain and profile engine."""
|
||||||
|
|
||||||
|
from user_engine.projections import ClaimsEnrichmentProjectionCache
|
||||||
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||||
|
|
||||||
__all__ = ["PLATFORM_TENANT", "UserEngineService", "__version__"]
|
__all__ = [
|
||||||
|
"ClaimsEnrichmentProjectionCache",
|
||||||
|
"PLATFORM_TENANT",
|
||||||
|
"UserEngineService",
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
|
|
||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
|
|||||||
49
src/user_engine/projections.py
Normal file
49
src/user_engine/projections.py
Normal file
@@ -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]
|
||||||
@@ -585,6 +585,8 @@ class UserEngineService:
|
|||||||
tenant: str | None = None,
|
tenant: str | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> Projection:
|
) -> 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)
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
correlation_id = correlation_id or new_id("corr")
|
correlation_id = correlation_id or new_id("corr")
|
||||||
self._authorize(
|
self._authorize(
|
||||||
@@ -601,9 +603,14 @@ class UserEngineService:
|
|||||||
effective = self._resolve_effective_profile(
|
effective = self._resolve_effective_profile(
|
||||||
user_id, application_id, tenant_context.tenant
|
user_id, application_id, tenant_context.tenant
|
||||||
)
|
)
|
||||||
|
owning_application_id = (
|
||||||
|
application_id if _is_application_projection(projection_type) else None
|
||||||
|
)
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
redactions: dict[str, str] = {}
|
redactions: dict[str, str] = {}
|
||||||
for key, definition in self._active_attribute_definitions().items():
|
for key, definition in self._active_attribute_definitions(
|
||||||
|
owning_application_id=owning_application_id
|
||||||
|
).items():
|
||||||
if key not in effective.values:
|
if key not in effective.values:
|
||||||
continue
|
continue
|
||||||
if not _visible_in_projection(definition, projection_type):
|
if not _visible_in_projection(definition, projection_type):
|
||||||
@@ -863,9 +870,26 @@ class UserEngineService:
|
|||||||
if binding and catalog.namespace not in binding.catalog_namespaces:
|
if binding and catalog.namespace not in binding.catalog_namespaces:
|
||||||
raise ValidationError("catalog namespace is not bound to application")
|
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(
|
active_definitions = self._active_attribute_definitions(
|
||||||
excluding_catalog_id=catalog.catalog_id
|
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:
|
for attribute in catalog.attributes:
|
||||||
if not attribute.key.startswith(f"{catalog.namespace}."):
|
if not attribute.key.startswith(f"{catalog.namespace}."):
|
||||||
raise ValidationError("attribute keys must use the catalog namespace")
|
raise ValidationError("attribute keys must use the catalog namespace")
|
||||||
@@ -880,6 +904,26 @@ class UserEngineService:
|
|||||||
if attribute.default is not None:
|
if attribute.default is not None:
|
||||||
self._validate_value(attribute, attribute.default)
|
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(
|
def _validate_profile_scope(
|
||||||
self,
|
self,
|
||||||
definition: AttributeDefinition,
|
definition: AttributeDefinition,
|
||||||
@@ -947,12 +991,20 @@ class UserEngineService:
|
|||||||
return definition
|
return definition
|
||||||
|
|
||||||
def _active_attribute_definitions(
|
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]:
|
) -> dict[str, AttributeDefinition]:
|
||||||
definitions: dict[str, AttributeDefinition] = {}
|
definitions: dict[str, AttributeDefinition] = {}
|
||||||
for catalog in self.store.catalogs.values():
|
for catalog in self.store.catalogs.values():
|
||||||
if catalog.catalog_id == excluding_catalog_id:
|
if catalog.catalog_id == excluding_catalog_id:
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
owning_application_id is not None
|
||||||
|
and catalog.owning_application_id != owning_application_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
if catalog.lifecycle != CatalogLifecycle.ACTIVE:
|
if catalog.lifecycle != CatalogLifecycle.ACTIVE:
|
||||||
continue
|
continue
|
||||||
for attribute in catalog.attributes:
|
for attribute in catalog.attributes:
|
||||||
@@ -984,6 +1036,14 @@ def _visible_in_projection(
|
|||||||
return visibility in definition.visibility
|
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(
|
def _must_redact(
|
||||||
definition: AttributeDefinition, projection_type: ProjectionType
|
definition: AttributeDefinition, projection_type: ProjectionType
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -994,3 +1054,20 @@ def _must_redact(
|
|||||||
}:
|
}:
|
||||||
return False
|
return False
|
||||||
return definition.sensitivity in {Sensitivity.SENSITIVE, Sensitivity.SECRET}
|
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]
|
||||||
|
|||||||
312
tests/test_multi_application_catalogs.py
Normal file
312
tests/test_multi_application_catalogs.py
Normal file
@@ -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()
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "User Engine Multi-Application And Catalog Support"
|
title: "User Engine Multi-Application And Catalog Support"
|
||||||
domain: netkingdom
|
domain: netkingdom
|
||||||
repo: user-engine
|
repo: user-engine
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -28,7 +28,7 @@ runtime projections without attribute collisions or data leakage.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T1
|
id: USER-WP-0004-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a9952b07-35e0-4b6a-921e-321c55fee011"
|
state_hub_task_id: "a9952b07-35e0-4b6a-921e-321c55fee011"
|
||||||
```
|
```
|
||||||
@@ -38,7 +38,7 @@ protected-system, catalog namespace, event identity, and deployment metadata.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T2
|
id: USER-WP-0004-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d4ada2e3-9859-489c-b2a9-529b0d9e03fb"
|
state_hub_task_id: "d4ada2e3-9859-489c-b2a9-529b0d9e03fb"
|
||||||
```
|
```
|
||||||
@@ -48,7 +48,7 @@ compatibility checks, and sensitivity downgrade prevention.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T3
|
id: USER-WP-0004-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "add3cbdb-f7ec-4362-a257-93ab874a2093"
|
state_hub_task_id: "add3cbdb-f7ec-4362-a257-93ab874a2093"
|
||||||
```
|
```
|
||||||
@@ -58,7 +58,7 @@ rules.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T4
|
id: USER-WP-0004-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d5ef60ee-c007-4efd-86e4-3244e92c555a"
|
state_hub_task_id: "d5ef60ee-c007-4efd-86e4-3244e92c555a"
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,7 @@ mutability, sensitivity, and redaction rules.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T5
|
id: USER-WP-0004-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c824c44d-24f5-4f28-91fd-4d739b5fa254"
|
state_hub_task_id: "c824c44d-24f5-4f28-91fd-4d739b5fa254"
|
||||||
```
|
```
|
||||||
@@ -78,7 +78,7 @@ adapter that does not make user-engine a token issuer.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T6
|
id: USER-WP-0004-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8a506280-d8d6-4fbd-9d05-831a76e3e8be"
|
state_hub_task_id: "8a506280-d8d6-4fbd-9d05-831a76e3e8be"
|
||||||
```
|
```
|
||||||
@@ -89,7 +89,7 @@ denial, and catalog migration checks.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0004-T7
|
id: USER-WP-0004-T7
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c8fd8760-ccc4-4df4-85c7-c1c4950b82d9"
|
state_hub_task_id: "c8fd8760-ccc4-4df4-85c7-c1c4950b82d9"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user