feat: implement prepared account claims

This commit is contained in:
2026-06-15 22:37:31 +02:00
parent a36a25898e
commit 97cd03b551
14 changed files with 1376 additions and 13 deletions

View File

@@ -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]

View File

@@ -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",

View File

@@ -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

View File

@@ -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."""

View File

@@ -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",