Add integrated user-engine scenarios

This commit is contained in:
2026-05-22 21:39:10 +02:00
parent 4058d15dbc
commit 9b241ab2e3
6 changed files with 525 additions and 12 deletions

View File

@@ -1,6 +1,14 @@
.PHONY: test .PHONY: test test-unit test-scenarios test-integration test-conformance
PYTHON ?= python3 PYTHON ?= python3
test: test: test-unit
test-unit:
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -p 'test_*.py' PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -p 'test_*.py'
test-scenarios: test-unit
test-integration: test-unit
test-conformance: test-unit

View File

@@ -6,5 +6,6 @@ Headless multi-application, multi-tenant user management engine.
make test make test
``` ```
See `docs/development.md`, `docs/configuration.md`, and `docs/examples.md` See `docs/development.md`, `docs/configuration.md`, `docs/examples.md`, and
for implementation boundaries and local usage examples. `docs/scenarios.md` for implementation boundaries, local usage examples, and
scenario coverage.

37
docs/scenarios.md Normal file
View File

@@ -0,0 +1,37 @@
# Integrated Scenario Matrix
The scenario suite is the conformance target for repos that integrate
user-engine. It keeps identity, authorization, tenant, application, profile,
projection, audit, and event behavior testable without a UI.
| Scenario | Proves |
| --- | --- |
| standalone_self_service | A verified actor can resolve `me`, write profile values, and read a projection. |
| denied_access | Authorization denials do not mutate state or emit outbox events. |
| tenant_admin | Tenant admins can manage in-tenant account, membership, and profile state. |
| platform_operator | Platform operators can operate across tenants without tenant-admin overreach. |
| cross_tenant_denial | Tenant actors cannot manage another tenant or platform root. |
| two_applications | Catalog ownership and projections prevent application data leakage. |
| sensitive_redaction | Sensitive values are redacted in runtime and claims-enrichment projections. |
| audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. |
## Fixture Actors
`user_engine.testing.scenarios` provides fixtures for human, service, agent,
delegated agent, tenant admin, platform operator, break-glass, local issuer,
invalid, expired, and missing-tenant identities.
## Commands
All suites currently run through the standard-library test runner:
```bash
make test
make test-scenarios
make test-integration
make test-conformance
```
The separate targets are aliases today. They are intentionally present so CI
can split unit, scenario, integration, and conformance execution later without
changing consumer documentation.

View File

@@ -0,0 +1,158 @@
"""Reusable scenario fixtures for user-engine conformance tests."""
from __future__ import annotations
from typing import Iterable, Mapping
from user_engine.domain import (
Actor,
AuthorizationDecision,
AuthorizationEffect,
AuthorizationRequest,
PrincipalType,
)
from user_engine.errors import ValidationError
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
)
SCENARIO_MATRIX = (
"standalone_self_service",
"denied_access",
"tenant_admin",
"platform_operator",
"cross_tenant_denial",
"two_applications",
"sensitive_redaction",
"audit_event_replay",
)
class StrictFixtureIdentityClaimsAdapter(FixtureIdentityClaimsAdapter):
"""Fixture adapter with production-like negative checks."""
def normalize(self, claims: Mapping[str, object]) -> Actor:
for required in ("iss", "sub", "tenant", "principal_type"):
if not claims.get(required):
raise ValidationError(f"{required} claim is required")
issuer = str(claims.get("iss", ""))
if issuer.startswith("local:"):
raise ValidationError("local issuers are not accepted")
if claims.get("expired"):
raise ValidationError("expired claims are not accepted")
return super().normalize(claims)
class ScenarioAuthorizationHarness:
"""Authorization harness for scenario and conformance-style tests."""
def __init__(
self,
*,
default_effect: AuthorizationEffect = AuthorizationEffect.ALLOW,
action_effects: dict[str, AuthorizationEffect] | None = None,
action_obligations: dict[str, tuple[str, ...]] | None = None,
) -> None:
self.default_effect = default_effect
self.action_effects = action_effects or {}
self.action_obligations = action_obligations 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)
if _cross_tenant_denied(request) or _assurance_denied(request):
effect = AuthorizationEffect.DENY
return AuthorizationDecision(
effect=effect,
reason="scenario",
obligations=self.action_obligations.get(request.action, ()),
)
def batch_check(
self, requests: Iterable[AuthorizationRequest]
) -> tuple[AuthorizationDecision, ...]:
return tuple(self.check(request) for request in requests)
def human_claims() -> dict[str, object]:
return human_actor_claims(subject="human-user")
def service_claims() -> dict[str, object]:
claims = human_actor_claims(subject="service-client")
claims["principal_type"] = PrincipalType.SERVICE.value
claims["roles"] = ["service"]
claims["azp"] = "service-client"
return claims
def agent_claims() -> dict[str, object]:
claims = human_actor_claims(subject="agent-worker")
claims["principal_type"] = PrincipalType.AGENT.value
claims["roles"] = ["agent"]
return claims
def delegated_agent_claims() -> dict[str, object]:
claims = agent_claims()
claims["agent"] = {"delegated_by": "human-user", "purpose": "support"}
return claims
def tenant_admin_claims() -> dict[str, object]:
claims = human_actor_claims(subject="tenant-admin")
claims["roles"] = ["tenant-admin"]
return claims
def platform_operator_claims() -> dict[str, object]:
claims = human_actor_claims(subject="platform-operator", tenant="platform:root")
claims["roles"] = ["platform-operator"]
return claims
def break_glass_claims() -> dict[str, object]:
claims = platform_operator_claims()
claims["roles"] = ["platform-operator", "break-glass"]
claims["assurance"] = {"level": "aal3", "methods": ["pwd", "webauthn"], "mfa": True}
return claims
def local_issuer_claims() -> dict[str, object]:
claims = human_claims()
claims["iss"] = "local:fixture"
return claims
def invalid_claims() -> dict[str, object]:
claims = human_claims()
del claims["sub"]
return claims
def expired_claims() -> dict[str, object]:
claims = human_claims()
claims["expired"] = True
return claims
def missing_tenant_claims() -> dict[str, object]:
claims = human_claims()
del claims["tenant"]
return claims
def _cross_tenant_denied(request: AuthorizationRequest) -> bool:
return (
request.tenant != request.actor.tenant
and "platform-operator" not in request.actor.roles
)
def _assurance_denied(request: AuthorizationRequest) -> bool:
required = request.context.get("required_assurance")
if required is None:
return False
return str(request.actor.assurance.get("level", "")) < str(required)

View File

@@ -0,0 +1,309 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import (
AttributeDefinition,
AuthorizationEffect,
AuthorizationRequest,
Catalog,
CatalogLifecycle,
Mutability,
ProfileScope,
ProjectionType,
Sensitivity,
Visibility,
)
from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError
from user_engine.projections import ClaimsEnrichmentProjectionCache
from user_engine.service import REDACTED, UserEngineService
from user_engine.testing.fixtures import sample_application, sample_application_binding
from user_engine.testing.scenarios import (
SCENARIO_MATRIX,
ScenarioAuthorizationHarness,
StrictFixtureIdentityClaimsAdapter,
expired_claims,
human_claims,
invalid_claims,
local_issuer_claims,
missing_tenant_claims,
platform_operator_claims,
tenant_admin_claims,
)
class IntegratedScenarioTests(unittest.TestCase):
def test_scenario_matrix_names_expected_conformance_paths(self):
self.assertEqual(
set(SCENARIO_MATRIX),
{
"standalone_self_service",
"denied_access",
"tenant_admin",
"platform_operator",
"cross_tenant_denial",
"two_applications",
"sensitive_redaction",
"audit_event_replay",
},
)
def test_strict_identity_adapter_rejects_negative_identity_fixtures(self):
adapter = StrictFixtureIdentityClaimsAdapter()
for claims in (
local_issuer_claims(),
invalid_claims(),
expired_claims(),
missing_tenant_claims(),
):
with self.assertRaises(ValidationError):
adapter.normalize(claims)
def test_authorization_harness_supports_denial_obligation_assurance_and_batch(self):
service, _, authz = _service(
action_effects={"profile.write": AuthorizationEffect.ALLOW},
action_obligations={"profile.write": ("audit:retain",)},
)
session = _bootstrap(service, tenant_admin_claims())
request = AuthorizationRequest(
actor=session.actor,
resource_type="user-engine:profile",
resource_id=session.user.user_id,
action="profile.write",
tenant="tenant:coulomb",
correlation_id="corr-authz",
target_user_id=session.user.user_id,
context={"required_assurance": "aal2"},
)
decision = authz.check(request)
denied = authz.check(
AuthorizationRequest(
actor=session.actor,
resource_type="user-engine:profile",
resource_id=session.user.user_id,
action="profile.write",
tenant="tenant:faraday",
correlation_id="corr-cross-tenant",
target_user_id=session.user.user_id,
)
)
batch = authz.batch_check((request, request))
self.assertTrue(decision.allowed)
self.assertEqual(decision.obligations, ("audit:retain",))
self.assertFalse(denied.allowed)
self.assertEqual(len(batch), 2)
def test_full_flow_from_claims_to_authz_mutation_projection_audit_and_event(self):
service, _, _ = _service()
session = _bootstrap(service, tenant_admin_claims(), catalog=_sensitive_catalog())
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="team",
scope_id="team:demo",
kind="member",
correlation_id="corr-membership",
)
service.set_profile_value(
session.actor,
session.user.user_id,
"demo.recovery_hint",
"first shell",
tenant="tenant:coulomb",
correlation_id="corr-sensitive",
)
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.APPLICATION_RUNTIME,
tenant="tenant:coulomb",
application_id="app.demo",
correlation_id="corr-projection",
)
self.assertEqual(projection.values["demo.recovery_hint"], REDACTED)
self.assertIn("membership.added", [event.event_type for event in service.outbox_events()])
self.assertTrue(all(record.correlation_id for record in service.audit_records()))
def test_cache_reuse_and_invalidation_control_projection_work(self):
service, _, authz = _service()
session = _bootstrap(service, human_claims())
cache = ClaimsEnrichmentProjectionCache()
before = len(authz.requests)
cache.get(
service,
session.actor,
user_id=session.user.user_id,
tenant="tenant:coulomb",
application_id="app.demo",
correlation_id="corr-cache-1",
)
cache.get(
service,
session.actor,
user_id=session.user.user_id,
tenant="tenant:coulomb",
application_id="app.demo",
correlation_id="corr-cache-2",
)
after_cached = len(authz.requests)
cache.invalidate_user(session.user.user_id)
cache.get(
service,
session.actor,
user_id=session.user.user_id,
tenant="tenant:coulomb",
application_id="app.demo",
correlation_id="corr-cache-3",
)
self.assertEqual(after_cached, before + 1)
self.assertEqual(len(authz.requests), after_cached + 1)
def test_security_negative_paths_cover_admin_overreach_and_namespace_hijack(self):
service, _, _ = _service()
session = _bootstrap(service, tenant_admin_claims())
with self.assertRaises(AuthorizationDenied):
service.set_tenant_account_status(
session.actor,
session.user.user_id,
status=session.account.status,
tenant="tenant:faraday",
correlation_id="corr-overreach",
)
with self.assertRaises(ConflictError):
service.publish_catalog(
session.actor,
Catalog(
catalog_id="hijack-demo",
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.GLOBAL,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.APPLICATION,),
mutability=(Mutability.ADMIN,),
),
),
),
correlation_id="corr-hijack",
)
def test_platform_operator_scenario_can_manage_target_tenant(self):
service, _, _ = _service()
session = service.me(platform_operator_claims(), correlation_id="corr-platform")
managed = service.create_user(
session.actor,
display_name="Managed",
primary_email="managed@example.test",
correlation_id="corr-create",
)
account = service.set_tenant_account_status(
session.actor,
managed.user_id,
session.account.status,
tenant="tenant:coulomb",
correlation_id="corr-tenant-account",
)
self.assertEqual(account.tenant, "tenant:coulomb")
def _service(
*,
action_effects: dict[str, AuthorizationEffect] | None = None,
action_obligations: dict[str, tuple[str, ...]] | None = None,
):
store = InMemoryUserEngineStore()
authz = ScenarioAuthorizationHarness(
action_effects=action_effects,
action_obligations=action_obligations,
)
service = UserEngineService(
store=store,
identity_adapter=StrictFixtureIdentityClaimsAdapter(),
authorization=authz,
)
return service, store, authz
def _bootstrap(
service: UserEngineService,
claims: dict[str, object],
*,
catalog: Catalog | None = None,
):
session = service.me(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,
catalog or _simple_catalog(),
correlation_id="corr-catalog",
)
return session
def _simple_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 _sensitive_catalog() -> Catalog:
catalog = _simple_catalog()
return Catalog(
catalog_id=catalog.catalog_id,
namespace=catalog.namespace,
version=catalog.version,
owning_application_id=catalog.owning_application_id,
lifecycle=catalog.lifecycle,
attributes=(
*catalog.attributes,
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),
),
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Integrated Test Scenarios" title: "User Engine Integrated Test Scenarios"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: active status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high
@@ -29,7 +29,7 @@ test scenarios.
```task ```task
id: USER-WP-0005-T1 id: USER-WP-0005-T1
status: todo status: done
priority: high priority: high
state_hub_task_id: "f0408602-4ec9-4d01-9a62-2daa3fa7373e" state_hub_task_id: "f0408602-4ec9-4d01-9a62-2daa3fa7373e"
``` ```
@@ -40,7 +40,7 @@ redaction, and audit/event replay.
```task ```task
id: USER-WP-0005-T2 id: USER-WP-0005-T2
status: todo status: done
priority: high priority: high
state_hub_task_id: "78dad786-f69d-4e84-884b-0e2a32338c3e" state_hub_task_id: "78dad786-f69d-4e84-884b-0e2a32338c3e"
``` ```
@@ -51,7 +51,7 @@ missing-tenant actors.
```task ```task
id: USER-WP-0005-T3 id: USER-WP-0005-T3
status: todo status: done
priority: high priority: high
state_hub_task_id: "87cac8eb-2182-4b17-aa29-60109cf6f2c4" state_hub_task_id: "87cac8eb-2182-4b17-aa29-60109cf6f2c4"
``` ```
@@ -61,7 +61,7 @@ assurance, and bulk decision scenarios.
```task ```task
id: USER-WP-0005-T4 id: USER-WP-0005-T4
status: todo status: done
priority: high priority: high
state_hub_task_id: "5fc6e120-0c94-4fb0-bc7f-2d8713a40011" state_hub_task_id: "5fc6e120-0c94-4fb0-bc7f-2d8713a40011"
``` ```
@@ -71,7 +71,7 @@ resolution, projection, audit write, and outbox event creation.
```task ```task
id: USER-WP-0005-T5 id: USER-WP-0005-T5
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "609a3579-268c-4ed9-b5b7-2e01dc8e7049" state_hub_task_id: "609a3579-268c-4ed9-b5b7-2e01dc8e7049"
``` ```
@@ -81,7 +81,7 @@ rendering, authorization batching, memoization, and cache invalidation.
```task ```task
id: USER-WP-0005-T6 id: USER-WP-0005-T6
status: todo status: done
priority: high priority: high
state_hub_task_id: "c346a142-3e7a-48ee-bf71-553cdcf4861d" state_hub_task_id: "c346a142-3e7a-48ee-bf71-553cdcf4861d"
``` ```
@@ -92,7 +92,7 @@ hijack, stale membership facts, and missing audit correlation.
```task ```task
id: USER-WP-0005-T7 id: USER-WP-0005-T7
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ac92965e-778d-48ec-a674-32b1c333bb0d" state_hub_task_id: "ac92965e-778d-48ec-a674-32b1c333bb0d"
``` ```