generated from coulomb/repo-seed
Add integrated user-engine scenarios
This commit is contained in:
12
Makefile
12
Makefile
@@ -1,6 +1,14 @@
|
||||
.PHONY: test
|
||||
.PHONY: test test-unit test-scenarios test-integration test-conformance
|
||||
|
||||
PYTHON ?= python3
|
||||
|
||||
test:
|
||||
test: test-unit
|
||||
|
||||
test-unit:
|
||||
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -p 'test_*.py'
|
||||
|
||||
test-scenarios: test-unit
|
||||
|
||||
test-integration: test-unit
|
||||
|
||||
test-conformance: test-unit
|
||||
|
||||
@@ -6,5 +6,6 @@ Headless multi-application, multi-tenant user management engine.
|
||||
make test
|
||||
```
|
||||
|
||||
See `docs/development.md`, `docs/configuration.md`, and `docs/examples.md`
|
||||
for implementation boundaries and local usage examples.
|
||||
See `docs/development.md`, `docs/configuration.md`, `docs/examples.md`, and
|
||||
`docs/scenarios.md` for implementation boundaries, local usage examples, and
|
||||
scenario coverage.
|
||||
|
||||
37
docs/scenarios.md
Normal file
37
docs/scenarios.md
Normal 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.
|
||||
158
src/user_engine/testing/scenarios.py
Normal file
158
src/user_engine/testing/scenarios.py
Normal 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)
|
||||
309
tests/test_integrated_scenarios.py
Normal file
309
tests/test_integrated_scenarios.py
Normal 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()
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "User Engine Integrated Test Scenarios"
|
||||
domain: netkingdom
|
||||
repo: user-engine
|
||||
status: active
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
@@ -29,7 +29,7 @@ test scenarios.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f0408602-4ec9-4d01-9a62-2daa3fa7373e"
|
||||
```
|
||||
@@ -40,7 +40,7 @@ redaction, and audit/event replay.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T2
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "78dad786-f69d-4e84-884b-0e2a32338c3e"
|
||||
```
|
||||
@@ -51,7 +51,7 @@ missing-tenant actors.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "87cac8eb-2182-4b17-aa29-60109cf6f2c4"
|
||||
```
|
||||
@@ -61,7 +61,7 @@ assurance, and bulk decision scenarios.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5fc6e120-0c94-4fb0-bc7f-2d8713a40011"
|
||||
```
|
||||
@@ -71,7 +71,7 @@ resolution, projection, audit write, and outbox event creation.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T5
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "609a3579-268c-4ed9-b5b7-2e01dc8e7049"
|
||||
```
|
||||
@@ -81,7 +81,7 @@ rendering, authorization batching, memoization, and cache invalidation.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c346a142-3e7a-48ee-bf71-553cdcf4861d"
|
||||
```
|
||||
@@ -92,7 +92,7 @@ hijack, stale membership facts, and missing audit correlation.
|
||||
|
||||
```task
|
||||
id: USER-WP-0005-T7
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "ac92965e-778d-48ec-a674-32b1c333bb0d"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user