generated from coulomb/repo-seed
Implement isolated user-engine MVP
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
11
src/user_engine/adapters/__init__.py
Normal file
11
src/user_engine/adapters/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Local adapters for standalone user-engine setups."""
|
||||||
|
|
||||||
|
from user_engine.adapters.local import (
|
||||||
|
InMemoryUserEngineStore,
|
||||||
|
LocalAuthorizationCheckPort,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InMemoryUserEngineStore",
|
||||||
|
"LocalAuthorizationCheckPort",
|
||||||
|
]
|
||||||
123
src/user_engine/adapters/local.py
Normal file
123
src/user_engine/adapters/local.py
Normal 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)
|
||||||
@@ -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
23
src/user_engine/errors.py
Normal 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
750
src/user_engine/service.py
Normal 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
256
tests/test_isolated_mvp.py
Normal 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()
|
||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user