Implement isolated user-engine MVP

This commit is contained in:
2026-05-22 21:20:19 +02:00
parent b77324d7b1
commit 5203d9f45f
13 changed files with 1196 additions and 13 deletions

View File

@@ -10,9 +10,12 @@ tested immediately in local and agent environments.
```text ```text
src/user_engine/ src/user_engine/
adapters/ local standalone adapters and deterministic test doubles
domain/ transport- and persistence-neutral domain schemas domain/ transport- and persistence-neutral domain schemas
errors.py typed service exceptions for callers and future transports
ports.py adapter protocols for identity, authorization, events, audit, ports.py adapter protocols for identity, authorization, events, audit,
membership export, application bindings, and secrets membership export, application bindings, and secrets
service.py headless service API for the isolated MVP
testing/ local fixtures for tests and examples testing/ local fixtures for tests and examples
tests/ standard-library unittest suite tests/ standard-library unittest suite
``` ```
@@ -40,3 +43,12 @@ Add new behavior in this order:
2. local fixture or adapter; 2. local fixture or adapter;
3. test that proves the boundary; 3. test that proves the boundary;
4. infrastructure adapter or API surface. 4. infrastructure adapter or API surface.
## Isolated MVP Surface
The initial headless API is `UserEngineService`. It exposes health,
readiness, `me`, user/account lifecycle, identity linking, application
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.

View File

@@ -1,5 +1,7 @@
"""Headless user-domain and profile engine.""" """Headless user-domain and profile engine."""
__all__ = ["__version__"] from user_engine.service import UserEngineService
__all__ = ["UserEngineService", "__version__"]
__version__ = "0.0.0" __version__ = "0.0.0"

View File

@@ -0,0 +1,11 @@
"""Local adapters for standalone user-engine setups."""
from user_engine.adapters.local import (
InMemoryUserEngineStore,
LocalAuthorizationCheckPort,
)
__all__ = [
"InMemoryUserEngineStore",
"LocalAuthorizationCheckPort",
]

View File

@@ -0,0 +1,123 @@
"""Standalone adapters for isolated user-engine deployments and tests."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from user_engine.domain import (
Account,
Application,
ApplicationBinding,
AuditRecord,
AuthorizationDecision,
AuthorizationEffect,
AuthorizationRequest,
Catalog,
ExternalIdentity,
OutboxEvent,
ProfileScope,
ProfileValue,
User,
)
SCHEMA_VERSION = "0001_initial"
@dataclass
class InMemoryUserEngineStore:
"""Small local persistence adapter with explicit schema state.
The store is intentionally simple and deterministic. It gives the MVP a
concrete persistence boundary without committing later implementation work
to a specific database driver.
"""
schema_version: str | None = None
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)
applications: dict[str, Application] = field(default_factory=dict)
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
catalogs: dict[str, Catalog] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict)
audit_records: list[AuditRecord] = field(default_factory=list)
outbox_events: list[OutboxEvent] = field(default_factory=list)
def migrate(self) -> None:
"""Apply the standalone schema migration manifest."""
self.schema_version = SCHEMA_VERSION
@property
def ready(self) -> bool:
return self.schema_version == SCHEMA_VERSION
def save_user(self, user: User) -> None:
self.users[user.user_id] = user
def save_account(self, account: Account) -> None:
self.accounts[account.user_id] = account
def save_identity(self, identity: ExternalIdentity) -> None:
self.identities[identity.identity_key] = identity
def save_application(self, application: Application) -> None:
self.applications[application.application_id] = application
def save_binding(self, binding: ApplicationBinding) -> None:
self.bindings[binding.application_id] = binding
def save_catalog(self, catalog: Catalog) -> None:
self.catalogs[catalog.catalog_id] = catalog
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)
] = value
def find_identity(self, issuer: str, subject: str) -> ExternalIdentity | None:
return self.identities.get((issuer, subject))
def user_account(self, user_id: str) -> Account | None:
return self.accounts.get(user_id)
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
)
def append_audit(self, record: AuditRecord) -> None:
self.audit_records.append(record)
def append_outbox(self, event: OutboxEvent) -> None:
self.outbox_events.append(event)
class LocalAuthorizationCheckPort:
"""Deterministic local authorization adapter.
Rules are action-specific. The default is allow so isolated tests and local
demos can focus on user-engine behavior while still exercising the port.
"""
def __init__(
self,
*,
default_effect: AuthorizationEffect = AuthorizationEffect.ALLOW,
action_effects: dict[str, AuthorizationEffect] | None = None,
) -> None:
self.default_effect = default_effect
self.action_effects = action_effects or {}
self.requests: list[AuthorizationRequest] = []
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
self.requests.append(request)
effect = self.action_effects.get(request.action, self.default_effect)
return AuthorizationDecision(effect=effect, reason="local")
def batch_check(
self, requests: Iterable[AuthorizationRequest]
) -> tuple[AuthorizationDecision, ...]:
return tuple(self.check(request) for request in requests)

View File

@@ -14,6 +14,7 @@ from user_engine.domain.models import (
Catalog, Catalog,
CatalogLifecycle, CatalogLifecycle,
ExternalIdentity, ExternalIdentity,
ManagementMode,
Membership, Membership,
Mutability, Mutability,
OutboxEvent, OutboxEvent,
@@ -24,6 +25,8 @@ from user_engine.domain.models import (
Sensitivity, Sensitivity,
User, User,
Visibility, Visibility,
new_id,
utc_now,
) )
__all__ = [ __all__ = [
@@ -40,6 +43,7 @@ __all__ = [
"Catalog", "Catalog",
"CatalogLifecycle", "CatalogLifecycle",
"ExternalIdentity", "ExternalIdentity",
"ManagementMode",
"Membership", "Membership",
"Mutability", "Mutability",
"OutboxEvent", "OutboxEvent",
@@ -50,4 +54,6 @@ __all__ = [
"Sensitivity", "Sensitivity",
"User", "User",
"Visibility", "Visibility",
"new_id",
"utc_now",
] ]

23
src/user_engine/errors.py Normal file
View File

@@ -0,0 +1,23 @@
"""Typed exceptions raised by user-engine services."""
from __future__ import annotations
class UserEngineError(Exception):
"""Base class for user-engine failures."""
class AuthorizationDenied(UserEngineError):
"""Raised when the authorization port denies an operation."""
class ConflictError(UserEngineError):
"""Raised when a write would violate a uniqueness constraint."""
class NotFoundError(UserEngineError):
"""Raised when a requested domain object does not exist."""
class ValidationError(UserEngineError):
"""Raised when input fails domain validation."""

750
src/user_engine/service.py Normal file
View File

@@ -0,0 +1,750 @@
"""Headless application service for the isolated user-engine MVP."""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any, Mapping
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import (
Account,
AccountStatus,
Actor,
Application,
ApplicationBinding,
AttributeDefinition,
AuditRecord,
AuthorizationRequest,
Catalog,
CatalogLifecycle,
ExternalIdentity,
Mutability,
OutboxEvent,
ProfileScope,
ProfileValue,
ProjectionType,
Sensitivity,
User,
Visibility,
new_id,
)
from user_engine.errors import (
AuthorizationDenied,
ConflictError,
NotFoundError,
ValidationError,
)
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
REDACTED = "<redacted>"
@dataclass(frozen=True)
class HealthReport:
status: str
schema_version: str | None
@dataclass(frozen=True)
class ReadinessReport:
ready: bool
checks: Mapping[str, bool]
@dataclass(frozen=True)
class UserSession:
actor: Actor
user: User
account: Account
identities: tuple[ExternalIdentity, ...]
@dataclass(frozen=True)
class EffectiveProfile:
user_id: str
application_id: str | None
values: Mapping[str, Any]
source_layers: Mapping[str, str]
trace: Mapping[str, tuple[str, ...]]
@dataclass(frozen=True)
class Projection:
user_id: str
projection_type: ProjectionType
application_id: str | None
values: Mapping[str, Any]
redactions: Mapping[str, str]
class UserEngineService:
"""Headless service API for isolated user and profile management."""
def __init__(
self,
*,
store: InMemoryUserEngineStore,
identity_adapter: IdentityClaimsAdapter,
authorization: AuthorizationCheckPort,
) -> None:
self.store = store
self.identity_adapter = identity_adapter
self.authorization = authorization
if not self.store.ready:
self.store.migrate()
def health(self) -> HealthReport:
return HealthReport(status="ok", schema_version=self.store.schema_version)
def readiness(self) -> ReadinessReport:
return ReadinessReport(
ready=self.store.ready,
checks={
"store_schema": self.store.ready,
"identity_adapter": self.identity_adapter is not None,
"authorization_port": self.authorization is not None,
},
)
def me(
self,
claims: Mapping[str, Any],
*,
correlation_id: str | None = None,
) -> UserSession:
actor = self.identity_adapter.normalize(claims)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="me.read",
resource_type="user-engine:me",
resource_id=actor.subject,
tenant=actor.tenant,
correlation_id=correlation_id,
)
identity = self.store.find_identity(*actor.identity_key)
if identity is not None:
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
return self._session(actor, user, account)
user = User(
display_name=actor.preferred_username,
primary_email=_optional_claim(actor, "email"),
)
account = Account(
account_id=new_id("acct"),
user_id=user.user_id,
status=AccountStatus.ACTIVE,
)
identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
)
self.store.save_user(user)
self.store.save_account(account)
self.store.save_identity(identity)
self._record_mutation(
actor,
action="user.create_from_identity",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"account_id": account.account_id,
"identity": {"issuer": actor.issuer, "subject": actor.subject},
},
)
return self._session(actor, user, account)
def create_user(
self,
actor: Actor,
*,
display_name: str | None,
primary_email: str | None,
correlation_id: str | None = None,
) -> User:
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="user.create",
resource_type="user-engine:user",
resource_id="new",
tenant=actor.tenant,
correlation_id=correlation_id,
)
user = User(display_name=display_name, primary_email=primary_email)
account = Account(account_id=new_id("acct"), user_id=user.user_id)
self.store.save_user(user)
self.store.save_account(account)
self._record_mutation(
actor,
action="user.create",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={"user_id": user.user_id, "account_id": account.account_id},
)
return user
def set_account_status(
self,
actor: Actor,
user_id: str,
status: AccountStatus,
*,
correlation_id: str | None = None,
) -> Account:
account = self._require_account(user_id)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="account.update",
resource_type="user-engine:account",
resource_id=account.account_id,
tenant=actor.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
updated = replace(account, status=status)
self.store.save_account(updated)
self._record_mutation(
actor,
action="account.update",
subject=user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="account.status_changed",
aggregate_id=account.account_id,
payload={"user_id": user_id, "status": status},
)
return updated
def link_identity(
self,
actor: Actor,
user_id: str,
*,
issuer: str,
subject: str,
provider: str | None = None,
correlation_id: str | None = None,
) -> ExternalIdentity:
self._require_user(user_id)
existing = self.store.find_identity(issuer, subject)
if existing is not None and existing.user_id != user_id:
raise ConflictError("external identity is already linked")
if existing is not None:
return existing
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="identity.link",
resource_type="user-engine:identity",
resource_id=f"{issuer}:{subject}",
tenant=actor.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user_id,
issuer=issuer,
subject=subject,
provider=provider,
)
self.store.save_identity(identity)
self._record_mutation(
actor,
action="identity.link",
subject=user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="identity.linked",
aggregate_id=user_id,
payload={"issuer": issuer, "subject": subject, "user_id": user_id},
)
return identity
def register_application(
self,
actor: Actor,
application: Application,
*,
binding: ApplicationBinding | None = None,
correlation_id: str | None = None,
) -> Application:
if application.application_id in self.store.applications:
raise ConflictError("application already exists")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="application.register",
resource_type="user-engine:application",
resource_id=application.application_id,
tenant=actor.tenant,
correlation_id=correlation_id,
application_id=application.application_id,
)
self.store.save_application(application)
if binding is not None:
if binding.application_id != application.application_id:
raise ValidationError("binding application id must match application")
self.store.save_binding(binding)
self._record_mutation(
actor,
action="application.register",
subject=application.application_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="application.registered",
aggregate_id=application.application_id,
payload={"application_id": application.application_id},
application_id=application.application_id,
)
return application
def publish_catalog(
self,
actor: Actor,
catalog: Catalog,
*,
correlation_id: str | None = None,
) -> Catalog:
self._validate_catalog(catalog)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="catalog.publish",
resource_type="user-engine:catalog",
resource_id=catalog.catalog_id,
tenant=actor.tenant,
correlation_id=correlation_id,
application_id=catalog.owning_application_id,
)
self.store.save_catalog(catalog)
self._record_mutation(
actor,
action="catalog.publish",
subject=catalog.catalog_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="catalog.published",
aggregate_id=catalog.catalog_id,
payload={
"catalog_id": catalog.catalog_id,
"namespace": catalog.namespace,
"version": catalog.version,
},
application_id=catalog.owning_application_id,
)
return catalog
def set_profile_value(
self,
actor: Actor,
user_id: str,
attribute_key: str,
value: Any,
*,
scope: ProfileScope = ProfileScope.GLOBAL,
scope_id: str | None = None,
application_id: str | None = None,
correlation_id: str | None = None,
) -> ProfileValue:
self._require_user(user_id)
definition = self._require_attribute(attribute_key)
self._validate_profile_scope(definition, scope, scope_id)
self._validate_value(definition, value)
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,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"scope": scope, "scope_id": scope_id},
)
profile_value = ProfileValue(
user_id=user_id,
attribute_key=attribute_key,
value=value,
scope=scope,
scope_id=scope_id,
source=f"{actor.issuer}:{actor.subject}",
)
self.store.save_profile_value(profile_value)
self._record_mutation(
actor,
action="profile.write",
subject=user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="profile.value_set",
aggregate_id=user_id,
payload={
"user_id": user_id,
"attribute_key": attribute_key,
"scope": scope,
"scope_id": scope_id,
},
application_id=application_id,
)
return profile_value
def effective_profile(
self,
actor: Actor,
user_id: str,
*,
application_id: str | None = None,
correlation_id: str | None = None,
) -> EffectiveProfile:
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,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
)
return self._resolve_effective_profile(user_id, application_id)
def projection(
self,
actor: Actor,
user_id: str,
projection_type: ProjectionType,
*,
application_id: str | None = None,
correlation_id: str | None = None,
) -> Projection:
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,
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)
values: dict[str, Any] = {}
redactions: dict[str, str] = {}
for key, definition in self._active_attribute_definitions().items():
if key not in effective.values:
continue
if not _visible_in_projection(definition, projection_type):
continue
if _must_redact(definition, projection_type):
values[key] = REDACTED
redactions[key] = "sensitivity"
else:
values[key] = effective.values[key]
return Projection(
user_id=user_id,
projection_type=projection_type,
application_id=application_id,
values=values,
redactions=redactions,
)
def audit_records(self) -> tuple[AuditRecord, ...]:
return tuple(self.store.audit_records)
def outbox_events(self) -> tuple[OutboxEvent, ...]:
return tuple(self.store.outbox_events)
def _session(self, actor: Actor, user: User, account: Account) -> UserSession:
identities = tuple(
identity
for identity in self.store.identities.values()
if identity.user_id == user.user_id
)
return UserSession(actor=actor, user=user, account=account, identities=identities)
def _authorize(
self,
actor: Actor,
*,
action: str,
resource_type: str,
resource_id: str,
tenant: str,
correlation_id: str,
application_id: str | None = None,
target_user_id: str | None = None,
context: Mapping[str, Any] | None = None,
):
request = AuthorizationRequest(
actor=actor,
resource_type=resource_type,
resource_id=resource_id,
action=action,
tenant=tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=target_user_id,
context=context or {},
)
decision = self.authorization.check(request)
if not decision.allowed:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=resource_id,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
application_id=application_id,
summary="authorization denied",
)
)
raise AuthorizationDenied(decision.reason or "authorization denied")
return decision
def _record_mutation(
self,
actor: Actor,
*,
action: str,
subject: str,
tenant: str,
correlation_id: str,
decision_id: str | None,
event_type: str,
aggregate_id: str,
payload: Mapping[str, Any],
application_id: str | None = None,
) -> None:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=subject,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision_id,
application_id=application_id,
summary=event_type,
)
)
self.store.append_outbox(
OutboxEvent(
event_id=new_id("evt"),
event_type=event_type,
aggregate_id=aggregate_id,
payload=dict(payload),
tenant=tenant,
correlation_id=correlation_id,
)
)
def _resolve_effective_profile(
self, user_id: str, application_id: str | None
) -> EffectiveProfile:
self._require_user(user_id)
values: dict[str, Any] = {}
source_layers: dict[str, str] = {}
trace: dict[str, list[str]] = {}
definitions = self._active_attribute_definitions()
for key, definition in definitions.items():
if definition.default is not None:
values[key] = definition.default
source_layers[key] = "catalog_default"
trace[key] = ["catalog_default"]
profile_values = self.store.values_for_user(user_id)
for profile_value in profile_values:
if profile_value.attribute_key not in definitions:
continue
if profile_value.scope != ProfileScope.GLOBAL:
continue
values[profile_value.attribute_key] = profile_value.value
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.APPLICATION:
continue
if profile_value.scope_id != application_id:
continue
values[profile_value.attribute_key] = profile_value.value
layer = f"application:{application_id}"
source_layers[profile_value.attribute_key] = layer
trace.setdefault(profile_value.attribute_key, []).append(layer)
return EffectiveProfile(
user_id=user_id,
application_id=application_id,
values=values,
source_layers=source_layers,
trace={key: tuple(layers) for key, layers in trace.items()},
)
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")
if catalog.lifecycle != CatalogLifecycle.ACTIVE:
raise ValidationError("only active catalogs can be published")
if not catalog.attributes:
raise ValidationError("catalog must define at least one attribute")
keys = [attribute.key for attribute in catalog.attributes]
if len(keys) != len(set(keys)):
raise ValidationError("catalog attribute keys must be unique")
binding = self.store.bindings.get(catalog.owning_application_id)
if binding and catalog.namespace not in binding.catalog_namespaces:
raise ValidationError("catalog namespace is not bound to application")
active_definitions = self._active_attribute_definitions(
excluding_catalog_id=catalog.catalog_id
)
for attribute in catalog.attributes:
if not attribute.key.startswith(f"{catalog.namespace}."):
raise ValidationError("attribute keys must use the catalog namespace")
if attribute.key in active_definitions:
raise ConflictError("active catalog already owns attribute key")
if not attribute.visibility:
raise ValidationError("attribute must declare visibility")
if not attribute.mutability:
raise ValidationError("attribute must declare mutability")
if Mutability.READ_ONLY in attribute.mutability and len(attribute.mutability) > 1:
raise ValidationError("read-only mutability cannot be combined")
if attribute.default is not None:
self._validate_value(attribute, attribute.default)
def _validate_profile_scope(
self,
definition: AttributeDefinition,
scope: ProfileScope,
scope_id: str | None,
) -> None:
allowed_scopes = {ProfileScope.GLOBAL, ProfileScope.APPLICATION, definition.scope}
if scope not in allowed_scopes:
raise ValidationError("profile value scope is not allowed for attribute")
if scope == ProfileScope.APPLICATION:
if scope_id is None:
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_id is not None:
raise ValidationError("only application scoped values may set scope_id")
def _validate_value(self, definition: AttributeDefinition, value: Any) -> None:
if value is None:
return
validators = {
"string": lambda item: isinstance(item, str),
"integer": lambda item: isinstance(item, int) and not isinstance(item, bool),
"number": lambda item: isinstance(item, (int, float))
and not isinstance(item, bool),
"boolean": lambda item: isinstance(item, bool),
"object": lambda item: isinstance(item, Mapping),
"array": lambda item: isinstance(item, (list, tuple)),
}
validator = validators.get(definition.value_type)
if validator is None:
raise ValidationError(f"unsupported attribute value_type: {definition.value_type}")
if not validator(value):
raise ValidationError(f"{definition.key} must be {definition.value_type}")
enum_values = definition.validation.get("enum")
if enum_values is not None and value not in enum_values:
raise ValidationError(f"{definition.key} is not an allowed value")
def _require_user(self, user_id: str) -> User:
user = self.store.users.get(user_id)
if user is None:
raise NotFoundError("user not found")
return user
def _require_account(self, user_id: str) -> Account:
account = self.store.user_account(user_id)
if account is None:
raise NotFoundError("account not found")
return account
def _require_attribute(self, attribute_key: str) -> AttributeDefinition:
definition = self._active_attribute_definitions().get(attribute_key)
if definition is None:
raise NotFoundError("active attribute definition not found")
return definition
def _active_attribute_definitions(
self, *, excluding_catalog_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 catalog.lifecycle != CatalogLifecycle.ACTIVE:
continue
for attribute in catalog.attributes:
definitions[attribute.key] = attribute
return dict(sorted(definitions.items()))
def _optional_claim(actor: Actor, key: str) -> str | None:
value = actor.claims.get(key)
if value is None:
return None
return str(value)
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:
if projection_type == ProjectionType.AUDIT:
return True
visibility = {
ProjectionType.SELF_SERVICE: Visibility.USER,
ProjectionType.ADMIN: Visibility.ADMIN,
ProjectionType.APPLICATION_RUNTIME: Visibility.APPLICATION,
ProjectionType.AGENT_CONTEXT: Visibility.APPLICATION,
ProjectionType.CLAIMS_ENRICHMENT: Visibility.APPLICATION,
}.get(projection_type)
if visibility is None:
return False
return visibility in definition.visibility
def _must_redact(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:
if projection_type in {
ProjectionType.ADMIN,
ProjectionType.AUDIT,
ProjectionType.SELF_SERVICE,
}:
return False
return definition.sensitivity in {Sensitivity.SENSITIVE, Sensitivity.SECRET}

256
tests/test_isolated_mvp.py Normal file
View File

@@ -0,0 +1,256 @@
import unittest
from user_engine.adapters.local import (
InMemoryUserEngineStore,
LocalAuthorizationCheckPort,
)
from user_engine.domain import (
AccountStatus,
AttributeDefinition,
AuthorizationEffect,
Catalog,
CatalogLifecycle,
Mutability,
ProfileScope,
ProjectionType,
Sensitivity,
Visibility,
)
from user_engine.errors import AuthorizationDenied, ValidationError
from user_engine.service import REDACTED, UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
)
class IsolatedMvpTests(unittest.TestCase):
def test_me_creates_user_account_and_identity_once(self):
service, store, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
again = service.me(human_actor_claims(), correlation_id="corr-me-again")
self.assertEqual(service.health().status, "ok")
self.assertTrue(service.readiness().ready)
self.assertEqual(session.user.user_id, again.user.user_id)
self.assertEqual(session.account.status, AccountStatus.ACTIVE)
self.assertEqual(len(store.users), 1)
self.assertEqual(len(store.identities), 1)
self.assertEqual(len(service.outbox_events()), 1)
def test_account_lifecycle_and_identity_linking(self):
service, _, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
disabled = service.set_account_status(
session.actor,
session.user.user_id,
AccountStatus.DISABLED,
correlation_id="corr-disable",
)
linked = service.link_identity(
session.actor,
session.user.user_id,
issuer="https://issuer.example.test",
subject="alternate-subject",
provider="fixture",
correlation_id="corr-link",
)
linked_session = service.me(
human_actor_claims(subject="alternate-subject"),
correlation_id="corr-linked-me",
)
self.assertEqual(disabled.status, AccountStatus.DISABLED)
self.assertEqual(linked.user_id, session.user.user_id)
self.assertEqual(linked_session.user.user_id, session.user.user_id)
self.assertIn(
"identity.linked",
[event.event_type for event in service.outbox_events()],
)
def test_catalog_profile_effective_profile_and_projection_flow(self):
service, _, _ = _service()
session = _bootstrap_app_and_catalog(service)
before_audit = len(service.audit_records())
before_outbox = len(service.outbox_events())
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.display_density",
"compact",
correlation_id="corr-global-density",
)
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.display_density",
"comfortable",
scope=ProfileScope.APPLICATION,
scope_id="app.demo",
application_id="app.demo",
correlation_id="corr-app-density",
)
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.recovery_hint",
"first keyboard",
correlation_id="corr-sensitive",
)
effective = service.effective_profile(
session.actor,
session.user.user_id,
application_id="app.demo",
correlation_id="corr-effective",
)
runtime = service.projection(
session.actor,
session.user.user_id,
ProjectionType.APPLICATION_RUNTIME,
application_id="app.demo",
correlation_id="corr-runtime",
)
self.assertEqual(effective.values["demo.display_density"], "comfortable")
self.assertEqual(
effective.source_layers["demo.display_density"],
"application:app.demo",
)
self.assertEqual(runtime.values["demo.recovery_hint"], REDACTED)
self.assertEqual(runtime.redactions["demo.recovery_hint"], "sensitivity")
self.assertEqual(len(service.audit_records()), before_audit + 3)
self.assertEqual(len(service.outbox_events()), before_outbox + 3)
def test_catalog_validation_rejects_duplicate_keys(self):
service, _, _ = _service()
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",
)
attribute = _display_density_attribute()
catalog = Catalog(
catalog_id="demo-duplicate",
namespace="demo",
version="0.1.0",
owning_application_id="app.demo",
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(attribute, attribute),
)
with self.assertRaises(ValidationError):
service.publish_catalog(session.actor, catalog, correlation_id="corr-cat")
def test_profile_value_validation_rejects_invalid_enum(self):
service, _, _ = _service()
session = _bootstrap_app_and_catalog(service)
before_outbox = len(service.outbox_events())
with self.assertRaises(ValidationError):
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.display_density",
"spacious",
correlation_id="corr-invalid-density",
)
self.assertEqual(len(service.outbox_events()), before_outbox)
def test_authorization_denial_does_not_mutate_profile_or_outbox(self):
service, store, _ = _service(
action_effects={"profile.write": AuthorizationEffect.DENY}
)
session = _bootstrap_app_and_catalog(service)
before_outbox = len(service.outbox_events())
before_audit = len(service.audit_records())
with self.assertRaises(AuthorizationDenied):
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.display_density",
"compact",
correlation_id="corr-denied-profile",
)
self.assertEqual(store.profile_values, {})
self.assertEqual(len(service.outbox_events()), before_outbox)
self.assertEqual(len(service.audit_records()), before_audit + 1)
self.assertEqual(service.audit_records()[-1].summary, "authorization denied")
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_app_and_catalog(service: UserEngineService):
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,
_mvp_catalog(),
correlation_id="corr-catalog",
)
return session
def _mvp_catalog() -> Catalog:
return Catalog(
catalog_id="demo-profile",
namespace="demo",
version="0.1.0",
owning_application_id="app.demo",
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
_display_density_attribute(),
AttributeDefinition(
key="demo.recovery_hint",
value_type="string",
scope=ProfileScope.GLOBAL,
sensitivity=Sensitivity.SENSITIVE,
visibility=(Visibility.USER, Visibility.APPLICATION, Visibility.ADMIN),
mutability=(Mutability.USER, Mutability.ADMIN),
),
),
)
def _display_density_attribute() -> AttributeDefinition:
return AttributeDefinition(
key="demo.display_density",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.USER, Visibility.APPLICATION),
mutability=(Mutability.USER,),
default="comfortable",
validation={"enum": ["compact", "comfortable"]},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Isolated MVP" title: "User Engine Isolated MVP"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: ready status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high
@@ -28,7 +28,7 @@ profile resolution, projections, audit, outbox, and tests.
```task ```task
id: USER-WP-0002-T1 id: USER-WP-0002-T1
status: todo status: done
priority: high priority: high
state_hub_task_id: "0b43c19e-7ca4-4d32-93f4-3c083a200092" state_hub_task_id: "0b43c19e-7ca4-4d32-93f4-3c083a200092"
``` ```
@@ -37,7 +37,7 @@ Implement the domain model and local persistence migrations.
```task ```task
id: USER-WP-0002-T2 id: USER-WP-0002-T2
status: todo status: done
priority: high priority: high
state_hub_task_id: "d6404f5c-292f-4eb5-819b-42fe8c237c60" state_hub_task_id: "d6404f5c-292f-4eb5-819b-42fe8c237c60"
``` ```
@@ -47,7 +47,7 @@ linking by `(issuer, subject)`.
```task ```task
id: USER-WP-0002-T3 id: USER-WP-0002-T3
status: todo status: done
priority: high priority: high
state_hub_task_id: "b0b0ad70-d590-4faf-916e-41dbf25d6c5f" state_hub_task_id: "b0b0ad70-d590-4faf-916e-41dbf25d6c5f"
``` ```
@@ -57,7 +57,7 @@ adapter.
```task ```task
id: USER-WP-0002-T4 id: USER-WP-0002-T4
status: todo status: done
priority: high priority: high
state_hub_task_id: "ce310565-75e3-4fb4-9358-0aaff14a8ada" state_hub_task_id: "ce310565-75e3-4fb4-9358-0aaff14a8ada"
``` ```
@@ -67,7 +67,7 @@ identity links, applications, catalogs, profiles, projections, and audit.
```task ```task
id: USER-WP-0002-T5 id: USER-WP-0002-T5
status: todo status: done
priority: high priority: high
state_hub_task_id: "4ebb8649-e3ff-4da8-80cd-eef8b1488129" state_hub_task_id: "4ebb8649-e3ff-4da8-80cd-eef8b1488129"
``` ```
@@ -77,7 +77,7 @@ application profile layers, and inspectable effective profile resolution.
```task ```task
id: USER-WP-0002-T6 id: USER-WP-0002-T6
status: todo status: done
priority: high priority: high
state_hub_task_id: "a238bbd8-95bb-499a-85f4-744acce188d4" state_hub_task_id: "a238bbd8-95bb-499a-85f4-744acce188d4"
``` ```
@@ -86,7 +86,7 @@ Persist audit records and outbox events atomically with mutations.
```task ```task
id: USER-WP-0002-T7 id: USER-WP-0002-T7
status: todo status: done
priority: high priority: high
state_hub_task_id: "a9826644-1fea-4ada-bc21-7c545e790ffc" state_hub_task_id: "a9826644-1fea-4ada-bc21-7c545e790ffc"
``` ```

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Multi-Tenancy" title: "User Engine Multi-Tenancy"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: ready status: active
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high

View File

@@ -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: ready status: active
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Integrated Test Scenarios" title: "User Engine Integrated Test Scenarios"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: ready status: active
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Implementation Assessment And Polish" title: "User Engine Implementation Assessment And Polish"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: ready status: active
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: medium planning_priority: medium