From 5203d9f45f1a03ac3bc652bbb786b5c8b40e2f57 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 21:20:19 +0200 Subject: [PATCH] Implement isolated user-engine MVP --- docs/development.md | 12 + src/user_engine/__init__.py | 4 +- src/user_engine/adapters/__init__.py | 11 + src/user_engine/adapters/local.py | 123 +++ src/user_engine/domain/__init__.py | 6 + src/user_engine/errors.py | 23 + src/user_engine/service.py | 750 ++++++++++++++++++ tests/test_isolated_mvp.py | 256 ++++++ workplans/USER-WP-0002-isolated-mvp.md | 16 +- workplans/USER-WP-0003-multi-tenancy.md | 2 +- ...USER-WP-0004-multi-application-catalogs.md | 2 +- .../USER-WP-0005-integrated-test-scenarios.md | 2 +- workplans/USER-WP-0006-finalization-polish.md | 2 +- 13 files changed, 1196 insertions(+), 13 deletions(-) create mode 100644 src/user_engine/adapters/__init__.py create mode 100644 src/user_engine/adapters/local.py create mode 100644 src/user_engine/errors.py create mode 100644 src/user_engine/service.py create mode 100644 tests/test_isolated_mvp.py diff --git a/docs/development.md b/docs/development.md index aa6c7cd..c40e690 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,9 +10,12 @@ tested immediately in local and agent environments. ```text src/user_engine/ + adapters/ local standalone adapters and deterministic test doubles 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, membership export, application bindings, and secrets + service.py headless service API for the isolated MVP testing/ local fixtures for tests and examples tests/ standard-library unittest suite ``` @@ -40,3 +43,12 @@ Add new behavior in this order: 2. local fixture or adapter; 3. test that proves the boundary; 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. diff --git a/src/user_engine/__init__.py b/src/user_engine/__init__.py index e2f2f16..00bed49 100644 --- a/src/user_engine/__init__.py +++ b/src/user_engine/__init__.py @@ -1,5 +1,7 @@ """Headless user-domain and profile engine.""" -__all__ = ["__version__"] +from user_engine.service import UserEngineService + +__all__ = ["UserEngineService", "__version__"] __version__ = "0.0.0" diff --git a/src/user_engine/adapters/__init__.py b/src/user_engine/adapters/__init__.py new file mode 100644 index 0000000..9894d75 --- /dev/null +++ b/src/user_engine/adapters/__init__.py @@ -0,0 +1,11 @@ +"""Local adapters for standalone user-engine setups.""" + +from user_engine.adapters.local import ( + InMemoryUserEngineStore, + LocalAuthorizationCheckPort, +) + +__all__ = [ + "InMemoryUserEngineStore", + "LocalAuthorizationCheckPort", +] diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py new file mode 100644 index 0000000..ae71c8a --- /dev/null +++ b/src/user_engine/adapters/local.py @@ -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) diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index 2df0433..ba80be2 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -14,6 +14,7 @@ from user_engine.domain.models import ( Catalog, CatalogLifecycle, ExternalIdentity, + ManagementMode, Membership, Mutability, OutboxEvent, @@ -24,6 +25,8 @@ from user_engine.domain.models import ( Sensitivity, User, Visibility, + new_id, + utc_now, ) __all__ = [ @@ -40,6 +43,7 @@ __all__ = [ "Catalog", "CatalogLifecycle", "ExternalIdentity", + "ManagementMode", "Membership", "Mutability", "OutboxEvent", @@ -50,4 +54,6 @@ __all__ = [ "Sensitivity", "User", "Visibility", + "new_id", + "utc_now", ] diff --git a/src/user_engine/errors.py b/src/user_engine/errors.py new file mode 100644 index 0000000..75825ab --- /dev/null +++ b/src/user_engine/errors.py @@ -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.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py new file mode 100644 index 0000000..3247f7c --- /dev/null +++ b/src/user_engine/service.py @@ -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 = "" + + +@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} diff --git a/tests/test_isolated_mvp.py b/tests/test_isolated_mvp.py new file mode 100644 index 0000000..9a932c2 --- /dev/null +++ b/tests/test_isolated_mvp.py @@ -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() diff --git a/workplans/USER-WP-0002-isolated-mvp.md b/workplans/USER-WP-0002-isolated-mvp.md index 2697ffc..16258f5 100644 --- a/workplans/USER-WP-0002-isolated-mvp.md +++ b/workplans/USER-WP-0002-isolated-mvp.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Isolated MVP" domain: netkingdom repo: user-engine -status: ready +status: finished owner: codex topic_slug: netkingdom planning_priority: high @@ -28,7 +28,7 @@ profile resolution, projections, audit, outbox, and tests. ```task id: USER-WP-0002-T1 -status: todo +status: done priority: high state_hub_task_id: "0b43c19e-7ca4-4d32-93f4-3c083a200092" ``` @@ -37,7 +37,7 @@ Implement the domain model and local persistence migrations. ```task id: USER-WP-0002-T2 -status: todo +status: done priority: high state_hub_task_id: "d6404f5c-292f-4eb5-819b-42fe8c237c60" ``` @@ -47,7 +47,7 @@ linking by `(issuer, subject)`. ```task id: USER-WP-0002-T3 -status: todo +status: done priority: high state_hub_task_id: "b0b0ad70-d590-4faf-916e-41dbf25d6c5f" ``` @@ -57,7 +57,7 @@ adapter. ```task id: USER-WP-0002-T4 -status: todo +status: done priority: high state_hub_task_id: "ce310565-75e3-4fb4-9358-0aaff14a8ada" ``` @@ -67,7 +67,7 @@ identity links, applications, catalogs, profiles, projections, and audit. ```task id: USER-WP-0002-T5 -status: todo +status: done priority: high state_hub_task_id: "4ebb8649-e3ff-4da8-80cd-eef8b1488129" ``` @@ -77,7 +77,7 @@ application profile layers, and inspectable effective profile resolution. ```task id: USER-WP-0002-T6 -status: todo +status: done priority: high state_hub_task_id: "a238bbd8-95bb-499a-85f4-744acce188d4" ``` @@ -86,7 +86,7 @@ Persist audit records and outbox events atomically with mutations. ```task id: USER-WP-0002-T7 -status: todo +status: done priority: high state_hub_task_id: "a9826644-1fea-4ada-bc21-7c545e790ffc" ``` diff --git a/workplans/USER-WP-0003-multi-tenancy.md b/workplans/USER-WP-0003-multi-tenancy.md index ff37a42..fc9b49f 100644 --- a/workplans/USER-WP-0003-multi-tenancy.md +++ b/workplans/USER-WP-0003-multi-tenancy.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Multi-Tenancy" domain: netkingdom repo: user-engine -status: ready +status: active owner: codex topic_slug: netkingdom planning_priority: high diff --git a/workplans/USER-WP-0004-multi-application-catalogs.md b/workplans/USER-WP-0004-multi-application-catalogs.md index f011700..f41e8eb 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: ready +status: active owner: codex topic_slug: netkingdom planning_priority: high diff --git a/workplans/USER-WP-0005-integrated-test-scenarios.md b/workplans/USER-WP-0005-integrated-test-scenarios.md index e74de57..c64516c 100644 --- a/workplans/USER-WP-0005-integrated-test-scenarios.md +++ b/workplans/USER-WP-0005-integrated-test-scenarios.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Integrated Test Scenarios" domain: netkingdom repo: user-engine -status: ready +status: active owner: codex topic_slug: netkingdom planning_priority: high diff --git a/workplans/USER-WP-0006-finalization-polish.md b/workplans/USER-WP-0006-finalization-polish.md index 2393aee..22fc7fb 100644 --- a/workplans/USER-WP-0006-finalization-polish.md +++ b/workplans/USER-WP-0006-finalization-polish.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Implementation Assessment And Polish" domain: netkingdom repo: user-engine -status: ready +status: active owner: codex topic_slug: netkingdom planning_priority: medium