generated from coulomb/repo-seed
Start user-engine implementation scaffold
This commit is contained in:
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: test
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
|
|
||||||
|
test:
|
||||||
|
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -p 'test_*.py'
|
||||||
11
README.md
11
README.md
@@ -1 +1,10 @@
|
|||||||
Headless multi-application, multi-tenant user mangement engine.
|
Headless multi-application, multi-tenant user management engine.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/development.md` and `docs/configuration.md` for the initial
|
||||||
|
implementation boundaries.
|
||||||
|
|||||||
44
docs/configuration.md
Normal file
44
docs/configuration.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Configuration Boundaries
|
||||||
|
|
||||||
|
## Standalone Mode
|
||||||
|
|
||||||
|
Standalone mode is for local development, tests, prototypes, and small
|
||||||
|
single-service deployments.
|
||||||
|
|
||||||
|
Expected characteristics:
|
||||||
|
|
||||||
|
- local configuration file or environment variables;
|
||||||
|
- local database or file-backed persistence during early development;
|
||||||
|
- fixture or local identity claims adapter;
|
||||||
|
- deterministic authorization test adapter;
|
||||||
|
- no password, MFA, or token issuance responsibility inside user-engine.
|
||||||
|
|
||||||
|
## Platform Mode
|
||||||
|
|
||||||
|
Platform mode is for a NetKingdom-aligned shared service deployment.
|
||||||
|
|
||||||
|
Expected characteristics:
|
||||||
|
|
||||||
|
- verified IAM Profile claims arrive from an identity layer;
|
||||||
|
- authorization decisions are requested through the authorization check port;
|
||||||
|
- runtime secrets are delivered through a scoped secret provider;
|
||||||
|
- audit records and outbox events are correlated with platform sinks;
|
||||||
|
- tenant and application bindings are explicit.
|
||||||
|
|
||||||
|
## Secret Names
|
||||||
|
|
||||||
|
The code should refer to logical secret names, not platform paths. Concrete
|
||||||
|
secret lookup is owned by the active `SecretProvider` adapter.
|
||||||
|
|
||||||
|
Initial logical names:
|
||||||
|
|
||||||
|
- `database.url`
|
||||||
|
- `event.signing_key`
|
||||||
|
- `webhook.shared_secret`
|
||||||
|
|
||||||
|
## Production Guardrails
|
||||||
|
|
||||||
|
- Local issuers must be rejected by production adapters.
|
||||||
|
- Sensitive writes must fail closed when authorization is unavailable.
|
||||||
|
- Claims enrichment must be optional and must not make user-engine a token
|
||||||
|
issuer.
|
||||||
42
docs/development.md
Normal file
42
docs/development.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
The initial implementation uses Python 3.12 and the standard library. The
|
||||||
|
first slice intentionally avoids runtime dependencies so the repository can be
|
||||||
|
tested immediately in local and agent environments.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/user_engine/
|
||||||
|
domain/ transport- and persistence-neutral domain schemas
|
||||||
|
ports.py adapter protocols for identity, authorization, events, audit,
|
||||||
|
membership export, application bindings, and secrets
|
||||||
|
testing/ local fixtures for tests and examples
|
||||||
|
tests/ standard-library unittest suite
|
||||||
|
```
|
||||||
|
|
||||||
|
The domain layer should not import HTTP frameworks, database clients, or
|
||||||
|
platform-specific SDKs. Those integrations belong behind ports.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
The command runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Rule
|
||||||
|
|
||||||
|
Add new behavior in this order:
|
||||||
|
|
||||||
|
1. domain schema or port;
|
||||||
|
2. local fixture or adapter;
|
||||||
|
3. test that proves the boundary;
|
||||||
|
4. infrastructure adapter or API surface.
|
||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "user-engine"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Headless user-domain and profile engine."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.user-engine]
|
||||||
|
package = "user_engine"
|
||||||
|
workplan = "USER-WP-0001"
|
||||||
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)
|
||||||
28
tests/test_domain_models.py
Normal file
28
tests/test_domain_models.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from user_engine.domain import (
|
||||||
|
AuthorizationDecision,
|
||||||
|
AuthorizationEffect,
|
||||||
|
CatalogLifecycle,
|
||||||
|
)
|
||||||
|
from user_engine.testing.fixtures import sample_catalog
|
||||||
|
|
||||||
|
|
||||||
|
class DomainModelTests(unittest.TestCase):
|
||||||
|
def test_catalog_exposes_attribute_keys(self):
|
||||||
|
catalog = sample_catalog()
|
||||||
|
|
||||||
|
self.assertEqual(catalog.lifecycle, CatalogLifecycle.ACTIVE)
|
||||||
|
self.assertEqual(catalog.attribute_keys(), {"demo.display_density"})
|
||||||
|
|
||||||
|
def test_authorization_decision_allowed_property(self):
|
||||||
|
self.assertTrue(
|
||||||
|
AuthorizationDecision(effect=AuthorizationEffect.ALLOW).allowed
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
AuthorizationDecision(effect=AuthorizationEffect.DENY).allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
49
tests/test_ports_and_fixtures.py
Normal file
49
tests/test_ports_and_fixtures.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from user_engine.domain import AuthorizationEffect, AuthorizationRequest
|
||||||
|
from user_engine.testing.fixtures import (
|
||||||
|
FixtureIdentityClaimsAdapter,
|
||||||
|
StaticAuthorizationCheckPort,
|
||||||
|
human_actor_claims,
|
||||||
|
sample_application_binding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortFixtureTests(unittest.TestCase):
|
||||||
|
def test_fixture_identity_claims_adapter_normalizes_actor(self):
|
||||||
|
adapter = FixtureIdentityClaimsAdapter()
|
||||||
|
actor = adapter.normalize(human_actor_claims())
|
||||||
|
|
||||||
|
self.assertEqual(actor.identity_key, ("https://issuer.example.test", "user-123"))
|
||||||
|
self.assertEqual(actor.tenant, "tenant:coulomb")
|
||||||
|
self.assertIn("profile", actor.scopes)
|
||||||
|
|
||||||
|
def test_static_authorization_check_records_requests(self):
|
||||||
|
adapter = FixtureIdentityClaimsAdapter()
|
||||||
|
actor = adapter.normalize(human_actor_claims())
|
||||||
|
authz = StaticAuthorizationCheckPort(effect=AuthorizationEffect.ALLOW)
|
||||||
|
request = AuthorizationRequest(
|
||||||
|
actor=actor,
|
||||||
|
resource_type="user-engine:profile",
|
||||||
|
resource_id="usr_123",
|
||||||
|
action="read",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-1",
|
||||||
|
target_user_id="usr_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = authz.check(request)
|
||||||
|
|
||||||
|
self.assertTrue(decision.allowed)
|
||||||
|
self.assertEqual(authz.requests, [request])
|
||||||
|
|
||||||
|
def test_sample_application_binding_keeps_external_ids_separate(self):
|
||||||
|
binding = sample_application_binding()
|
||||||
|
|
||||||
|
self.assertEqual(binding.application_id, "app.demo")
|
||||||
|
self.assertEqual(binding.oidc_client_id, "demo-client")
|
||||||
|
self.assertEqual(binding.protected_system_id, "user-engine.demo")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "User Engine Preparation And Interface Adoption"
|
title: "User Engine Preparation And Interface Adoption"
|
||||||
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
|
||||||
@@ -25,7 +25,7 @@ from the PRD, architecture blueprint, and NetKingdom interface guidance.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0001-T1
|
id: USER-WP-0001-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7f91d466-2f97-4acf-832a-e9de0b21be04"
|
state_hub_task_id: "7f91d466-2f97-4acf-832a-e9de0b21be04"
|
||||||
```
|
```
|
||||||
@@ -35,7 +35,7 @@ test commands, and module boundaries.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0001-T2
|
id: USER-WP-0001-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "940ce697-fbad-4ef9-9024-175f482b2d3a"
|
state_hub_task_id: "940ce697-fbad-4ef9-9024-175f482b2d3a"
|
||||||
```
|
```
|
||||||
@@ -46,7 +46,7 @@ audit writer, and secret provider.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0001-T3
|
id: USER-WP-0001-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c16bd102-0f86-4951-86ee-8d6088ac888b"
|
state_hub_task_id: "c16bd102-0f86-4951-86ee-8d6088ac888b"
|
||||||
```
|
```
|
||||||
@@ -56,7 +56,7 @@ catalogs, profile values, memberships, audit records, and outbox events.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0001-T4
|
id: USER-WP-0001-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "9e962947-6d1f-49ae-a362-d8a13d430b87"
|
state_hub_task_id: "9e962947-6d1f-49ae-a362-d8a13d430b87"
|
||||||
```
|
```
|
||||||
@@ -66,7 +66,7 @@ authorization decisions.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0001-T5
|
id: USER-WP-0001-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c5b24341-ae26-48a0-97ac-6fbcf108b0e0"
|
state_hub_task_id: "c5b24341-ae26-48a0-97ac-6fbcf108b0e0"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user