generated from coulomb/repo-seed
feat: implement prepared account claims
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user