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/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`,
|
||||||
`docs/netkingdom-registration-onboarding-vision.md`,
|
`docs/netkingdom-registration-onboarding-vision.md`,
|
||||||
`docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.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/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
|
||||||
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
||||||
for implementation boundaries, contracts, canon mappings, examples, and release
|
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`
|
Implementation and planning work is tracked in `workplans/USER-WP-0001`
|
||||||
through `USER-WP-0015`. `USER-WP-0010` implements the first headless
|
through `USER-WP-0015`. `USER-WP-0010` implements the first headless
|
||||||
registration and factor-evidence slice; `USER-WP-0011` through `USER-WP-0015`
|
registration and factor-evidence slice. `USER-WP-0011` implements prepared
|
||||||
remain proposed future workplans for prepared accounts, hats/access profiles,
|
accounts and entitlement claims. `USER-WP-0012` through `USER-WP-0015` remain
|
||||||
onboarding journeys, optional UI, and security conformance.
|
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`,
|
- `start_registration`, `attach_registration_factor`, `complete_registration`,
|
||||||
`abandon_registration`, `expire_registration`, `resume_registration`,
|
`abandon_registration`, `expire_registration`, `resume_registration`,
|
||||||
`registration_diagnostics`
|
`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`
|
- `me`, `create_user`, `set_account_status`, `link_identity`
|
||||||
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
|
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
|
||||||
`tenant_diagnostics`
|
`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
|
run eID proofing, or issue tokens. Those remain external IAM/proofing adapter
|
||||||
responsibilities.
|
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 Contract
|
||||||
|
|
||||||
`identity_context` is the first canon-facing read model for NetKingdom
|
`identity_context` is the first canon-facing read model for NetKingdom
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ once.
|
|||||||
|
|
||||||
## Recommended Workplans
|
## 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 |
|
| Workplan | Title | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| USER-WP-0010 | Registration Identity And Factor Model | Add registration sessions, NetKingdom ID semantics, factor evidence, and verification adapter boundaries. |
|
| 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
|
Diagnostics report counts by status and total verified factors. They do not
|
||||||
return factor values.
|
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
|
- Hats, realms, services, assets, and access profiles are left to
|
||||||
USER-WP-0012.
|
USER-WP-0012.
|
||||||
- Welcome protocols and onboarding journeys are left to USER-WP-0013.
|
- Welcome protocols and onboarding journeys are left to USER-WP-0013.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from user_engine.domain import (
|
|||||||
IdentityFactor,
|
IdentityFactor,
|
||||||
Membership,
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
|
PreparedAccount,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
RegistrationSession,
|
RegistrationSession,
|
||||||
@@ -54,6 +55,7 @@ class InMemoryUserEngineStore:
|
|||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
|
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
|
||||||
|
prepared_accounts: dict[str, PreparedAccount] = field(default_factory=dict)
|
||||||
profile_values: dict[
|
profile_values: dict[
|
||||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||||
] = field(default_factory=dict)
|
] = field(default_factory=dict)
|
||||||
@@ -181,6 +183,21 @@ class InMemoryUserEngineStore:
|
|||||||
if factor.user_id == user_id
|
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:
|
def save_profile_value(self, value: ProfileValue) -> None:
|
||||||
self.profile_values[
|
self.profile_values[
|
||||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||||
@@ -240,6 +257,7 @@ class InMemoryUserEngineStore:
|
|||||||
"family_invitations": len(self.family_invitations),
|
"family_invitations": len(self.family_invitations),
|
||||||
"registration_sessions": len(self.registration_sessions),
|
"registration_sessions": len(self.registration_sessions),
|
||||||
"identity_factors": len(self.identity_factors),
|
"identity_factors": len(self.identity_factors),
|
||||||
|
"prepared_accounts": len(self.prepared_accounts),
|
||||||
"profile_values": len(self.profile_values),
|
"profile_values": len(self.profile_values),
|
||||||
"audit_records": len(self.audit_records),
|
"audit_records": len(self.audit_records),
|
||||||
"pending_outbox_events": len(self.outbox_events),
|
"pending_outbox_events": len(self.outbox_events),
|
||||||
@@ -258,6 +276,7 @@ class InMemoryUserEngineStore:
|
|||||||
"family_invitations": copy.deepcopy(self.family_invitations),
|
"family_invitations": copy.deepcopy(self.family_invitations),
|
||||||
"registration_sessions": copy.deepcopy(self.registration_sessions),
|
"registration_sessions": copy.deepcopy(self.registration_sessions),
|
||||||
"identity_factors": copy.deepcopy(self.identity_factors),
|
"identity_factors": copy.deepcopy(self.identity_factors),
|
||||||
|
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
|
||||||
"profile_values": copy.deepcopy(self.profile_values),
|
"profile_values": copy.deepcopy(self.profile_values),
|
||||||
"audit_records": copy.deepcopy(self.audit_records),
|
"audit_records": copy.deepcopy(self.audit_records),
|
||||||
"outbox_events": copy.deepcopy(self.outbox_events),
|
"outbox_events": copy.deepcopy(self.outbox_events),
|
||||||
@@ -285,6 +304,7 @@ class InMemoryUserEngineStore:
|
|||||||
"registration_sessions"
|
"registration_sessions"
|
||||||
] # type: ignore[assignment]
|
] # type: ignore[assignment]
|
||||||
self.identity_factors = snapshot["identity_factors"] # 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.profile_values = snapshot["profile_values"] # type: ignore[assignment]
|
||||||
self.audit_records = [*snapshot_audit_records, *denied_audit_records]
|
self.audit_records = [*snapshot_audit_records, *denied_audit_records]
|
||||||
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]
|
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ from user_engine.domain.models import (
|
|||||||
Mutability,
|
Mutability,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
PrincipalType,
|
PrincipalType,
|
||||||
|
PreparedAccount,
|
||||||
|
PreparedAccountStatus,
|
||||||
|
PreparedEntitlement,
|
||||||
|
PreparedEntitlementKind,
|
||||||
|
PreparedFactorRequirement,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
@@ -71,6 +76,11 @@ __all__ = [
|
|||||||
"Mutability",
|
"Mutability",
|
||||||
"OutboxEvent",
|
"OutboxEvent",
|
||||||
"PrincipalType",
|
"PrincipalType",
|
||||||
|
"PreparedAccount",
|
||||||
|
"PreparedAccountStatus",
|
||||||
|
"PreparedEntitlement",
|
||||||
|
"PreparedEntitlementKind",
|
||||||
|
"PreparedFactorRequirement",
|
||||||
"ProfileScope",
|
"ProfileScope",
|
||||||
"ProfileValue",
|
"ProfileValue",
|
||||||
"ProjectionType",
|
"ProjectionType",
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ class RegistrationStatus(StrEnum):
|
|||||||
REJECTED = "rejected"
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class PreparedAccountStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
CLAIMED = "claimed"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
class IdentityFactorType(StrEnum):
|
class IdentityFactorType(StrEnum):
|
||||||
EMAIL = "email"
|
EMAIL = "email"
|
||||||
PHONE = "phone"
|
PHONE = "phone"
|
||||||
@@ -78,6 +85,14 @@ class IdentityFactorType(StrEnum):
|
|||||||
SSO = "sso"
|
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):
|
class ProfileScope(StrEnum):
|
||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
TENANT = "tenant"
|
TENANT = "tenant"
|
||||||
@@ -379,6 +394,54 @@ class RegistrationSession:
|
|||||||
rejected_at: datetime | None = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class AuthorizationRequest:
|
class AuthorizationRequest:
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from user_engine.domain import (
|
|||||||
IdentityFactor,
|
IdentityFactor,
|
||||||
Membership,
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
|
PreparedAccount,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
RegistrationSession,
|
RegistrationSession,
|
||||||
TenantAccount,
|
TenantAccount,
|
||||||
@@ -148,6 +149,17 @@ class UserEngineStore(Protocol):
|
|||||||
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
||||||
"""Return verified factors attached to a user."""
|
"""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:
|
def save_profile_value(self, value: ProfileValue) -> None:
|
||||||
"""Create or replace a profile value."""
|
"""Create or replace a profile value."""
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Iterable, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from user_engine.domain import (
|
from user_engine.domain import (
|
||||||
@@ -30,6 +31,11 @@ from user_engine.domain import (
|
|||||||
Membership,
|
Membership,
|
||||||
Mutability,
|
Mutability,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
|
PreparedAccount,
|
||||||
|
PreparedAccountStatus,
|
||||||
|
PreparedEntitlement,
|
||||||
|
PreparedEntitlementKind,
|
||||||
|
PreparedFactorRequirement,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
@@ -65,6 +71,7 @@ _TERMINAL_REGISTRATION_STATUSES = {
|
|||||||
RegistrationStatus.EXPIRED,
|
RegistrationStatus.EXPIRED,
|
||||||
RegistrationStatus.REJECTED,
|
RegistrationStatus.REJECTED,
|
||||||
}
|
}
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -169,6 +176,18 @@ class RegistrationCompletion:
|
|||||||
identity_context: IdentityContext
|
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)
|
@dataclass(frozen=True)
|
||||||
class TenantDiagnostics:
|
class TenantDiagnostics:
|
||||||
tenant: str
|
tenant: str
|
||||||
@@ -612,6 +631,334 @@ class UserEngineService:
|
|||||||
pending_session_count=pending_count,
|
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(
|
def me(
|
||||||
self,
|
self,
|
||||||
claims: Mapping[str, Any],
|
claims: Mapping[str, Any],
|
||||||
@@ -1749,6 +2096,54 @@ class UserEngineService:
|
|||||||
)
|
)
|
||||||
return updated
|
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(
|
def _require_registration_session(
|
||||||
self, registration_id: str
|
self, registration_id: str
|
||||||
) -> RegistrationSession:
|
) -> RegistrationSession:
|
||||||
@@ -1770,6 +2165,229 @@ class UserEngineService:
|
|||||||
raise ValidationError("factor verifier adapter is required")
|
raise ValidationError("factor verifier adapter is required")
|
||||||
return self.factor_verifier.normalize(verification)
|
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(
|
def _ensure_actor_session(
|
||||||
self, actor: Actor, correlation_id: str
|
self, actor: Actor, correlation_id: str
|
||||||
) -> UserSession:
|
) -> UserSession:
|
||||||
@@ -2595,6 +3213,43 @@ def _registration_primary_email(
|
|||||||
return _optional_claim(actor, "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:
|
def _scope_concept(scope_type: str) -> str:
|
||||||
concepts = {
|
concepts = {
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class _ProtocolOnlyStore:
|
|||||||
"identity_factors",
|
"identity_factors",
|
||||||
"memberships",
|
"memberships",
|
||||||
"outbox_events",
|
"outbox_events",
|
||||||
|
"prepared_accounts",
|
||||||
"profile_values",
|
"profile_values",
|
||||||
"registration_sessions",
|
"registration_sessions",
|
||||||
"tenant_accounts",
|
"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"
|
title: "Prepared Accounts And Entitlement Claims"
|
||||||
domain: netkingdom
|
domain: netkingdom
|
||||||
repo: user-engine
|
repo: user-engine
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -45,7 +45,7 @@ history.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T1
|
id: USER-WP-0011-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "11508f77-170b-4b22-bfdc-115a69bfe4db"
|
state_hub_task_id: "11508f77-170b-4b22-bfdc-115a69bfe4db"
|
||||||
```
|
```
|
||||||
@@ -56,7 +56,7 @@ audit metadata.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T2
|
id: USER-WP-0011-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "86ca36d4-721b-48fe-8c0c-c6a1e6740d2f"
|
state_hub_task_id: "86ca36d4-721b-48fe-8c0c-c6a1e6740d2f"
|
||||||
```
|
```
|
||||||
@@ -66,7 +66,7 @@ accounts, guarded by the authorization port.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T3
|
id: USER-WP-0011-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fe5a08e8-1101-4cec-b02f-b2eee8928604"
|
state_hub_task_id: "fe5a08e8-1101-4cec-b02f-b2eee8928604"
|
||||||
```
|
```
|
||||||
@@ -76,7 +76,7 @@ prepared account requirements and produce explicit claim decisions.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T4
|
id: USER-WP-0011-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8aef6d9e-5e76-4e44-bf81-58049b22a25c"
|
state_hub_task_id: "8aef6d9e-5e76-4e44-bf81-58049b22a25c"
|
||||||
```
|
```
|
||||||
@@ -87,7 +87,7 @@ onboarding journey starts.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T5
|
id: USER-WP-0011-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "527519a1-48ed-45fc-a6fc-739986ae6303"
|
state_hub_task_id: "527519a1-48ed-45fc-a6fc-739986ae6303"
|
||||||
```
|
```
|
||||||
@@ -97,7 +97,7 @@ matches, expired packages, privileged roles, and manual approval requirements.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0011-T6
|
id: USER-WP-0011-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "9530c8d6-82af-4635-8af8-aa79c54be94d"
|
state_hub_task_id: "9530c8d6-82af-4635-8af8-aa79c54be94d"
|
||||||
```
|
```
|
||||||
@@ -122,3 +122,39 @@ activation, denial, expiry, and revocation.
|
|||||||
- Prepared entitlement activation facade.
|
- Prepared entitlement activation facade.
|
||||||
- Claim matching rules and tests.
|
- Claim matching rules and tests.
|
||||||
- Documentation for account preparation boundaries.
|
- 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