From 97cd03b551aacad6de827b96357a5b6c83f21e3a Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 22:37:31 +0200 Subject: [PATCH] feat: implement prepared account claims --- README.md | 3 +- SCOPE.md | 7 +- docs/contracts.md | 22 + ...tkingdom-registration-onboarding-vision.md | 4 + ...repared-accounts-and-entitlement-claims.md | 117 ++++ .../registration-identity-and-factor-model.md | 5 +- src/user_engine/adapters/local.py | 20 + src/user_engine/domain/__init__.py | 10 + src/user_engine/domain/models.py | 63 ++ src/user_engine/ports.py | 12 + src/user_engine/service.py | 655 ++++++++++++++++++ tests/test_ports_and_fixtures.py | 1 + tests/test_prepared_accounts.py | 420 +++++++++++ ...repared-accounts-and-entitlement-claims.md | 50 +- 14 files changed, 1376 insertions(+), 13 deletions(-) create mode 100644 docs/prepared-accounts-and-entitlement-claims.md create mode 100644 tests/test_prepared_accounts.py diff --git a/README.md b/README.md index cadbd97..1c0b8f7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, `docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`, `docs/netkingdom-registration-onboarding-vision.md`, `docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.md`, -`docs/registration-identity-and-factor-model.md`, `docs/scenarios.md`, +`docs/registration-identity-and-factor-model.md`, +`docs/prepared-accounts-and-entitlement-claims.md`, `docs/scenarios.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 39703bc..a0c9efe 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -59,6 +59,7 @@ truth. Implementation and planning work is tracked in `workplans/USER-WP-0001` through `USER-WP-0015`. `USER-WP-0010` implements the first headless -registration and factor-evidence slice; `USER-WP-0011` through `USER-WP-0015` -remain proposed future workplans for prepared accounts, hats/access profiles, -onboarding journeys, optional UI, and security conformance. +registration and factor-evidence slice. `USER-WP-0011` implements prepared +accounts and entitlement claims. `USER-WP-0012` through `USER-WP-0015` remain +proposed future workplans for hats/access profiles, onboarding journeys, +optional UI, and security conformance. diff --git a/docs/contracts.md b/docs/contracts.md index cdd865b..575f221 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -9,6 +9,9 @@ HTTP or RPC adapters should preserve these operation names: - `start_registration`, `attach_registration_factor`, `complete_registration`, `abandon_registration`, `expire_registration`, `resume_registration`, `registration_diagnostics` +- `prepare_account`, `update_prepared_account`, `list_prepared_accounts`, + `revoke_prepared_account`, `expire_prepared_account`, + `claim_prepared_account` - `me`, `create_user`, `set_account_status`, `link_identity` - `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`, `tenant_diagnostics` @@ -40,6 +43,25 @@ user-engine does not verify factors itself, issue credentials, perform MFA, run eID proofing, or issue tokens. Those remain external IAM/proofing adapter responsibilities. +## Prepared Account Contract + +Prepared accounts are pending user-domain facts for people who have not yet +registered or have not yet claimed their prepared rights. They can carry +required factor matches, entitlement intent, preparer metadata, expiry, and +claim lifecycle state, but they do not create credentials. + +`claim_prepared_account` requires a completed registration session and +unexpired verified `IdentityFactor` records that satisfy every prepared factor +requirement. A successful claim marks the package claimed and converts +prepared entitlements into user-engine-owned facts: tenant account state, +memberships, catalog validated profile values, application bindings, and +onboarding-request events. + +Expired, revoked, claimed, mismatching, ambiguous, duplicate, or +approval-required packages fail closed. Denied claim decisions are audited +without outbox events. Mutation outbox payloads include ids, counts, statuses, +factor types, and journey names, but not normalized factor values. + ## Identity Context Contract `identity_context` is the first canon-facing read model for NetKingdom diff --git a/docs/netkingdom-registration-onboarding-vision.md b/docs/netkingdom-registration-onboarding-vision.md index e6df545..5781c58 100644 --- a/docs/netkingdom-registration-onboarding-vision.md +++ b/docs/netkingdom-registration-onboarding-vision.md @@ -235,6 +235,10 @@ once. ## Recommended Workplans +As of 2026-06-15, `USER-WP-0010` and `USER-WP-0011` are implemented as +headless user-engine slices. The later workplans remain recommended follow-on +work. + | Workplan | Title | Purpose | | --- | --- | --- | | USER-WP-0010 | Registration Identity And Factor Model | Add registration sessions, NetKingdom ID semantics, factor evidence, and verification adapter boundaries. | diff --git a/docs/prepared-accounts-and-entitlement-claims.md b/docs/prepared-accounts-and-entitlement-claims.md new file mode 100644 index 0000000..4dbaad0 --- /dev/null +++ b/docs/prepared-accounts-and-entitlement-claims.md @@ -0,0 +1,117 @@ +# Prepared Accounts And Entitlement Claims + +Status: implemented headless slice +Date: 2026-06-15 +Related workplan: USER-WP-0011 + +## Purpose + +Prepared accounts let a tenant admin, operator, family owner, service owner, or +upstream system prepare user-domain intent before a person registers. The +package can name expected factor matches, tenant account state, memberships, +profile defaults, application bindings, and onboarding journey hints. + +Prepared accounts are not credentials. A package is claimable only after a +completed registration presents matching verified factor evidence. + +## Domain Model + +`PreparedAccount` stores pending account intent: + +- tenant +- required factor matches +- prepared entitlements +- status: `pending`, `claimed`, `revoked`, or `expired` +- preparer subject +- optional display name and primary email hints +- optional expiry +- claim metadata and lifecycle timestamps + +`PreparedFactorRequirement` stores the factor type and normalized value to +match against verified registration factors. The model also carries optional +source-system and evidence references. + +`PreparedEntitlement` stores the activation intent. Supported kinds are: + +- `tenant_account` +- `membership` +- `profile_value` +- `application_binding` +- `onboarding_journey` + +Entitlements may be marked `requires_approval`. Those packages fail closed in +the current claim facade until an explicit approval workflow is added. + +## Public Facade + +`UserEngineService` exposes: + +- `prepare_account(...)` +- `update_prepared_account(...)` +- `list_prepared_accounts(...)` +- `revoke_prepared_account(...)` +- `expire_prepared_account(...)` +- `claim_prepared_account(...)` + +Create, update, list, revoke, expire, and claim operations all pass through the +authorization port. The service depends on `UserEngineStore` protocol methods, +not the in-memory adapter internals. + +## Claim Rules + +Claims are only evaluated for completed registration sessions with a resolved +canonical user. A prepared account matches when every required factor is +present as unexpired verified `IdentityFactor` evidence on the registration. + +The claim facade fails closed when: + +- the caller names a missing, revoked, expired, claimed, or mismatching package; +- no prepared account matches the registration factors; +- multiple pending prepared accounts match the same verified factors; +- any entitlement in the package requires manual approval; +- entitlement activation references an invalid profile attribute or + unregistered application. + +Factor requirements must include non-empty normalized values. Duplicate +pending packages with the same tenant and factor-signature are blocked during +create/update. Expired packages are ignored by duplicate checks and cannot be +claimed. + +## Activation + +Successful claim converts prepared entitlements into user-engine-owned facts: + +- `TenantAccount` for tenant access state; +- `Membership` for scoped role facts; +- `ProfileValue` for catalog-validated profile defaults; +- `ApplicationBinding` for registered protected-system mappings; +- `prepared_account.onboarding_requested` outbox events for journey starts. + +The prepared account is then marked `claimed` with the claiming user and +registration id. + +## Audit, Outbox, And Redaction + +Prepared-account mutations emit audit and outbox records: + +- `prepared_account.created` +- `prepared_account.updated` +- `prepared_account.revoked` +- `prepared_account.expired` +- `prepared_account.claimed` +- `prepared_account.onboarding_requested` + +Denied claim decisions are audited without outbox events. Outbox payloads use +ids, counts, factor types, statuses, and journey names. They deliberately avoid +normalized factor values such as email addresses, phone numbers, postal +addresses, and eID payloads. + +## Current Limits + +- Prepared accounts do not issue credentials, invitations, MFA challenges, or + tokens. +- Approval-required entitlement packages are blocked until a later workplan + adds explicit approval decisions. +- Final authorization policy and ACL evaluation remains outside user-engine; + user-engine only activates owned facts for policy systems to consume. +- Journey orchestration beyond outbox requests is left to USER-WP-0013. diff --git a/docs/registration-identity-and-factor-model.md b/docs/registration-identity-and-factor-model.md index 3a6495f..df84818 100644 --- a/docs/registration-identity-and-factor-model.md +++ b/docs/registration-identity-and-factor-model.md @@ -106,9 +106,10 @@ payloads. Diagnostics report counts by status and total verified factors. They do not return factor values. -## Current Limits +## Follow-On Boundaries -- Prepared account claiming is intentionally left to USER-WP-0011. +- Prepared account claiming is implemented by USER-WP-0011 and documented in + `docs/prepared-accounts-and-entitlement-claims.md`. - Hats, realms, services, assets, and access profiles are left to USER-WP-0012. - Welcome protocols and onboarding journeys are left to USER-WP-0013. diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index 6cf251d..387a90a 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -21,6 +21,7 @@ from user_engine.domain import ( IdentityFactor, Membership, OutboxEvent, + PreparedAccount, ProfileScope, ProfileValue, RegistrationSession, @@ -54,6 +55,7 @@ class InMemoryUserEngineStore: default_factory=dict ) identity_factors: dict[str, IdentityFactor] = field(default_factory=dict) + prepared_accounts: dict[str, PreparedAccount] = field(default_factory=dict) profile_values: dict[ tuple[str, str, ProfileScope, str | None], ProfileValue ] = field(default_factory=dict) @@ -181,6 +183,21 @@ class InMemoryUserEngineStore: if factor.user_id == user_id ) + def save_prepared_account(self, account: PreparedAccount) -> None: + self.prepared_accounts[account.prepared_account_id] = account + + def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None: + return self.prepared_accounts.get(prepared_account_id) + + def prepared_accounts_for_tenant( + self, tenant: str + ) -> tuple[PreparedAccount, ...]: + return tuple( + account + for account in self.prepared_accounts.values() + if account.tenant == tenant + ) + def save_profile_value(self, value: ProfileValue) -> None: self.profile_values[ (value.user_id, value.attribute_key, value.scope, value.scope_id) @@ -240,6 +257,7 @@ class InMemoryUserEngineStore: "family_invitations": len(self.family_invitations), "registration_sessions": len(self.registration_sessions), "identity_factors": len(self.identity_factors), + "prepared_accounts": len(self.prepared_accounts), "profile_values": len(self.profile_values), "audit_records": len(self.audit_records), "pending_outbox_events": len(self.outbox_events), @@ -258,6 +276,7 @@ class InMemoryUserEngineStore: "family_invitations": copy.deepcopy(self.family_invitations), "registration_sessions": copy.deepcopy(self.registration_sessions), "identity_factors": copy.deepcopy(self.identity_factors), + "prepared_accounts": copy.deepcopy(self.prepared_accounts), "profile_values": copy.deepcopy(self.profile_values), "audit_records": copy.deepcopy(self.audit_records), "outbox_events": copy.deepcopy(self.outbox_events), @@ -285,6 +304,7 @@ class InMemoryUserEngineStore: "registration_sessions" ] # type: ignore[assignment] self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment] + self.prepared_accounts = snapshot["prepared_accounts"] # type: ignore[assignment] self.profile_values = snapshot["profile_values"] # type: ignore[assignment] self.audit_records = [*snapshot_audit_records, *denied_audit_records] self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment] diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index 94e971a..6931078 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -29,6 +29,11 @@ from user_engine.domain.models import ( Mutability, OutboxEvent, PrincipalType, + PreparedAccount, + PreparedAccountStatus, + PreparedEntitlement, + PreparedEntitlementKind, + PreparedFactorRequirement, ProfileScope, ProfileValue, ProjectionType, @@ -71,6 +76,11 @@ __all__ = [ "Mutability", "OutboxEvent", "PrincipalType", + "PreparedAccount", + "PreparedAccountStatus", + "PreparedEntitlement", + "PreparedEntitlementKind", + "PreparedFactorRequirement", "ProfileScope", "ProfileValue", "ProjectionType", diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index d71ab65..2bee539 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -69,6 +69,13 @@ class RegistrationStatus(StrEnum): REJECTED = "rejected" +class PreparedAccountStatus(StrEnum): + PENDING = "pending" + CLAIMED = "claimed" + REVOKED = "revoked" + EXPIRED = "expired" + + class IdentityFactorType(StrEnum): EMAIL = "email" PHONE = "phone" @@ -78,6 +85,14 @@ class IdentityFactorType(StrEnum): SSO = "sso" +class PreparedEntitlementKind(StrEnum): + TENANT_ACCOUNT = "tenant_account" + MEMBERSHIP = "membership" + PROFILE_VALUE = "profile_value" + APPLICATION_BINDING = "application_binding" + ONBOARDING_JOURNEY = "onboarding_journey" + + class ProfileScope(StrEnum): GLOBAL = "global" TENANT = "tenant" @@ -379,6 +394,54 @@ class RegistrationSession: rejected_at: datetime | None = None +@dataclass(frozen=True) +class PreparedFactorRequirement: + factor_type: IdentityFactorType + normalized_value: str + source_system: str | None = None + evidence_refs: tuple[CanonEntityReference, ...] = () + + +@dataclass(frozen=True) +class PreparedEntitlement: + kind: PreparedEntitlementKind + tenant: str + entitlement_id: str = field(default_factory=lambda: new_id("pent")) + scope_type: str | None = None + scope_id: str | None = None + role: str | None = None + attribute_key: str | None = None + value: Any = None + profile_scope: ProfileScope = ProfileScope.GLOBAL + profile_scope_id: str | None = None + tenant_account_status: AccountStatus = AccountStatus.ACTIVE + application_binding: ApplicationBinding | None = None + onboarding_journey: str | None = None + requires_approval: bool = False + evidence_refs: tuple[CanonEntityReference, ...] = () + + +@dataclass(frozen=True) +class PreparedAccount: + tenant: str + required_factor_matches: tuple[PreparedFactorRequirement, ...] + entitlements: tuple[PreparedEntitlement, ...] + prepared_account_id: str = field(default_factory=lambda: new_id("pacct")) + status: PreparedAccountStatus = PreparedAccountStatus.PENDING + display_name: str | None = None + primary_email: str | None = None + prepared_by_subject: str | None = None + correlation_id: str | None = None + expires_at: datetime | None = None + claimed_by_user_id: str | None = None + claimed_registration_id: str | None = None + created_at: datetime = field(default_factory=utc_now) + updated_at: datetime = field(default_factory=utc_now) + claimed_at: datetime | None = None + revoked_at: datetime | None = None + expired_at: datetime | None = None + + @dataclass(frozen=True) class AuthorizationRequest: actor: Actor diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 939ce57..81c50cc 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -26,6 +26,7 @@ from user_engine.domain import ( IdentityFactor, Membership, OutboxEvent, + PreparedAccount, ProfileValue, RegistrationSession, TenantAccount, @@ -148,6 +149,17 @@ class UserEngineStore(Protocol): def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]: """Return verified factors attached to a user.""" + def save_prepared_account(self, account: PreparedAccount) -> None: + """Create or replace a prepared account package.""" + + def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None: + """Return a prepared account package by id.""" + + def prepared_accounts_for_tenant( + self, tenant: str + ) -> tuple[PreparedAccount, ...]: + """Return prepared account packages for a tenant.""" + def save_profile_value(self, value: ProfileValue) -> None: """Create or replace a profile value.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 57f53bb..5bf5043 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, replace +from datetime import datetime from typing import Any, Iterable, Mapping from user_engine.domain import ( @@ -30,6 +31,11 @@ from user_engine.domain import ( Membership, Mutability, OutboxEvent, + PreparedAccount, + PreparedAccountStatus, + PreparedEntitlement, + PreparedEntitlementKind, + PreparedFactorRequirement, ProfileScope, ProfileValue, ProjectionType, @@ -65,6 +71,7 @@ _TERMINAL_REGISTRATION_STATUSES = { RegistrationStatus.EXPIRED, RegistrationStatus.REJECTED, } +_UNSET = object() @dataclass(frozen=True) @@ -169,6 +176,18 @@ class RegistrationCompletion: identity_context: IdentityContext +@dataclass(frozen=True) +class PreparedAccountClaim: + prepared_account: PreparedAccount + registration: RegistrationSession + user: User + memberships: tuple[Membership, ...] + tenant_accounts: tuple[TenantAccount, ...] + profile_values: tuple[ProfileValue, ...] + application_bindings: tuple[ApplicationBinding, ...] + onboarding_journeys: tuple[str, ...] + + @dataclass(frozen=True) class TenantDiagnostics: tenant: str @@ -612,6 +631,334 @@ class UserEngineService: pending_session_count=pending_count, ) + def prepare_account( + self, + actor: Actor, + *, + tenant: str, + required_factor_matches: Iterable[PreparedFactorRequirement], + entitlements: Iterable[PreparedEntitlement], + display_name: str | None = None, + primary_email: str | None = None, + expires_at: datetime | None = None, + correlation_id: str | None = None, + ) -> PreparedAccount: + tenant_context = self.resolve_tenant_context(actor, tenant) + required = tuple(required_factor_matches) + prepared_entitlements = tuple(entitlements) + self._validate_prepared_factor_requirements(required) + if not prepared_entitlements: + raise ValidationError("prepared account requires at least one entitlement") + self._validate_prepared_entitlements( + prepared_entitlements, tenant_context.tenant + ) + self._ensure_no_duplicate_prepared_account( + tenant_context.tenant, + required, + excluding_prepared_account_id=None, + ) + correlation_id = correlation_id or new_id("corr") + decision = self._authorize( + actor, + action="prepared_account.create", + resource_type="user-engine:prepared-account", + resource_id="new", + tenant=tenant_context.tenant, + correlation_id=correlation_id, + context={ + "factor_types": tuple( + sorted({item.factor_type.value for item in required}) + ), + "entitlement_count": len(prepared_entitlements), + }, + ) + prepared = PreparedAccount( + tenant=tenant_context.tenant, + required_factor_matches=required, + entitlements=prepared_entitlements, + display_name=display_name, + primary_email=primary_email, + prepared_by_subject=actor.subject, + correlation_id=correlation_id, + expires_at=expires_at, + ) + with self.store.transaction(): + self.store.save_prepared_account(prepared) + self._record_mutation( + actor, + action="prepared_account.create", + subject=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="prepared_account.created", + aggregate_id=prepared.prepared_account_id, + payload={ + "prepared_account_id": prepared.prepared_account_id, + "factor_types": tuple( + sorted( + { + item.factor_type.value + for item in prepared.required_factor_matches + } + ) + ), + "entitlement_count": len(prepared.entitlements), + "expires_at": prepared.expires_at.isoformat() + if prepared.expires_at + else None, + }, + ) + return prepared + + def update_prepared_account( + self, + actor: Actor, + prepared_account_id: str, + *, + required_factor_matches: Iterable[PreparedFactorRequirement] | None = None, + entitlements: Iterable[PreparedEntitlement] | None = None, + expires_at: datetime | None | object = _UNSET, + correlation_id: str | None = None, + ) -> PreparedAccount: + prepared = self._require_prepared_account(prepared_account_id) + self._ensure_prepared_account_pending(prepared) + tenant_context = self.resolve_tenant_context(actor, prepared.tenant) + required = ( + tuple(required_factor_matches) + if required_factor_matches is not None + else prepared.required_factor_matches + ) + prepared_entitlements = ( + tuple(entitlements) if entitlements is not None else prepared.entitlements + ) + self._validate_prepared_factor_requirements(required) + if not prepared_entitlements: + raise ValidationError("prepared account requires at least one entitlement") + self._validate_prepared_entitlements( + prepared_entitlements, tenant_context.tenant + ) + self._ensure_no_duplicate_prepared_account( + tenant_context.tenant, + required, + excluding_prepared_account_id=prepared.prepared_account_id, + ) + correlation_id = correlation_id or new_id("corr") + decision = self._authorize( + actor, + action="prepared_account.update", + resource_type="user-engine:prepared-account", + resource_id=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + updated = replace( + prepared, + required_factor_matches=required, + entitlements=prepared_entitlements, + expires_at=prepared.expires_at if expires_at is _UNSET else expires_at, + updated_at=utc_now(), + ) + with self.store.transaction(): + self.store.save_prepared_account(updated) + self._record_mutation( + actor, + action="prepared_account.update", + subject=updated.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="prepared_account.updated", + aggregate_id=updated.prepared_account_id, + payload={ + "prepared_account_id": updated.prepared_account_id, + "factor_types": tuple( + sorted( + { + item.factor_type.value + for item in updated.required_factor_matches + } + ) + ), + "entitlement_count": len(updated.entitlements), + "expires_at": updated.expires_at.isoformat() + if updated.expires_at + else None, + }, + ) + return updated + + def list_prepared_accounts( + self, + actor: Actor, + *, + tenant: str, + correlation_id: str | None = None, + ) -> tuple[PreparedAccount, ...]: + tenant_context = self.resolve_tenant_context(actor, tenant) + correlation_id = correlation_id or new_id("corr") + self._authorize( + actor, + action="prepared_account.read", + resource_type="user-engine:prepared-account", + resource_id=tenant_context.tenant, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + return self.store.prepared_accounts_for_tenant(tenant_context.tenant) + + def revoke_prepared_account( + self, + actor: Actor, + prepared_account_id: str, + *, + correlation_id: str | None = None, + ) -> PreparedAccount: + return self._close_prepared_account( + actor, + prepared_account_id, + status=PreparedAccountStatus.REVOKED, + action="prepared_account.revoke", + event_type="prepared_account.revoked", + correlation_id=correlation_id, + ) + + def expire_prepared_account( + self, + actor: Actor, + prepared_account_id: str, + *, + correlation_id: str | None = None, + ) -> PreparedAccount: + return self._close_prepared_account( + actor, + prepared_account_id, + status=PreparedAccountStatus.EXPIRED, + action="prepared_account.expire", + event_type="prepared_account.expired", + correlation_id=correlation_id, + ) + + def claim_prepared_account( + self, + actor: Actor, + registration_id: str, + *, + prepared_account_id: str | None = None, + correlation_id: str | None = None, + ) -> PreparedAccountClaim: + session = self._require_registration_session(registration_id) + if session.status != RegistrationStatus.COMPLETED or session.user_id is None: + raise ValidationError("registration must be completed before claim") + tenant_context = self.resolve_tenant_context(actor, session.tenant) + correlation_id = correlation_id or new_id("corr") + factors = self.store.factors_for_registration(session.registration_id) + prepared = self._resolve_prepared_account_claim( + actor, + session, + factors, + prepared_account_id=prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + if any(entitlement.requires_approval for entitlement in prepared.entitlements): + self._record_audit_only( + actor, + action="prepared_account.claim", + subject=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + summary="prepared account claim denied: approval required", + ) + raise AuthorizationDenied("prepared account claim requires approval") + decision = self._authorize( + actor, + action="prepared_account.claim", + resource_type="user-engine:prepared-account", + resource_id=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + target_user_id=session.user_id, + context={ + "registration_id": session.registration_id, + "entitlement_count": len(prepared.entitlements), + }, + ) + user = self._require_user(session.user_id) + ( + tenant_accounts, + memberships, + profile_values, + application_bindings, + onboarding_journeys, + ) = self._prepared_entitlement_records( + prepared, + user.user_id, + correlation_id=correlation_id, + ) + claimed_at = utc_now() + claimed = replace( + prepared, + status=PreparedAccountStatus.CLAIMED, + claimed_by_user_id=user.user_id, + claimed_registration_id=session.registration_id, + claimed_at=claimed_at, + updated_at=claimed_at, + ) + with self.store.transaction(): + for account in tenant_accounts: + self.store.save_tenant_account(account) + for membership in memberships: + self.store.save_membership(membership) + for value in profile_values: + self.store.save_profile_value(value) + for binding in application_bindings: + self.store.save_binding(binding) + self.store.save_prepared_account(claimed) + self._record_mutation( + actor, + action="prepared_account.claim", + subject=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="prepared_account.claimed", + aggregate_id=user.user_id, + payload={ + "prepared_account_id": prepared.prepared_account_id, + "registration_id": session.registration_id, + "user_id": user.user_id, + "entitlement_count": len(prepared.entitlements), + }, + ) + for journey in onboarding_journeys: + self._record_mutation( + actor, + action="prepared_account.onboarding.start", + subject=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="prepared_account.onboarding_requested", + aggregate_id=user.user_id, + payload={ + "prepared_account_id": prepared.prepared_account_id, + "registration_id": session.registration_id, + "user_id": user.user_id, + "journey": journey, + }, + ) + return PreparedAccountClaim( + prepared_account=claimed, + registration=session, + user=user, + memberships=memberships, + tenant_accounts=tenant_accounts, + profile_values=profile_values, + application_bindings=application_bindings, + onboarding_journeys=onboarding_journeys, + ) + def me( self, claims: Mapping[str, Any], @@ -1749,6 +2096,54 @@ class UserEngineService: ) return updated + def _close_prepared_account( + self, + actor: Actor, + prepared_account_id: str, + *, + status: PreparedAccountStatus, + action: str, + event_type: str, + correlation_id: str | None, + ) -> PreparedAccount: + prepared = self._require_prepared_account(prepared_account_id) + self._ensure_prepared_account_pending(prepared) + tenant_context = self.resolve_tenant_context(actor, prepared.tenant) + correlation_id = correlation_id or new_id("corr") + decision = self._authorize( + actor, + action=action, + resource_type="user-engine:prepared-account", + resource_id=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + now = utc_now() + updated = replace( + prepared, + status=status, + updated_at=now, + revoked_at=now if status == PreparedAccountStatus.REVOKED else None, + expired_at=now if status == PreparedAccountStatus.EXPIRED else None, + ) + with self.store.transaction(): + self.store.save_prepared_account(updated) + self._record_mutation( + actor, + action=action, + subject=prepared.prepared_account_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type=event_type, + aggregate_id=prepared.prepared_account_id, + payload={ + "prepared_account_id": prepared.prepared_account_id, + "status": updated.status, + }, + ) + return updated + def _require_registration_session( self, registration_id: str ) -> RegistrationSession: @@ -1770,6 +2165,229 @@ class UserEngineService: raise ValidationError("factor verifier adapter is required") return self.factor_verifier.normalize(verification) + def _require_prepared_account(self, prepared_account_id: str) -> PreparedAccount: + prepared = self.store.prepared_account(prepared_account_id) + if prepared is None: + raise NotFoundError("prepared account not found") + return prepared + + def _ensure_prepared_account_pending(self, prepared: PreparedAccount) -> None: + if prepared.status != PreparedAccountStatus.PENDING: + raise ValidationError("prepared account is not pending") + + def _ensure_no_duplicate_prepared_account( + self, + tenant: str, + requirements: tuple[PreparedFactorRequirement, ...], + *, + excluding_prepared_account_id: str | None, + ) -> None: + signature = _prepared_factor_signature(requirements) + for prepared in self.store.prepared_accounts_for_tenant(tenant): + if prepared.prepared_account_id == excluding_prepared_account_id: + continue + if prepared.status != PreparedAccountStatus.PENDING: + continue + if _prepared_account_expired(prepared): + continue + if _prepared_factor_signature(prepared.required_factor_matches) == signature: + raise ConflictError("matching prepared account already exists") + + def _validate_prepared_factor_requirements( + self, requirements: tuple[PreparedFactorRequirement, ...] + ) -> None: + if not requirements: + raise ValidationError("prepared account requires at least one factor match") + for requirement in requirements: + if not requirement.normalized_value.strip(): + raise ValidationError( + "prepared account factor match requires normalized value" + ) + + def _validate_prepared_entitlements( + self, entitlements: tuple[PreparedEntitlement, ...], tenant: str + ) -> None: + for entitlement in entitlements: + if entitlement.tenant != tenant: + raise ValidationError("prepared entitlement tenant must match account") + if entitlement.kind == PreparedEntitlementKind.TENANT_ACCOUNT: + continue + if entitlement.kind == PreparedEntitlementKind.MEMBERSHIP: + if not entitlement.scope_type or not entitlement.scope_id: + raise ValidationError("membership entitlement requires scope") + if not entitlement.role: + raise ValidationError("membership entitlement requires role") + elif entitlement.kind == PreparedEntitlementKind.PROFILE_VALUE: + if not entitlement.attribute_key: + raise ValidationError("profile entitlement requires attribute_key") + elif entitlement.kind == PreparedEntitlementKind.APPLICATION_BINDING: + if entitlement.application_binding is None: + raise ValidationError( + "application binding entitlement requires binding" + ) + elif entitlement.kind == PreparedEntitlementKind.ONBOARDING_JOURNEY: + if not entitlement.onboarding_journey: + raise ValidationError( + "onboarding journey entitlement requires journey" + ) + else: + raise ValidationError(f"unsupported entitlement kind: {entitlement.kind}") + + def _resolve_prepared_account_claim( + self, + actor: Actor, + session: RegistrationSession, + factors: tuple[IdentityFactor, ...], + *, + prepared_account_id: str | None, + tenant: str, + correlation_id: str, + ) -> PreparedAccount: + candidates = ( + (self._require_prepared_account(prepared_account_id),) + if prepared_account_id is not None + else self.store.prepared_accounts_for_tenant(tenant) + ) + matches = tuple( + prepared + for prepared in candidates + if prepared.tenant == tenant + and prepared.status == PreparedAccountStatus.PENDING + and not _prepared_account_expired(prepared) + and _prepared_account_matches_factors(prepared, factors) + ) + if prepared_account_id is not None and not matches: + self._record_audit_only( + actor, + action="prepared_account.claim", + subject=prepared_account_id, + tenant=tenant, + correlation_id=correlation_id, + summary="prepared account claim denied: factor mismatch or closed", + ) + raise ValidationError("prepared account claim requirements are not met") + if not matches: + self._record_audit_only( + actor, + action="prepared_account.claim", + subject=session.registration_id, + tenant=tenant, + correlation_id=correlation_id, + summary="prepared account claim denied: no match", + ) + raise NotFoundError("no matching prepared account found") + if len(matches) > 1: + self._record_audit_only( + actor, + action="prepared_account.claim", + subject=session.registration_id, + tenant=tenant, + correlation_id=correlation_id, + summary="prepared account claim denied: ambiguous match", + ) + raise ConflictError("multiple prepared accounts match registration") + return matches[0] + + def _prepared_entitlement_records( + self, + prepared: PreparedAccount, + user_id: str, + *, + correlation_id: str, + ) -> tuple[ + tuple[TenantAccount, ...], + tuple[Membership, ...], + tuple[ProfileValue, ...], + tuple[ApplicationBinding, ...], + tuple[str, ...], + ]: + tenant_accounts: list[TenantAccount] = [] + memberships: list[Membership] = [] + profile_values: list[ProfileValue] = [] + application_bindings: list[ApplicationBinding] = [] + onboarding_journeys: list[str] = [] + for entitlement in prepared.entitlements: + if entitlement.kind == PreparedEntitlementKind.TENANT_ACCOUNT: + tenant_accounts.append( + TenantAccount( + user_id=user_id, + tenant=entitlement.tenant, + status=entitlement.tenant_account_status, + ) + ) + elif entitlement.kind == PreparedEntitlementKind.MEMBERSHIP: + memberships.append( + Membership( + membership_id=new_id("mem"), + user_id=user_id, + tenant=entitlement.tenant, + scope_type=entitlement.scope_type or "tenant", + scope_id=entitlement.scope_id or entitlement.tenant, + kind=entitlement.role or "member", + source_system="prepared-account", + freshness_version=correlation_id, + ) + ) + elif entitlement.kind == PreparedEntitlementKind.PROFILE_VALUE: + definition = self._require_attribute(entitlement.attribute_key or "") + self._validate_profile_scope( + definition, + entitlement.profile_scope, + entitlement.profile_scope_id, + ) + self._validate_value(definition, entitlement.value) + profile_values.append( + ProfileValue( + user_id=user_id, + attribute_key=entitlement.attribute_key or "", + value=entitlement.value, + scope=entitlement.profile_scope, + scope_id=entitlement.profile_scope_id, + source=f"prepared-account:{prepared.prepared_account_id}", + ) + ) + elif entitlement.kind == PreparedEntitlementKind.APPLICATION_BINDING: + if entitlement.application_binding is not None: + if self.store.application( + entitlement.application_binding.application_id + ) is None: + raise ValidationError( + "application binding entitlement requires registered application" + ) + application_bindings.append(entitlement.application_binding) + elif entitlement.kind == PreparedEntitlementKind.ONBOARDING_JOURNEY: + if entitlement.onboarding_journey: + onboarding_journeys.append(entitlement.onboarding_journey) + return ( + tuple(tenant_accounts), + tuple(memberships), + tuple(profile_values), + tuple(application_bindings), + tuple(onboarding_journeys), + ) + + def _record_audit_only( + self, + actor: Actor, + *, + action: str, + subject: str, + tenant: str, + correlation_id: str, + summary: str, + ) -> None: + self.store.append_audit( + AuditRecord( + audit_id=new_id("aud"), + actor=actor, + action=action, + subject=subject, + tenant=tenant, + correlation_id=correlation_id, + summary=summary, + ) + ) + def _ensure_actor_session( self, actor: Actor, correlation_id: str ) -> UserSession: @@ -2595,6 +3213,43 @@ def _registration_primary_email( return _optional_claim(actor, "email") +def _prepared_factor_signature( + requirements: Iterable[PreparedFactorRequirement], +) -> tuple[tuple[str, str], ...]: + return tuple( + sorted( + ( + requirement.factor_type.value, + requirement.normalized_value.casefold(), + ) + for requirement in requirements + ) + ) + + +def _prepared_account_expired(prepared: PreparedAccount) -> bool: + return prepared.expires_at is not None and prepared.expires_at <= utc_now() + + +def _prepared_account_matches_factors( + prepared: PreparedAccount, factors: Iterable[IdentityFactor] +) -> bool: + now = utc_now() + factor_values = { + (factor.factor_type, factor.normalized_value.casefold()) + for factor in factors + if factor.expires_at is None or factor.expires_at > now + } + return all( + ( + requirement.factor_type, + requirement.normalized_value.casefold(), + ) + in factor_values + for requirement in prepared.required_factor_matches + ) + + def _scope_concept(scope_type: str) -> str: concepts = { "team": "Team", diff --git a/tests/test_ports_and_fixtures.py b/tests/test_ports_and_fixtures.py index 8bbe320..5f8af11 100644 --- a/tests/test_ports_and_fixtures.py +++ b/tests/test_ports_and_fixtures.py @@ -163,6 +163,7 @@ class _ProtocolOnlyStore: "identity_factors", "memberships", "outbox_events", + "prepared_accounts", "profile_values", "registration_sessions", "tenant_accounts", diff --git a/tests/test_prepared_accounts.py b/tests/test_prepared_accounts.py new file mode 100644 index 0000000..415c19b --- /dev/null +++ b/tests/test_prepared_accounts.py @@ -0,0 +1,420 @@ +from datetime import timedelta +import unittest + +from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort +from user_engine.domain import ( + AccountStatus, + CanonEntityReference, + Catalog, + CatalogLifecycle, + FactorVerification, + IdentityFactorType, + PreparedAccount, + PreparedAccountStatus, + PreparedEntitlement, + PreparedEntitlementKind, + PreparedFactorRequirement, + ProfileScope, + utc_now, +) +from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError +from user_engine.service import UserEngineService +from user_engine.testing.fixtures import ( + FixtureIdentityClaimsAdapter, + human_actor_claims, + sample_application, + sample_application_binding, + sample_catalog, +) + + +class PreparedAccountTests(unittest.TestCase): + def test_claim_prepared_account_activates_entitlements(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + _bootstrap_catalog(service, preparer) + prepared = _prepare_demo_account(service, preparer) + registration = _complete_registration(service, applicant) + + claim = service.claim_prepared_account( + applicant, + registration.session.registration_id, + correlation_id="corr-claim", + ) + + self.assertEqual(claim.prepared_account.status, PreparedAccountStatus.CLAIMED) + self.assertEqual(claim.prepared_account.claimed_by_user_id, claim.user.user_id) + self.assertEqual(claim.tenant_accounts[0].status, AccountStatus.ACTIVE) + self.assertEqual(claim.memberships[0].scope_id, "team:demo") + self.assertEqual(claim.memberships[0].kind, "member") + self.assertEqual(claim.profile_values[0].attribute_key, "demo.display_density") + self.assertEqual(claim.profile_values[0].value, "compact") + self.assertEqual(claim.onboarding_journeys, ("welcome-demo",)) + self.assertEqual( + store.prepared_account(prepared.prepared_account_id).status, + PreparedAccountStatus.CLAIMED, + ) + self.assertIn( + "prepared_account.onboarding_requested", + [event.event_type for event in service.outbox_events()], + ) + self.assertNotIn( + "sample.user@example.test", + repr([event.payload for event in service.outbox_events()]), + ) + + def test_claim_requires_matching_verified_factor(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + _bootstrap_catalog(service, preparer) + prepared = _prepare_demo_account( + service, + preparer, + email="different@example.test", + ) + registration = _complete_registration(service, applicant) + before_outbox = len(service.outbox_events()) + + with self.assertRaises(ValidationError): + service.claim_prepared_account( + applicant, + registration.session.registration_id, + prepared_account_id=prepared.prepared_account_id, + correlation_id="corr-claim-mismatch", + ) + + self.assertEqual( + store.prepared_account(prepared.prepared_account_id).status, + PreparedAccountStatus.PENDING, + ) + self.assertEqual(store.memberships_for_user(registration.user.user_id), ()) + self.assertEqual(len(service.outbox_events()), before_outbox) + self.assertEqual( + service.audit_records()[-1].summary, + "prepared account claim denied: factor mismatch or closed", + ) + + def test_claim_ignores_expired_factor_evidence(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + _bootstrap_catalog(service, preparer) + prepared = _prepare_demo_account(service, preparer) + registration = _complete_registration( + service, + applicant, + factor_expires_at=utc_now() - timedelta(days=1), + ) + + with self.assertRaises(ValidationError): + service.claim_prepared_account( + applicant, + registration.session.registration_id, + prepared_account_id=prepared.prepared_account_id, + correlation_id="corr-claim-expired-factor", + ) + + self.assertEqual(store.memberships_for_user(registration.user.user_id), ()) + self.assertEqual( + service.audit_records()[-1].summary, + "prepared account claim denied: factor mismatch or closed", + ) + + def test_ambiguous_prepared_account_matches_fail_closed(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + _bootstrap_catalog(service, preparer) + requirement = _email_requirement() + entitlements = (_membership_entitlement(),) + store.save_prepared_account( + PreparedAccount( + tenant="tenant:coulomb", + required_factor_matches=(requirement,), + entitlements=entitlements, + prepared_by_subject="fixture", + ) + ) + store.save_prepared_account( + PreparedAccount( + tenant="tenant:coulomb", + required_factor_matches=(requirement,), + entitlements=entitlements, + prepared_by_subject="fixture", + ) + ) + registration = _complete_registration(service, applicant) + + with self.assertRaises(ConflictError): + service.claim_prepared_account( + applicant, + registration.session.registration_id, + correlation_id="corr-ambiguous", + ) + + self.assertEqual( + service.audit_records()[-1].summary, + "prepared account claim denied: ambiguous match", + ) + self.assertEqual(store.memberships_for_user(registration.user.user_id), ()) + + def test_approval_required_entitlement_blocks_claim(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + prepared = service.prepare_account( + preparer, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement(),), + entitlements=( + PreparedEntitlement( + kind=PreparedEntitlementKind.MEMBERSHIP, + tenant="tenant:coulomb", + scope_type="team", + scope_id="team:ops", + role="admin", + requires_approval=True, + ), + ), + correlation_id="corr-prepare-privileged", + ) + registration = _complete_registration(service, applicant) + + with self.assertRaises(AuthorizationDenied): + service.claim_prepared_account( + applicant, + registration.session.registration_id, + prepared_account_id=prepared.prepared_account_id, + correlation_id="corr-claim-privileged", + ) + + self.assertEqual( + store.prepared_account(prepared.prepared_account_id).status, + PreparedAccountStatus.PENDING, + ) + self.assertEqual(store.memberships_for_user(registration.user.user_id), ()) + self.assertEqual( + service.audit_records()[-1].summary, + "prepared account claim denied: approval required", + ) + + def test_revoked_and_expired_prepared_accounts_cannot_be_claimed(self): + service, store = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + applicant = _actor(subject="new-user", email="sample.user@example.test") + _bootstrap_catalog(service, preparer) + revoked = _prepare_demo_account(service, preparer) + service.revoke_prepared_account( + preparer, + revoked.prepared_account_id, + correlation_id="corr-revoke", + ) + expired = _prepare_demo_account(service, preparer) + service.expire_prepared_account( + preparer, + expired.prepared_account_id, + correlation_id="corr-expire", + ) + registration = _complete_registration(service, applicant) + + for prepared in (revoked, expired): + with self.assertRaises(ValidationError): + service.claim_prepared_account( + applicant, + registration.session.registration_id, + prepared_account_id=prepared.prepared_account_id, + correlation_id=f"corr-claim-{prepared.prepared_account_id}", + ) + + self.assertEqual(store.memberships_for_user(registration.user.user_id), ()) + self.assertEqual( + service.audit_records()[-1].summary, + "prepared account claim denied: factor mismatch or closed", + ) + + def test_duplicate_pending_prepared_accounts_are_rejected(self): + service, _ = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + _bootstrap_catalog(service, preparer) + _prepare_demo_account(service, preparer) + + with self.assertRaises(ConflictError): + _prepare_demo_account(service, preparer) + + def test_weak_factor_requirements_are_rejected(self): + service, _ = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + + with self.assertRaises(ValidationError): + service.prepare_account( + preparer, + tenant="tenant:coulomb", + required_factor_matches=( + PreparedFactorRequirement( + factor_type=IdentityFactorType.EMAIL, + normalized_value=" ", + ), + ), + entitlements=(_membership_entitlement(),), + correlation_id="corr-weak-factor", + ) + + def test_revoke_and_list_prepared_accounts(self): + service, _ = _service() + preparer = _actor(subject="tenant-admin", roles=("tenant-admin",)) + prepared = _prepare_demo_account(service, preparer) + + listed = service.list_prepared_accounts( + preparer, + tenant="tenant:coulomb", + correlation_id="corr-list", + ) + revoked = service.revoke_prepared_account( + preparer, + prepared.prepared_account_id, + correlation_id="corr-revoke", + ) + + self.assertEqual(listed[0].prepared_account_id, prepared.prepared_account_id) + self.assertEqual(revoked.status, PreparedAccountStatus.REVOKED) + + +def _service(): + store = InMemoryUserEngineStore() + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort(), + ) + return service, store + + +def _actor( + *, + subject: str = "sample-user", + roles: tuple[str, ...] = ("user",), + email: str = "sample.user@example.test", +): + claims = human_actor_claims(subject=subject, tenant="tenant:coulomb") + claims["roles"] = list(roles) + claims["email"] = email + claims["preferred_username"] = subject + return FixtureIdentityClaimsAdapter().normalize(claims) + + +def _bootstrap_catalog(service: UserEngineService, actor): + service.register_application( + actor, + sample_application(), + binding=sample_application_binding(), + correlation_id="corr-app", + ) + service.publish_catalog( + actor, + Catalog( + catalog_id="demo-prepared-profile", + namespace=sample_catalog().namespace, + version=sample_catalog().version, + owning_application_id=sample_catalog().owning_application_id, + lifecycle=CatalogLifecycle.ACTIVE, + attributes=sample_catalog().attributes, + ), + correlation_id="corr-catalog", + ) + + +def _complete_registration( + service: UserEngineService, + actor, + *, + factor_expires_at=None, +): + session = service.start_registration(actor, correlation_id="corr-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", + evidence_refs=( + CanonEntityReference( + concept="Evidence Source", + identifier="email-proof", + source_system="fixture-email", + ), + ), + expires_at=factor_expires_at, + ), + correlation_id="corr-factor", + ) + return service.complete_registration( + actor, + session.registration_id, + correlation_id="corr-complete", + ) + + +def _prepare_demo_account( + service: UserEngineService, + preparer, + *, + email: str = "sample.user@example.test", +): + return service.prepare_account( + preparer, + tenant="tenant:coulomb", + required_factor_matches=(_email_requirement(email=email),), + entitlements=( + PreparedEntitlement( + kind=PreparedEntitlementKind.TENANT_ACCOUNT, + tenant="tenant:coulomb", + tenant_account_status=AccountStatus.ACTIVE, + ), + _membership_entitlement(), + PreparedEntitlement( + kind=PreparedEntitlementKind.PROFILE_VALUE, + tenant="tenant:coulomb", + attribute_key="demo.display_density", + value="compact", + profile_scope=ProfileScope.APPLICATION, + profile_scope_id="app.demo", + ), + PreparedEntitlement( + kind=PreparedEntitlementKind.ONBOARDING_JOURNEY, + tenant="tenant:coulomb", + onboarding_journey="welcome-demo", + ), + ), + display_name="Prepared User", + primary_email=email, + correlation_id="corr-prepare", + ) + + +def _email_requirement( + *, + email: str = "sample.user@example.test", +) -> PreparedFactorRequirement: + return PreparedFactorRequirement( + factor_type=IdentityFactorType.EMAIL, + normalized_value=email, + source_system="fixture-email", + ) + + +def _membership_entitlement() -> PreparedEntitlement: + return PreparedEntitlement( + kind=PreparedEntitlementKind.MEMBERSHIP, + tenant="tenant:coulomb", + scope_type="team", + scope_id="team:demo", + role="member", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md b/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md index 9494484..037fd23 100644 --- a/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md +++ b/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md @@ -4,7 +4,7 @@ type: workplan title: "Prepared Accounts And Entitlement Claims" domain: netkingdom repo: user-engine -status: proposed +status: finished owner: codex topic_slug: netkingdom planning_priority: high @@ -45,7 +45,7 @@ history. ```task id: USER-WP-0011-T1 -status: todo +status: done priority: high state_hub_task_id: "11508f77-170b-4b22-bfdc-115a69bfe4db" ``` @@ -56,7 +56,7 @@ audit metadata. ```task id: USER-WP-0011-T2 -status: todo +status: done priority: high state_hub_task_id: "86ca36d4-721b-48fe-8c0c-c6a1e6740d2f" ``` @@ -66,7 +66,7 @@ accounts, guarded by the authorization port. ```task id: USER-WP-0011-T3 -status: todo +status: done priority: high state_hub_task_id: "fe5a08e8-1101-4cec-b02f-b2eee8928604" ``` @@ -76,7 +76,7 @@ prepared account requirements and produce explicit claim decisions. ```task id: USER-WP-0011-T4 -status: todo +status: done priority: high state_hub_task_id: "8aef6d9e-5e76-4e44-bf81-58049b22a25c" ``` @@ -87,7 +87,7 @@ onboarding journey starts. ```task id: USER-WP-0011-T5 -status: todo +status: done priority: medium state_hub_task_id: "527519a1-48ed-45fc-a6fc-739986ae6303" ``` @@ -97,7 +97,7 @@ matches, expired packages, privileged roles, and manual approval requirements. ```task id: USER-WP-0011-T6 -status: todo +status: done priority: medium state_hub_task_id: "9530c8d6-82af-4635-8af8-aa79c54be94d" ``` @@ -122,3 +122,39 @@ activation, denial, expiry, and revocation. - Prepared entitlement activation facade. - Claim matching rules and tests. - Documentation for account preparation boundaries. + +## Implementation Notes + +Implemented on 2026-06-15: + +- Added `PreparedAccountStatus`, `PreparedEntitlementKind`, + `PreparedFactorRequirement`, `PreparedEntitlement`, and `PreparedAccount` + domain models. +- Added prepared-account persistence to `UserEngineStore` and + `InMemoryUserEngineStore`, including transaction rollback snapshots and + adapter-neutral record counts. +- Added `UserEngineService` prepared-account facade methods: + `prepare_account`, `update_prepared_account`, `list_prepared_accounts`, + `revoke_prepared_account`, `expire_prepared_account`, and + `claim_prepared_account`. +- Added factor-match claim resolution for completed registrations, explicit + claim decisions, duplicate pending package checks, expiry handling, + weak-factor rejection, ambiguous-match rejection, expired-factor rejection, + and approval-required fail-closed behavior. +- Added entitlement activation into tenant accounts, memberships, catalog + validated profile values, application bindings, and onboarding-request + outbox events. +- Added audit/outbox behavior for preparation, update, claim, onboarding + request, expiry, and revocation while keeping normalized factor values out + of event payloads. +- Added `docs/prepared-accounts-and-entitlement-claims.md`, public contract + updates, and scenario tests for successful claim, mismatch, ambiguity, + approval-required denial, list, and revoke behavior. + +Verification: + +```text +make test +Ran 55 tests in 0.362s +OK +```