From 2ceecf6463ad411baea4b7509c475869b182036c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 23:59:45 +0200 Subject: [PATCH] test: add registration security conformance --- README.md | 1 + SCOPE.md | 4 +- docs/contracts.md | 13 + ...tkingdom-registration-onboarding-vision.md | 4 +- ...ation-scenario-and-security-conformance.md | 108 ++++ docs/scenarios.md | 21 + src/user_engine/testing/scenarios.py | 60 ++ tests/test_integrated_scenarios.py | 23 + .../test_registration_security_conformance.py | 576 ++++++++++++++++++ ...ation-scenario-and-security-conformance.md | 47 +- 10 files changed, 846 insertions(+), 11 deletions(-) create mode 100644 docs/registration-scenario-and-security-conformance.md create mode 100644 tests/test_registration_security_conformance.py diff --git a/README.md b/README.md index 7087efd..06b1285 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, `docs/hats-realms-services-assets-access-profiles.md`, `docs/onboarding-journeys-and-welcome-protocols.md`, `docs/registration-and-access-management-ui.md`, `docs/scenarios.md`, +`docs/registration-scenario-and-security-conformance.md`, `docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`, `docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md` for implementation boundaries, contracts, canon mappings, examples, and release diff --git a/SCOPE.md b/SCOPE.md index 234a56c..9a65dc0 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -64,5 +64,5 @@ accounts and entitlement claims. `USER-WP-0012` implements hats, realms, services, assets, access profiles, active context, and exportable access-control facts. `USER-WP-0013` implements onboarding journeys and welcome protocols. `USER-WP-0014` implements the optional registration and -access-management UI contract facade. `USER-WP-0015` remains proposed future -work for security conformance. +access-management UI contract facade. `USER-WP-0015` implements registration +scenario and security conformance tests. diff --git a/docs/contracts.md b/docs/contracts.md index 5f229d4..84ac5bc 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -43,6 +43,19 @@ accessible HTML verification. It does not handle credential entry, MFA challenges, token issuance, hidden policy decisions, notifications, or service-specific admin consoles. +## Scenario And Security Conformance Contract + +`user_engine.testing.scenarios` defines `SCENARIO_MATRIX` and +`REGISTRATION_SCENARIO_MATRIX` for local conformance. The matrix covers +self-registration, prepared-account claims, privileged approval gates, +eID-backed assurance, family invite, tenant admin invite, group access, +cross-tenant denial, and USER-WP-0014 UI workflows. + +Conformance tests must run without production IAM, proofing, notification, +workflow, authorization-engine, or database infrastructure. They exercise +adapter seams with local harnesses and assert fail-closed behavior, audit +evidence, outbox replay, redaction, and durable transaction semantics. + ## Registration Contract Registration is a headless user-entry facade. It creates a diff --git a/docs/netkingdom-registration-onboarding-vision.md b/docs/netkingdom-registration-onboarding-vision.md index 6e8761c..7353631 100644 --- a/docs/netkingdom-registration-onboarding-vision.md +++ b/docs/netkingdom-registration-onboarding-vision.md @@ -236,8 +236,8 @@ once. ## Recommended Workplans As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`, -`USER-WP-0013`, and `USER-WP-0014` are implemented as user-engine slices. The -later security-conformance workplan remains recommended follow-on work. +`USER-WP-0013`, `USER-WP-0014`, and `USER-WP-0015` are implemented as +user-engine slices. | Workplan | Title | Purpose | | --- | --- | --- | diff --git a/docs/registration-scenario-and-security-conformance.md b/docs/registration-scenario-and-security-conformance.md new file mode 100644 index 0000000..48c811f --- /dev/null +++ b/docs/registration-scenario-and-security-conformance.md @@ -0,0 +1,108 @@ +# Registration Scenario And Security Conformance + +Status: implemented conformance slice +Date: 2026-06-15 +Related workplan: USER-WP-0015 + +## Purpose + +This slice turns the NetKingdom registration and onboarding roadmap into an +executable local conformance contract. It proves that the headless APIs and the +optional UI contract can complete the main registration journey, fail closed on +security negative paths, redact sensitive values, and exercise adapter seams +without production infrastructure. + +## Scenario Matrix + +The registration matrix is defined in `user_engine.testing.scenarios` as +`REGISTRATION_SCENARIO_MATRIX`. + +It covers: + +- self-registration; +- prepared account claim; +- privileged role requiring approval; +- eID-backed assurance; +- family invite; +- tenant admin invite; +- group access; +- denied cross-tenant claim. + +The broader `SCENARIO_MATRIX` now also names registration/onboarding, +prepared-claim, group-access hat, denied cross-tenant claim, and UI workflow +coverage. + +## End-To-End Conformance + +`tests/test_registration_security_conformance.py` includes a full local flow: + +```text +register application/catalog +prepare account with onboarding hint +complete email-backed registration +claim prepared account +select active hat +read claims projection and identity context +export access-control facts +render admin UI +assert onboarding event emission and redaction +``` + +This proves the path from registration through identity context, claims +enrichment, active access context, access fact export, onboarding, and UI +diagnostics. + +## Security Negative Paths + +The conformance suite exercises fail-closed behavior for: + +- weak factor requirements; +- duplicate identity links; +- prepared-account hijack attempts; +- expired prepared claims; +- missing or cross-tenant context; +- privileged prepared roles requiring approval; +- stale approval through approval-required access profiles. + +Denied prepared-account claim decisions leave audit evidence without creating +memberships for the attacker or emitting successful activation events. + +## Redaction And Diagnostics + +Conformance checks assert that these values do not leak through diagnostics, +events, or UI output: + +- normalized factor values; +- email addresses used for prepared account matching; +- sensitive profile values; +- access profile claim/default values; +- proofing adapter secret input. + +Sensitive profile values are redacted in runtime projections as +``. + +## Adapter Conformance + +The local adapter conformance path covers: + +- factor verification adapter normalization; +- authorization harness request capture and obligations; +- access-control fact export; +- onboarding handoff lifecycle gaps and resume; +- audit record availability; +- outbox replay through pending events; +- in-memory durable-store rollback behavior in existing port tests. + +No provider-specific IAM, eID, SMS, email, authorization-engine, notification, +or workflow infrastructure is required. + +## Commands + +```bash +make test +make test-conformance +``` + +The Makefile targets currently run the same standard-library test suite. They +remain separate entry points so CI can split unit, integration, scenario, and +conformance execution later. diff --git a/docs/scenarios.md b/docs/scenarios.md index 40e8840..4bda8cd 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -16,6 +16,27 @@ projection, audit, and event behavior testable without a UI. | audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. | | identity_canon_context | Actor, user, account, authenticated subject, authorization principal, tenant, membership, grant-like facts, and evidence references stay distinguishable. | | family_dataspace_onboarding | A family tenant can register a personal dataspace, invite members, accept SSO identities, project claims context, and deny cross-family access. | +| registration_onboarding_full | Registration, prepared claim, active hat, claims projection, onboarding, access fact export, and UI diagnostics work as one local flow. | +| prepared_account_claim | Prepared rights can be claimed only after matching verified factors. | +| privileged_role_requires_approval | Privileged prepared roles fail closed without approval. | +| eid_assurance_registration | eID-backed factor evidence can participate in registration conformance. | +| tenant_admin_invite | Tenant admins can prepare users and inspect diagnostics without issuing credentials. | +| group_access_hat | Group-derived memberships can produce active hat and access-control facts. | +| denied_cross_tenant_claim | Cross-tenant prepared claims and tenant overreach fail closed. | +| ui_registration_access_flow | USER-WP-0014 UI contracts cover registration, prepared rights, hats, admin diagnostics, redaction, and responsive metadata. | + +## Registration Scenario Matrix + +`REGISTRATION_SCENARIO_MATRIX` covers: + +- self-registration; +- prepared account claim; +- privileged role requiring approval; +- eID-backed assurance; +- family invite; +- tenant admin invite; +- group access; +- denied cross-tenant claim. ## Fixture Actors diff --git a/src/user_engine/testing/scenarios.py b/src/user_engine/testing/scenarios.py index 13f3f1e..8f9234d 100644 --- a/src/user_engine/testing/scenarios.py +++ b/src/user_engine/testing/scenarios.py @@ -26,7 +26,67 @@ SCENARIO_MATRIX = ( "two_applications", "sensitive_redaction", "audit_event_replay", + "identity_canon_context", "family_dataspace_onboarding", + "registration_onboarding_full", + "prepared_account_claim", + "privileged_role_requires_approval", + "eid_assurance_registration", + "tenant_admin_invite", + "group_access_hat", + "denied_cross_tenant_claim", + "ui_registration_access_flow", +) + +REGISTRATION_SCENARIO_MATRIX = ( + { + "id": "self_registration", + "actor": "human", + "factors": ("email",), + "expects": ("registration.completed", "identity_context", "netkingdom_id"), + }, + { + "id": "prepared_account_claim", + "actor": "human", + "factors": ("email",), + "expects": ("prepared_account.claimed", "membership", "onboarding_journey"), + }, + { + "id": "privileged_role_requires_approval", + "actor": "human", + "factors": ("email",), + "expects": ("authorization_denied", "no_membership_mutation"), + }, + { + "id": "eid_assurance_registration", + "actor": "human", + "factors": ("eid",), + "expects": ("registration.completed", "high_assurance_factor"), + }, + { + "id": "family_invite", + "actor": "family-owner", + "factors": ("sso",), + "expects": ("family_invitation.accepted", "claims_projection"), + }, + { + "id": "tenant_admin_invite", + "actor": "tenant-admin", + "factors": ("email",), + "expects": ("prepared_account.created", "tenant_diagnostics"), + }, + { + "id": "group_access", + "actor": "human", + "factors": ("email",), + "expects": ("active_access_context", "access_control_fact"), + }, + { + "id": "denied_cross_tenant_claim", + "actor": "human", + "factors": ("email",), + "expects": ("authorization_denied", "audit_record", "no_outbox_event"), + }, ) diff --git a/tests/test_integrated_scenarios.py b/tests/test_integrated_scenarios.py index e655a72..114ab06 100644 --- a/tests/test_integrated_scenarios.py +++ b/tests/test_integrated_scenarios.py @@ -18,6 +18,7 @@ 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 ( + REGISTRATION_SCENARIO_MATRIX, SCENARIO_MATRIX, ScenarioAuthorizationHarness, StrictFixtureIdentityClaimsAdapter, @@ -44,7 +45,29 @@ class IntegratedScenarioTests(unittest.TestCase): "two_applications", "sensitive_redaction", "audit_event_replay", + "identity_canon_context", "family_dataspace_onboarding", + "registration_onboarding_full", + "prepared_account_claim", + "privileged_role_requires_approval", + "eid_assurance_registration", + "tenant_admin_invite", + "group_access_hat", + "denied_cross_tenant_claim", + "ui_registration_access_flow", + }, + ) + self.assertEqual( + {scenario["id"] for scenario in REGISTRATION_SCENARIO_MATRIX}, + { + "self_registration", + "prepared_account_claim", + "privileged_role_requires_approval", + "eid_assurance_registration", + "family_invite", + "tenant_admin_invite", + "group_access", + "denied_cross_tenant_claim", }, ) diff --git a/tests/test_registration_security_conformance.py b/tests/test_registration_security_conformance.py new file mode 100644 index 0000000..18bf51a --- /dev/null +++ b/tests/test_registration_security_conformance.py @@ -0,0 +1,576 @@ +from dataclasses import replace +from datetime import timedelta +import unittest + +from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort +from user_engine.domain import ( + AccessMembershipRequirement, + AccessProfile, + AccessScopeType, + AccountStatus, + AttributeDefinition, + Catalog, + CatalogLifecycle, + FactorVerification, + IdentityFactorType, + Mutability, + OnboardingJourneyStatus, + OnboardingTriggerType, + PreparedEntitlement, + PreparedEntitlementKind, + PreparedFactorRequirement, + ProfileScope, + ProjectionType, + Sensitivity, + Visibility, + WelcomeProtocol, + WelcomeProtocolStep, + utc_now, +) +from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError +from user_engine.service import REDACTED, UserEngineService +from user_engine.testing.fixtures import ( + FixtureIdentityClaimsAdapter, + human_actor_claims, + sample_application, + sample_application_binding, +) +from user_engine.testing.scenarios import ( + ScenarioAuthorizationHarness, + StrictFixtureIdentityClaimsAdapter, + missing_tenant_claims, +) +from user_engine.ui import RegistrationAccessManagementUi, UiViewport + + +class RegistrationSecurityConformanceTests(unittest.TestCase): + def test_full_registration_claim_hat_onboarding_ui_conformance_path(self): + service, store, _ = _service() + actor = _actor("conformance-user") + _bootstrap_application(service, actor) + service.register_welcome_protocol( + actor, + _prepared_welcome_protocol(), + correlation_id="corr-conf-protocol", + ) + prepared = service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement(),), + entitlements=( + PreparedEntitlement( + kind=PreparedEntitlementKind.MEMBERSHIP, + tenant="tenant:coulomb", + scope_type="realm", + scope_id="realm:citadel", + role="operator", + ), + PreparedEntitlement( + kind=PreparedEntitlementKind.ONBOARDING_JOURNEY, + tenant="tenant:coulomb", + onboarding_journey="welcome-demo", + ), + ), + correlation_id="corr-conf-prepare", + ) + registration = _complete_registration(service, actor) + claim = service.claim_prepared_account( + actor, + registration.session.registration_id, + prepared_account_id=prepared.prepared_account_id, + correlation_id="corr-conf-claim", + ) + profile = service.register_access_profile( + actor, + _operator_access_profile(), + correlation_id="corr-conf-profile", + ) + selection = service.select_active_hat( + actor, + registration.user.user_id, + profile.access_profile_id, + correlation_id="corr-conf-hat", + ) + projection = service.projection( + actor, + registration.user.user_id, + ProjectionType.CLAIMS_ENRICHMENT, + application_id="app.demo", + tenant="tenant:coulomb", + correlation_id="corr-conf-projection", + ) + context = service.identity_context( + actor, + user_id=registration.user.user_id, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-conf-context", + ) + export = service.export_access_control_facts( + actor, + tenant="tenant:coulomb", + user_id=registration.user.user_id, + correlation_id="corr-conf-export", + ) + ui_html = RegistrationAccessManagementUi(service).render_html( + RegistrationAccessManagementUi(service).admin_dashboard( + actor, + tenant="tenant:coulomb", + viewport=UiViewport.DESKTOP, + ) + ) + + self.assertEqual(claim.memberships[0].kind, "operator") + self.assertEqual(selection.active_context.hat, "operator") + self.assertEqual(projection.access_context["active_hat"], "operator") + self.assertTrue(context.onboarding_journeys) + self.assertEqual( + context.onboarding_journeys[0].status, + OnboardingJourneyStatus.IN_PROGRESS, + ) + self.assertIn("user", export.manifest["subject_types"]) + self.assertIn( + "onboarding_journey.started", + [event.event_type for event in service.outbox_events()], + ) + self.assertNotIn("sample.user@example.test", ui_html) + self.assertEqual(store.record_counts()["onboarding_journeys"], 1) + + def test_security_negative_paths_fail_closed_with_audit_evidence(self): + service, store, _ = _service() + actor = _actor("security-user") + _bootstrap_application(service, actor) + registration = _complete_registration(service, actor) + + other_user = service.create_user( + actor, + display_name="Other", + primary_email="other@example.test", + correlation_id="corr-other", + ) + with self.assertRaises(ConflictError): + service.link_identity( + actor, + other_user.user_id, + issuer=actor.issuer, + subject=actor.subject, + correlation_id="corr-duplicate-identity", + ) + + with self.assertRaises(ValidationError): + service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=( + PreparedFactorRequirement( + factor_type=IdentityFactorType.EMAIL, + normalized_value=" ", + ), + ), + entitlements=(_membership_entitlement(),), + correlation_id="corr-weak-factor", + ) + + hijack = service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=( + PreparedFactorRequirement( + factor_type=IdentityFactorType.EMAIL, + normalized_value="victim@example.test", + ), + ), + entitlements=(_membership_entitlement(),), + correlation_id="corr-hijack-prepare", + ) + with self.assertRaises(ValidationError): + service.claim_prepared_account( + actor, + registration.session.registration_id, + prepared_account_id=hijack.prepared_account_id, + correlation_id="corr-hijack-claim", + ) + + expired = service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement("expired@example.test"),), + entitlements=(_membership_entitlement(scope_id="realm:expired"),), + expires_at=utc_now() - timedelta(days=1), + correlation_id="corr-expired-prepare", + ) + with self.assertRaises(ValidationError): + service.claim_prepared_account( + actor, + registration.session.registration_id, + prepared_account_id=expired.prepared_account_id, + correlation_id="corr-expired-claim", + ) + + with self.assertRaises(AuthorizationDenied): + service.resolve_tenant_context(actor, "tenant:faraday") + + privileged = service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement(),), + entitlements=( + replace( + _membership_entitlement(scope_id="realm:privileged"), + role="admin", + requires_approval=True, + ), + ), + correlation_id="corr-privileged-prepare", + ) + with self.assertRaises(AuthorizationDenied): + service.claim_prepared_account( + actor, + registration.session.registration_id, + prepared_account_id=privileged.prepared_account_id, + correlation_id="corr-privileged-claim", + ) + + access_profile = service.register_access_profile( + actor, + replace(_operator_access_profile(), requires_approval=True), + correlation_id="corr-stale-approval-profile", + ) + with self.assertRaises(AuthorizationDenied): + service.select_active_hat( + actor, + registration.user.user_id, + access_profile.access_profile_id, + correlation_id="corr-stale-approval", + ) + + audit_summaries = [record.summary for record in service.audit_records()] + self.assertIn( + "prepared account claim denied: factor mismatch or closed", + audit_summaries, + ) + self.assertIn( + "prepared account claim denied: approval required", + audit_summaries, + ) + self.assertEqual(store.memberships_for_user(other_user.user_id), ()) + + def test_redaction_and_diagnostics_conformance(self): + service, _, _ = _service() + actor = _actor("redaction-user") + _bootstrap_application(service, actor, catalog=_sensitive_catalog()) + registration = _complete_registration(service, actor) + service.set_profile_value( + actor, + registration.user.user_id, + "demo.recovery_hint", + "blue envelope", + tenant="tenant:coulomb", + correlation_id="corr-sensitive-profile", + ) + service.prepare_account( + actor, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement(),), + entitlements=(_membership_entitlement(),), + primary_email="sample.user@example.test", + correlation_id="corr-redaction-prepare", + ) + service.register_access_profile( + actor, + replace( + _operator_access_profile(), + claims={"policy_secret": "do-not-render"}, + profile_defaults={"landing_hint": "do-not-render"}, + ), + correlation_id="corr-redaction-profile", + ) + + projection = service.projection( + actor, + registration.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + application_id="app.demo", + tenant="tenant:coulomb", + correlation_id="corr-sensitive-projection", + ) + access_diagnostics = service.access_profile_diagnostics( + actor, + tenant="tenant:coulomb", + correlation_id="corr-access-diagnostics", + ) + admin_html = RegistrationAccessManagementUi(service).render_html( + RegistrationAccessManagementUi(service).admin_dashboard( + actor, + tenant="tenant:coulomb", + viewport=UiViewport.DESKTOP, + ) + ) + event_payloads = repr([event.payload for event in service.outbox_events()]) + + self.assertEqual(projection.values["demo.recovery_hint"], REDACTED) + self.assertNotIn("blue envelope", repr(access_diagnostics)) + self.assertNotIn("do-not-render", repr(access_diagnostics)) + self.assertNotIn("sample.user@example.test", admin_html) + self.assertNotIn("sample.user@example.test", event_payloads) + self.assertNotIn("blue envelope", event_payloads) + + def test_adapter_conformance_harnesses_without_production_infrastructure(self): + store = InMemoryUserEngineStore() + authz = ScenarioAuthorizationHarness( + action_obligations={"access_control_facts.export": ("acl:sync",)} + ) + service = UserEngineService( + store=store, + identity_adapter=StrictFixtureIdentityClaimsAdapter(), + authorization=authz, + factor_verifier=_FixtureFactorVerifier(), + ) + actor = service.identity_adapter.normalize(human_actor_claims()) + _bootstrap_application(service, actor) + registration = service.start_registration(actor, correlation_id="corr-adapter-start") + service.attach_registration_factor( + actor, + registration.registration_id, + {"type": "eid", "value": "EID-123", "secret": "strip-me"}, + correlation_id="corr-adapter-factor", + ) + service.attach_registration_factor( + actor, + registration.registration_id, + {"type": "email", "value": "sample.user@example.test"}, + correlation_id="corr-adapter-email", + ) + completed = service.complete_registration( + actor, + registration.registration_id, + correlation_id="corr-adapter-complete", + ) + service.add_membership( + actor, + completed.user.user_id, + tenant="tenant:coulomb", + scope_type="group", + scope_id="group:research", + kind="member", + correlation_id="corr-adapter-group", + ) + export = service.export_access_control_facts( + actor, + tenant="tenant:coulomb", + user_id=completed.user.user_id, + correlation_id="corr-adapter-export", + ) + protocol = service.register_welcome_protocol( + actor, + WelcomeProtocol( + tenant="tenant:coulomb", + name="Adapter Handoff", + trigger_type=OnboardingTriggerType.MANUAL, + steps=( + WelcomeProtocolStep( + step_key="callback", + title="Callback", + subsystem="crm", + requires_subsystem_callback=True, + ), + ), + ), + correlation_id="corr-adapter-protocol", + ) + blocked = service.start_onboarding_journey( + actor, + completed.user.user_id, + protocol.protocol_id, + correlation_id="corr-adapter-onboarding", + ).journey + resumed = service.resume_onboarding_journey( + actor, + blocked.journey_id, + callback_refs={"callback": "crm://welcome/callback"}, + correlation_id="corr-adapter-resume", + ) + + self.assertIn("group", export.manifest["subject_types"]) + self.assertTrue(any(request.action == "access_control_facts.export" for request in authz.requests)) + self.assertNotIn("strip-me", repr(store.factors_for_user(completed.user.user_id))) + self.assertEqual(blocked.status, OnboardingJourneyStatus.BLOCKED) + self.assertEqual(resumed.status, OnboardingJourneyStatus.IN_PROGRESS) + self.assertTrue(service.audit_records()) + self.assertTrue(service.outbox_events()) + self.assertEqual(service.operability_snapshot().issues, ()) + + with self.assertRaises(ValidationError): + service.identity_adapter.normalize(missing_tenant_claims()) + + +class _FixtureFactorVerifier: + def normalize(self, proofing_result): + factor_type = IdentityFactorType(str(proofing_result["type"])) + return FactorVerification( + factor_type=factor_type, + normalized_value=str(proofing_result["value"]).casefold(), + display_value=None, + source_system="fixture-proofing", + assurance={"level": "ial2" if factor_type == IdentityFactorType.EID else "ial1"}, + ) + + +def _service(): + store = InMemoryUserEngineStore() + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort(), + ) + return service, store, service.authorization + + +def _actor(subject: str): + claims = human_actor_claims(subject=subject, tenant="tenant:coulomb") + claims["roles"] = ["tenant-admin"] + return FixtureIdentityClaimsAdapter().normalize(claims) + + +def _bootstrap_application( + service: UserEngineService, + actor, + *, + catalog: Catalog | None = None, +) -> None: + service.register_application( + actor, + sample_application(), + binding=sample_application_binding(), + correlation_id="corr-bootstrap-app", + ) + service.publish_catalog( + actor, + catalog or _simple_catalog(), + correlation_id="corr-bootstrap-catalog", + ) + + +def _complete_registration(service: UserEngineService, actor): + session = service.start_registration(actor, correlation_id="corr-reg-start") + service.attach_registration_factor( + actor, + session.registration_id, + FactorVerification( + factor_type=IdentityFactorType.EMAIL, + normalized_value="sample.user@example.test", + display_value="sample.user@example.test", + source_system="fixture-email", + ), + correlation_id="corr-reg-factor", + ) + return service.complete_registration( + actor, + session.registration_id, + correlation_id="corr-reg-complete", + ) + + +def _email_requirement(email: str = "sample.user@example.test"): + return PreparedFactorRequirement( + factor_type=IdentityFactorType.EMAIL, + normalized_value=email, + ) + + +def _membership_entitlement(scope_id: str = "realm:citadel"): + return PreparedEntitlement( + kind=PreparedEntitlementKind.MEMBERSHIP, + tenant="tenant:coulomb", + scope_type="realm", + scope_id=scope_id, + role="operator", + ) + + +def _operator_access_profile() -> AccessProfile: + return AccessProfile( + tenant="tenant:coulomb", + display_name="Operator", + hat="operator", + scope_type=AccessScopeType.REALM, + scope_id="realm:citadel", + realm_id="realm:citadel", + service_id="app.demo", + membership_requirements=( + AccessMembershipRequirement( + scope_type="realm", + scope_id="realm:citadel", + kind="operator", + ), + ), + required_factor_types=(IdentityFactorType.EMAIL,), + claims={"service_role": "operator"}, + ) + + +def _prepared_welcome_protocol() -> WelcomeProtocol: + return WelcomeProtocol( + tenant="tenant:coulomb", + name="Prepared Welcome", + trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT, + journey_key="welcome-demo", + prepared_journey="welcome-demo", + steps=( + WelcomeProtocolStep( + step_key="intro", + title="Intro", + subsystem="portal", + callback_ref="portal://welcome", + requires_subsystem_callback=True, + ), + ), + ) + + +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-0015-registration-scenario-and-security-conformance.md b/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md index ba99b76..f7ef30c 100644 --- a/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md +++ b/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md @@ -4,7 +4,7 @@ type: workplan title: "Registration Scenario And Security Conformance" domain: netkingdom repo: user-engine -status: proposed +status: finished owner: codex topic_slug: netkingdom planning_priority: medium @@ -44,7 +44,7 @@ should cover both headless APIs and the optional UI surface where present. ```task id: USER-WP-0015-T1 -status: todo +status: done priority: high state_hub_task_id: "5ca0a269-559d-4138-b702-9984a411f2ed" ``` @@ -55,7 +55,7 @@ tenant admin invite, group access, and denied cross-tenant claim. ```task id: USER-WP-0015-T2 -status: todo +status: done priority: high state_hub_task_id: "6ee492b1-923f-4aa0-8e17-b69f522c4898" ``` @@ -65,7 +65,7 @@ claims enrichment, active hat selection, and onboarding event emission. ```task id: USER-WP-0015-T3 -status: todo +status: done priority: high state_hub_task_id: "b813a88f-ced6-40ce-9a25-d1c666fb73c9" ``` @@ -76,7 +76,7 @@ privileged role escalation, and stale approvals. ```task id: USER-WP-0015-T4 -status: todo +status: done priority: medium state_hub_task_id: "5a03ac1a-1f8e-455b-8f75-691e8bdda286" ``` @@ -86,7 +86,7 @@ prepared-account metadata, active hat context, and access-profile evidence. ```task id: USER-WP-0015-T5 -status: todo +status: done priority: medium state_hub_task_id: "fcf32b4d-d050-4989-bb05-844e0d13e548" ``` @@ -97,7 +97,7 @@ durable store behavior. ```task id: USER-WP-0015-T6 -status: todo +status: done priority: medium state_hub_task_id: "a7850784-3b86-453f-bbc7-1d53d0813f82" ``` @@ -119,3 +119,36 @@ prepared rights review, hat selection, admin preparation, and blocked journey. - Headless and UI conformance tests. - Security negative-path test suite. - Adapter conformance harness for registration dependencies. + +## Implementation Notes + +Implemented on 2026-06-15: + +- Extended `SCENARIO_MATRIX` and added `REGISTRATION_SCENARIO_MATRIX` covering + self-registration, prepared account claim, privileged role approval gates, + eID-backed assurance, family invite, tenant admin invite, group access, and + denied cross-tenant claim. +- Added `tests/test_registration_security_conformance.py` for a full local + registration -> prepared claim -> active hat -> claims projection -> + identity context -> access fact export -> onboarding -> UI diagnostics path. +- Added security negative-path tests for weak factor requirements, duplicate + identity links, prepared-account hijack attempts, expired claims, + cross-tenant/missing tenant context, privileged prepared-role approval, and + stale approval through approval-required access profiles. +- Added redaction and diagnostics checks for factor values, prepared-account + email metadata, sensitive profile values, access-profile claims/defaults, + and proofing adapter secrets. +- Added adapter conformance coverage for factor verification normalization, + authorization harness capture, access fact export, onboarding handoff/resume, + audit availability, outbox replay, and local durable-store behavior. +- Extended UI workflow coverage from USER-WP-0014 through the conformance + path and documented the local conformance contract in + `docs/registration-scenario-and-security-conformance.md`. + +Verification: + +```text +make test +Ran 75 tests in 1.506s +OK +```