generated from coulomb/repo-seed
Start user-engine implementation scaffold
This commit is contained in:
5
src/user_engine/__init__.py
Normal file
5
src/user_engine/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Headless user-domain and profile engine."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.0.0"
|
||||
53
src/user_engine/domain/__init__.py
Normal file
53
src/user_engine/domain/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Domain schemas for user-engine."""
|
||||
|
||||
from user_engine.domain.models import (
|
||||
Account,
|
||||
AccountStatus,
|
||||
Actor,
|
||||
Application,
|
||||
ApplicationBinding,
|
||||
AttributeDefinition,
|
||||
AuditRecord,
|
||||
AuthorizationDecision,
|
||||
AuthorizationEffect,
|
||||
AuthorizationRequest,
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
ExternalIdentity,
|
||||
Membership,
|
||||
Mutability,
|
||||
OutboxEvent,
|
||||
PrincipalType,
|
||||
ProfileScope,
|
||||
ProfileValue,
|
||||
ProjectionType,
|
||||
Sensitivity,
|
||||
User,
|
||||
Visibility,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountStatus",
|
||||
"Actor",
|
||||
"Application",
|
||||
"ApplicationBinding",
|
||||
"AttributeDefinition",
|
||||
"AuditRecord",
|
||||
"AuthorizationDecision",
|
||||
"AuthorizationEffect",
|
||||
"AuthorizationRequest",
|
||||
"Catalog",
|
||||
"CatalogLifecycle",
|
||||
"ExternalIdentity",
|
||||
"Membership",
|
||||
"Mutability",
|
||||
"OutboxEvent",
|
||||
"PrincipalType",
|
||||
"ProfileScope",
|
||||
"ProfileValue",
|
||||
"ProjectionType",
|
||||
"Sensitivity",
|
||||
"User",
|
||||
"Visibility",
|
||||
]
|
||||
276
src/user_engine/domain/models.py
Normal file
276
src/user_engine/domain/models.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Core user-engine domain schemas.
|
||||
|
||||
These dataclasses are deliberately persistence- and transport-neutral. API
|
||||
handlers, databases, and platform adapters should translate into and out of
|
||||
these shapes instead of putting domain rules in infrastructure code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import StrEnum
|
||||
from typing import Any, Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
"""Return an opaque local identifier with a readable type prefix."""
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PrincipalType(StrEnum):
|
||||
HUMAN = "human"
|
||||
SERVICE = "service"
|
||||
AGENT = "agent"
|
||||
|
||||
|
||||
class AccountStatus(StrEnum):
|
||||
INVITED = "invited"
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
SUSPENDED = "suspended"
|
||||
DELETION_PENDING = "deletion_pending"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class ManagementMode(StrEnum):
|
||||
LOCAL = "local"
|
||||
EXTERNALLY_PROVISIONED = "externally_provisioned"
|
||||
FEDERATED = "federated"
|
||||
SERVICE_MANAGED = "service_managed"
|
||||
|
||||
|
||||
class ProfileScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
TENANT = "tenant"
|
||||
APPLICATION = "application"
|
||||
MEMBERSHIP = "membership"
|
||||
|
||||
|
||||
class ProjectionType(StrEnum):
|
||||
SELF_SERVICE = "self_service"
|
||||
ADMIN = "admin"
|
||||
APPLICATION_RUNTIME = "application_runtime"
|
||||
AUDIT = "audit"
|
||||
AGENT_CONTEXT = "agent_context"
|
||||
CLAIMS_ENRICHMENT = "claims_enrichment"
|
||||
|
||||
|
||||
class Sensitivity(StrEnum):
|
||||
PUBLIC = "public"
|
||||
INTERNAL = "internal"
|
||||
PERSONAL = "personal"
|
||||
SENSITIVE = "sensitive"
|
||||
SECRET = "secret"
|
||||
|
||||
|
||||
class Visibility(StrEnum):
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
APPLICATION = "application"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class Mutability(StrEnum):
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
APPLICATION = "application"
|
||||
SYSTEM = "system"
|
||||
READ_ONLY = "read_only"
|
||||
|
||||
|
||||
class CatalogLifecycle(StrEnum):
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
DEPRECATED = "deprecated"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
class AuthorizationEffect(StrEnum):
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
REDACT = "redact"
|
||||
AUDIT_ONLY = "audit_only"
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Actor:
|
||||
issuer: str
|
||||
subject: str
|
||||
tenant: str
|
||||
principal_type: PrincipalType
|
||||
audience: tuple[str, ...]
|
||||
roles: tuple[str, ...] = ()
|
||||
groups: tuple[str, ...] = ()
|
||||
scopes: tuple[str, ...] = ()
|
||||
assurance: Mapping[str, Any] = field(default_factory=dict)
|
||||
authorized_party: str | None = None
|
||||
preferred_username: str | None = None
|
||||
claims: Mapping[str, Any] = field(default_factory=dict)
|
||||
agent: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def identity_key(self) -> tuple[str, str]:
|
||||
return (self.issuer, self.subject)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class User:
|
||||
user_id: str = field(default_factory=lambda: new_id("usr"))
|
||||
display_name: str | None = None
|
||||
primary_email: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
account_id: str
|
||||
user_id: str
|
||||
status: AccountStatus = AccountStatus.INVITED
|
||||
management_mode: ManagementMode = ManagementMode.LOCAL
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalIdentity:
|
||||
identity_id: str
|
||||
user_id: str
|
||||
issuer: str
|
||||
subject: str
|
||||
provider: str | None = None
|
||||
linked_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
@property
|
||||
def identity_key(self) -> tuple[str, str]:
|
||||
return (self.issuer, self.subject)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Application:
|
||||
application_id: str
|
||||
display_name: str
|
||||
owner: str
|
||||
allowed_profile_scopes: tuple[ProfileScope, ...] = (ProfileScope.GLOBAL,)
|
||||
allowed_projection_types: tuple[ProjectionType, ...] = (
|
||||
ProjectionType.APPLICATION_RUNTIME,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationBinding:
|
||||
application_id: str
|
||||
oidc_client_id: str | None = None
|
||||
protected_system_id: str | None = None
|
||||
catalog_namespaces: tuple[str, ...] = ()
|
||||
event_source: str | None = None
|
||||
deployment_ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttributeDefinition:
|
||||
key: str
|
||||
value_type: str
|
||||
scope: ProfileScope
|
||||
sensitivity: Sensitivity
|
||||
visibility: tuple[Visibility, ...]
|
||||
mutability: tuple[Mutability, ...]
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
validation: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Catalog:
|
||||
catalog_id: str
|
||||
namespace: str
|
||||
version: str
|
||||
owning_application_id: str
|
||||
lifecycle: CatalogLifecycle = CatalogLifecycle.DRAFT
|
||||
attributes: tuple[AttributeDefinition, ...] = ()
|
||||
|
||||
def attribute_keys(self) -> set[str]:
|
||||
return {attribute.key for attribute in self.attributes}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileValue:
|
||||
user_id: str
|
||||
attribute_key: str
|
||||
value: Any
|
||||
scope: ProfileScope = ProfileScope.GLOBAL
|
||||
scope_id: str | None = None
|
||||
source: str = "user-engine"
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Membership:
|
||||
membership_id: str
|
||||
user_id: str
|
||||
tenant: str
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
kind: str
|
||||
source_system: str = "user-engine"
|
||||
owning_system: str = "user-engine"
|
||||
freshness_version: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationRequest:
|
||||
actor: Actor
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
tenant: str
|
||||
correlation_id: str
|
||||
application_id: str | None = None
|
||||
target_user_id: str | None = None
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationDecision:
|
||||
effect: AuthorizationEffect
|
||||
decision_id: str = field(default_factory=lambda: new_id("dec"))
|
||||
reason: str | None = None
|
||||
obligations: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.effect in {
|
||||
AuthorizationEffect.ALLOW,
|
||||
AuthorizationEffect.AUDIT_ONLY,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditRecord:
|
||||
audit_id: str
|
||||
actor: Actor
|
||||
action: str
|
||||
subject: str
|
||||
tenant: str
|
||||
correlation_id: str
|
||||
decision_id: str | None = None
|
||||
application_id: str | None = None
|
||||
summary: str | None = None
|
||||
recorded_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboxEvent:
|
||||
event_id: str
|
||||
event_type: str
|
||||
aggregate_id: str
|
||||
payload: Mapping[str, Any]
|
||||
tenant: str
|
||||
correlation_id: str
|
||||
occurred_at: datetime = field(default_factory=utc_now)
|
||||
83
src/user_engine/ports.py
Normal file
83
src/user_engine/ports.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Implementation ports for user-engine adapters.
|
||||
|
||||
The domain layer should depend on these protocols. Concrete implementations
|
||||
can be local test adapters, HTTP clients, database-backed stores, or platform
|
||||
adapters without changing domain code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping, Protocol
|
||||
|
||||
from user_engine.domain import (
|
||||
Actor,
|
||||
ApplicationBinding,
|
||||
AuditRecord,
|
||||
AuthorizationDecision,
|
||||
AuthorizationRequest,
|
||||
Membership,
|
||||
OutboxEvent,
|
||||
)
|
||||
|
||||
|
||||
class IdentityClaimsAdapter(Protocol):
|
||||
"""Normalize verified identity claims into a user-engine actor."""
|
||||
|
||||
def normalize(self, claims: Mapping[str, Any]) -> Actor:
|
||||
"""Return a normalized actor from already-verified claims."""
|
||||
|
||||
def identity_key(self, actor: Actor) -> tuple[str, str]:
|
||||
"""Return the stable external identity link key."""
|
||||
|
||||
|
||||
class AuthorizationCheckPort(Protocol):
|
||||
"""Ask whether an actor may perform an action."""
|
||||
|
||||
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
|
||||
"""Return the authorization decision for one request."""
|
||||
|
||||
def batch_check(
|
||||
self, requests: Iterable[AuthorizationRequest]
|
||||
) -> tuple[AuthorizationDecision, ...]:
|
||||
"""Return decisions in request order."""
|
||||
|
||||
|
||||
class ApplicationBindingStore(Protocol):
|
||||
"""Store links between user-engine applications and external systems."""
|
||||
|
||||
def get(self, application_id: str) -> ApplicationBinding | None:
|
||||
"""Return a binding by user-engine application id."""
|
||||
|
||||
def save(self, binding: ApplicationBinding) -> None:
|
||||
"""Create or replace an application binding."""
|
||||
|
||||
|
||||
class MembershipFactExporter(Protocol):
|
||||
"""Export membership facts as read models for authorization systems."""
|
||||
|
||||
def export(self, memberships: Iterable[Membership]) -> Mapping[str, Any]:
|
||||
"""Return an adapter-neutral membership fact manifest."""
|
||||
|
||||
|
||||
class EventOutbox(Protocol):
|
||||
"""Persist and publish durable domain events."""
|
||||
|
||||
def append(self, event: OutboxEvent) -> None:
|
||||
"""Append an event in the same unit of work as its mutation."""
|
||||
|
||||
def pending(self) -> tuple[OutboxEvent, ...]:
|
||||
"""Return events waiting for delivery."""
|
||||
|
||||
|
||||
class AuditWriter(Protocol):
|
||||
"""Persist local audit records and support platform audit export."""
|
||||
|
||||
def record(self, audit_record: AuditRecord) -> None:
|
||||
"""Persist an audit record."""
|
||||
|
||||
|
||||
class SecretProvider(Protocol):
|
||||
"""Load runtime secret material from the active environment."""
|
||||
|
||||
def get(self, name: str) -> str:
|
||||
"""Return a secret value by logical name."""
|
||||
1
src/user_engine/testing/__init__.py
Normal file
1
src/user_engine/testing/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Testing helpers and local fixtures for user-engine."""
|
||||
151
src/user_engine/testing/fixtures.py
Normal file
151
src/user_engine/testing/fixtures.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Local fixtures used by early user-engine tests and examples."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from user_engine.domain import (
|
||||
Actor,
|
||||
Application,
|
||||
ApplicationBinding,
|
||||
AttributeDefinition,
|
||||
AuthorizationDecision,
|
||||
AuthorizationEffect,
|
||||
AuthorizationRequest,
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
Mutability,
|
||||
PrincipalType,
|
||||
ProfileScope,
|
||||
ProjectionType,
|
||||
Sensitivity,
|
||||
Visibility,
|
||||
)
|
||||
from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter
|
||||
|
||||
|
||||
class FixtureIdentityClaimsAdapter:
|
||||
"""Normalize dictionary fixtures that already passed token verification."""
|
||||
|
||||
def normalize(self, claims: Mapping[str, object]) -> Actor:
|
||||
scopes = claims.get("scope", ())
|
||||
if isinstance(scopes, str):
|
||||
scopes = tuple(part for part in scopes.split(" ") if part)
|
||||
return Actor(
|
||||
issuer=str(claims["iss"]),
|
||||
subject=str(claims["sub"]),
|
||||
tenant=str(claims["tenant"]),
|
||||
principal_type=PrincipalType(str(claims["principal_type"])),
|
||||
audience=tuple(_as_tuple(claims.get("aud", ()))),
|
||||
roles=tuple(_as_tuple(claims.get("roles", ()))),
|
||||
groups=tuple(_as_tuple(claims.get("groups", ()))),
|
||||
scopes=tuple(_as_tuple(scopes)),
|
||||
assurance=dict(claims.get("assurance", {})),
|
||||
authorized_party=_optional_str(claims.get("azp") or claims.get("client_id")),
|
||||
preferred_username=_optional_str(claims.get("preferred_username")),
|
||||
claims=dict(claims),
|
||||
agent=dict(claims.get("agent", {})),
|
||||
)
|
||||
|
||||
def identity_key(self, actor: Actor) -> tuple[str, str]:
|
||||
return actor.identity_key
|
||||
|
||||
|
||||
class StaticAuthorizationCheckPort:
|
||||
"""Deterministic authorization adapter for tests and local examples."""
|
||||
|
||||
def __init__(self, effect: AuthorizationEffect = AuthorizationEffect.ALLOW):
|
||||
self.effect = effect
|
||||
self.requests: list[AuthorizationRequest] = []
|
||||
|
||||
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
|
||||
self.requests.append(request)
|
||||
return AuthorizationDecision(effect=self.effect, reason="fixture")
|
||||
|
||||
def batch_check(
|
||||
self, requests: Iterable[AuthorizationRequest]
|
||||
) -> tuple[AuthorizationDecision, ...]:
|
||||
return tuple(self.check(request) for request in requests)
|
||||
|
||||
|
||||
def human_actor_claims(
|
||||
*,
|
||||
issuer: str = "https://issuer.example.test",
|
||||
subject: str = "user-123",
|
||||
tenant: str = "tenant:coulomb",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"iss": issuer,
|
||||
"sub": subject,
|
||||
"aud": ["user-engine"],
|
||||
"tenant": tenant,
|
||||
"principal_type": "human",
|
||||
"groups": ["tenant:coulomb:users"],
|
||||
"roles": ["user"],
|
||||
"scope": "openid profile email",
|
||||
"preferred_username": "sample.user",
|
||||
"email": "sample.user@example.test",
|
||||
"assurance": {
|
||||
"level": "aal2",
|
||||
"methods": ["pwd", "otp"],
|
||||
"mfa": True,
|
||||
"source": "fixture",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sample_application() -> Application:
|
||||
return Application(
|
||||
application_id="app.demo",
|
||||
display_name="Demo Application",
|
||||
owner="team:demo",
|
||||
allowed_profile_scopes=(ProfileScope.GLOBAL, ProfileScope.APPLICATION),
|
||||
allowed_projection_types=(ProjectionType.APPLICATION_RUNTIME,),
|
||||
)
|
||||
|
||||
|
||||
def sample_application_binding() -> ApplicationBinding:
|
||||
return ApplicationBinding(
|
||||
application_id="app.demo",
|
||||
oidc_client_id="demo-client",
|
||||
protected_system_id="user-engine.demo",
|
||||
catalog_namespaces=("demo",),
|
||||
event_source="user-engine.demo",
|
||||
deployment_ref="local",
|
||||
)
|
||||
|
||||
|
||||
def sample_catalog() -> Catalog:
|
||||
return Catalog(
|
||||
catalog_id="demo-profile",
|
||||
namespace="demo",
|
||||
version="0.1.0",
|
||||
owning_application_id="app.demo",
|
||||
lifecycle=CatalogLifecycle.ACTIVE,
|
||||
attributes=(
|
||||
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"]},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _as_tuple(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
return tuple(str(item) for item in value)
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
Reference in New Issue
Block a user