generated from coulomb/repo-seed
test: add registration security conformance
This commit is contained in:
@@ -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
|
||||
|
||||
4
SCOPE.md
4
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- | --- |
|
||||
|
||||
108
docs/registration-scenario-and-security-conformance.md
Normal file
108
docs/registration-scenario-and-security-conformance.md
Normal file
@@ -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
|
||||
`<redacted>`.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
576
tests/test_registration_security_conformance.py
Normal file
576
tests/test_registration_security_conformance.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user