generated from coulomb/repo-seed
feat: implement prepared account claims
This commit is contained in:
@@ -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
|
||||
|
||||
7
SCOPE.md
7
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
117
docs/prepared-accounts-and-entitlement-claims.md
Normal file
117
docs/prepared-accounts-and-entitlement-claims.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -163,6 +163,7 @@ class _ProtocolOnlyStore:
|
||||
"identity_factors",
|
||||
"memberships",
|
||||
"outbox_events",
|
||||
"prepared_accounts",
|
||||
"profile_values",
|
||||
"registration_sessions",
|
||||
"tenant_accounts",
|
||||
|
||||
420
tests/test_prepared_accounts.py
Normal file
420
tests/test_prepared_accounts.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user