From 9b241ab2e3c6e7f31a03997a93d4aa5e50eac4ca Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 21:39:10 +0200 Subject: [PATCH] Add integrated user-engine scenarios --- Makefile | 12 +- README.md | 5 +- docs/scenarios.md | 37 +++ src/user_engine/testing/scenarios.py | 158 +++++++++ tests/test_integrated_scenarios.py | 309 ++++++++++++++++++ .../USER-WP-0005-integrated-test-scenarios.md | 16 +- 6 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 docs/scenarios.md create mode 100644 src/user_engine/testing/scenarios.py create mode 100644 tests/test_integrated_scenarios.py diff --git a/Makefile b/Makefile index df4c48e..d862c20 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 77a5b2f..b9507a1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/scenarios.md b/docs/scenarios.md new file mode 100644 index 0000000..15ee73c --- /dev/null +++ b/docs/scenarios.md @@ -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. diff --git a/src/user_engine/testing/scenarios.py b/src/user_engine/testing/scenarios.py new file mode 100644 index 0000000..0a32c47 --- /dev/null +++ b/src/user_engine/testing/scenarios.py @@ -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) diff --git a/tests/test_integrated_scenarios.py b/tests/test_integrated_scenarios.py new file mode 100644 index 0000000..8c38f44 --- /dev/null +++ b/tests/test_integrated_scenarios.py @@ -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() diff --git a/workplans/USER-WP-0005-integrated-test-scenarios.md b/workplans/USER-WP-0005-integrated-test-scenarios.md index c64516c..26ba08c 100644 --- a/workplans/USER-WP-0005-integrated-test-scenarios.md +++ b/workplans/USER-WP-0005-integrated-test-scenarios.md @@ -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" ```