Add multi-application catalog support

This commit is contained in:
2026-05-22 21:33:38 +02:00
parent 6d0cc7b0f9
commit 1440a597df
7 changed files with 525 additions and 13 deletions

View File

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

View 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]

View File

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