generated from coulomb/repo-seed
Implement registration identity model
This commit is contained in:
@@ -11,7 +11,7 @@ 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/scenarios.md`,
|
`docs/registration-identity-and-factor-model.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
@@ -58,6 +58,7 @@ truth.
|
|||||||
## Current Planning
|
## Current Planning
|
||||||
|
|
||||||
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` through `USER-WP-0015` are proposed
|
through `USER-WP-0015`. `USER-WP-0010` implements the first headless
|
||||||
future workplans for NetKingdom registration, prepared accounts, hats/access
|
registration and factor-evidence slice; `USER-WP-0011` through `USER-WP-0015`
|
||||||
profiles, onboarding journeys, optional UI, and security conformance.
|
remain proposed future workplans for prepared accounts, hats/access profiles,
|
||||||
|
onboarding journeys, optional UI, and security conformance.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
HTTP or RPC adapters should preserve these operation names:
|
HTTP or RPC adapters should preserve these operation names:
|
||||||
|
|
||||||
- `health`, `readiness`, `operability_snapshot`, `outbox_diagnostics`
|
- `health`, `readiness`, `operability_snapshot`, `outbox_diagnostics`
|
||||||
|
- `start_registration`, `attach_registration_factor`, `complete_registration`,
|
||||||
|
`abandon_registration`, `expire_registration`, `resume_registration`,
|
||||||
|
`registration_diagnostics`
|
||||||
- `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`
|
||||||
@@ -16,6 +19,27 @@ HTTP or RPC adapters should preserve these operation names:
|
|||||||
`accept_family_invitation`
|
`accept_family_invitation`
|
||||||
- `audit_records`, `outbox_events`
|
- `audit_records`, `outbox_events`
|
||||||
|
|
||||||
|
## Registration Contract
|
||||||
|
|
||||||
|
Registration is a headless user-entry facade. It creates a
|
||||||
|
`RegistrationSession`, accepts safe `FactorVerification` evidence from external
|
||||||
|
proofing adapters, records persisted `IdentityFactor` metadata, and completes
|
||||||
|
the session into a stable NetKingdom ID.
|
||||||
|
|
||||||
|
The first NetKingdom ID contract is `User.user_id`: an opaque, stable user
|
||||||
|
identifier that must not encode IAM issuer/subject pairs, email addresses,
|
||||||
|
phone numbers, postal addresses, eID payloads, tenant names, or other proofing
|
||||||
|
data.
|
||||||
|
|
||||||
|
Registration completion creates or resolves a `User`, `Account`,
|
||||||
|
`TenantAccount`, and `ExternalIdentity` link for the verified actor, attaches
|
||||||
|
verified factors to that user, emits audit/outbox records, and returns
|
||||||
|
`identity_context`.
|
||||||
|
|
||||||
|
user-engine does not verify factors itself, issue credentials, perform MFA,
|
||||||
|
run eID proofing, or issue tokens. Those remain external IAM/proofing adapter
|
||||||
|
responsibilities.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
116
docs/registration-identity-and-factor-model.md
Normal file
116
docs/registration-identity-and-factor-model.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Registration Identity And Factor Model
|
||||||
|
|
||||||
|
Status: implemented headless slice
|
||||||
|
Date: 2026-06-15
|
||||||
|
Related workplan: USER-WP-0010
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the first NetKingdom registration slice in
|
||||||
|
`user-engine`. The slice lets a caller start a registration session, attach
|
||||||
|
externally verified identity-factor evidence, complete registration into a
|
||||||
|
stable NetKingdom user/account, and inspect safe diagnostics.
|
||||||
|
|
||||||
|
The design keeps user-engine in its identity-domain lane:
|
||||||
|
|
||||||
|
- IAM and proofing providers verify credentials, email, phone, eID, invite, or
|
||||||
|
SSO evidence.
|
||||||
|
- user-engine stores the registration session, normalized factor metadata,
|
||||||
|
user/account records, external identity links, audit records, and outbox
|
||||||
|
events.
|
||||||
|
- Authorization systems continue to make final policy and ACL decisions.
|
||||||
|
|
||||||
|
## NetKingdom ID
|
||||||
|
|
||||||
|
For this slice, the NetKingdom ID is the existing stable `User.user_id`.
|
||||||
|
|
||||||
|
That choice keeps the first implementation simple and avoids adding a second
|
||||||
|
identifier before a concrete public-ID requirement exists. Consumers should
|
||||||
|
treat the ID as opaque. It must not encode identity-provider issuer/subject
|
||||||
|
pairs, factor values, tenant names, email addresses, phone numbers, or eID
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
If NetKingdom later needs a public alias, vanity handle, or migration-safe
|
||||||
|
external identifier, that can be added as a separate mapped identifier without
|
||||||
|
changing the registration facade's `netkingdom_id` contract.
|
||||||
|
|
||||||
|
## Domain Model
|
||||||
|
|
||||||
|
`RegistrationSession` tracks the workflow:
|
||||||
|
|
||||||
|
- `started`
|
||||||
|
- `factor_pending`
|
||||||
|
- `factor_verified`
|
||||||
|
- `completed`
|
||||||
|
- `abandoned`
|
||||||
|
- `expired`
|
||||||
|
- `rejected`
|
||||||
|
|
||||||
|
`FactorVerification` is adapter output from an external proofing system. It is
|
||||||
|
safe metadata: factor type, normalized value, optional display value, source
|
||||||
|
system, assurance metadata, evidence references, verification time, and
|
||||||
|
optional expiry.
|
||||||
|
|
||||||
|
`IdentityFactor` is the persisted user-engine record. During registration it
|
||||||
|
is attached to a registration session. After completion it is also attached to
|
||||||
|
the canonical user.
|
||||||
|
|
||||||
|
Supported factor types are:
|
||||||
|
|
||||||
|
- `email`
|
||||||
|
- `phone`
|
||||||
|
- `postal_address`
|
||||||
|
- `eid`
|
||||||
|
- `invite`
|
||||||
|
- `sso`
|
||||||
|
|
||||||
|
## Public Facade
|
||||||
|
|
||||||
|
`UserEngineService` exposes:
|
||||||
|
|
||||||
|
- `start_registration(...)`
|
||||||
|
- `attach_registration_factor(...)`
|
||||||
|
- `complete_registration(...)`
|
||||||
|
- `abandon_registration(...)`
|
||||||
|
- `expire_registration(...)`
|
||||||
|
- `resume_registration(...)`
|
||||||
|
- `registration_diagnostics(...)`
|
||||||
|
|
||||||
|
The completion result includes the completed session, user, account,
|
||||||
|
`netkingdom_id`, and `identity_context`.
|
||||||
|
|
||||||
|
## Factor Adapter Boundary
|
||||||
|
|
||||||
|
`FactorVerificationAdapter` normalizes external proofing results into
|
||||||
|
`FactorVerification`. A caller may also pass an already-normalized
|
||||||
|
`FactorVerification` directly.
|
||||||
|
|
||||||
|
Adapters must strip secret challenge material, proofing payloads, provider
|
||||||
|
tokens, and raw documents before data reaches user-engine.
|
||||||
|
|
||||||
|
## Audit, Outbox, And Redaction
|
||||||
|
|
||||||
|
Registration mutations emit local audit records and outbox events:
|
||||||
|
|
||||||
|
- `registration.started`
|
||||||
|
- `registration.factor_verified`
|
||||||
|
- `registration.completed`
|
||||||
|
- `registration.abandoned`
|
||||||
|
- `registration.expired`
|
||||||
|
|
||||||
|
Outbox payloads include registration ids, factor ids, factor types, source
|
||||||
|
systems, status, and user ids. They deliberately do not include normalized
|
||||||
|
factor values such as email addresses, phone numbers, postal addresses, or eID
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
Diagnostics report counts by status and total verified factors. They do not
|
||||||
|
return factor values.
|
||||||
|
|
||||||
|
## Current Limits
|
||||||
|
|
||||||
|
- Prepared account claiming is intentionally left to USER-WP-0011.
|
||||||
|
- Hats, realms, services, assets, and access profiles are left to
|
||||||
|
USER-WP-0012.
|
||||||
|
- Welcome protocols and onboarding journeys are left to USER-WP-0013.
|
||||||
|
- Registration UI is left to USER-WP-0014.
|
||||||
|
- Provider-backed proofing and credential flows remain external adapters.
|
||||||
@@ -18,10 +18,12 @@ from user_engine.domain import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
FamilyInvitation,
|
FamilyInvitation,
|
||||||
|
IdentityFactor,
|
||||||
Membership,
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
|
RegistrationSession,
|
||||||
TenantAccount,
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -48,6 +50,10 @@ class InMemoryUserEngineStore:
|
|||||||
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
||||||
catalogs: dict[str, Catalog] = field(default_factory=dict)
|
catalogs: dict[str, Catalog] = field(default_factory=dict)
|
||||||
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
|
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
|
||||||
|
registration_sessions: dict[str, RegistrationSession] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
identity_factors: dict[str, IdentityFactor] = 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)
|
||||||
@@ -142,6 +148,39 @@ class InMemoryUserEngineStore:
|
|||||||
if invitation.user_id == user_id
|
if invitation.user_id == user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_registration_session(self, session: RegistrationSession) -> None:
|
||||||
|
self.registration_sessions[session.registration_id] = session
|
||||||
|
|
||||||
|
def registration_session(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> RegistrationSession | None:
|
||||||
|
return self.registration_sessions.get(registration_id)
|
||||||
|
|
||||||
|
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
|
||||||
|
return tuple(self.registration_sessions.values())
|
||||||
|
|
||||||
|
def save_identity_factor(self, factor: IdentityFactor) -> None:
|
||||||
|
self.identity_factors[factor.factor_id] = factor
|
||||||
|
|
||||||
|
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
|
||||||
|
return self.identity_factors.get(factor_id)
|
||||||
|
|
||||||
|
def factors_for_registration(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> tuple[IdentityFactor, ...]:
|
||||||
|
return tuple(
|
||||||
|
factor
|
||||||
|
for factor in self.identity_factors.values()
|
||||||
|
if factor.registration_id == registration_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
||||||
|
return tuple(
|
||||||
|
factor
|
||||||
|
for factor in self.identity_factors.values()
|
||||||
|
if factor.user_id == user_id
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
@@ -199,6 +238,8 @@ class InMemoryUserEngineStore:
|
|||||||
"applications": len(self.applications),
|
"applications": len(self.applications),
|
||||||
"catalogs": len(self.catalogs),
|
"catalogs": len(self.catalogs),
|
||||||
"family_invitations": len(self.family_invitations),
|
"family_invitations": len(self.family_invitations),
|
||||||
|
"registration_sessions": len(self.registration_sessions),
|
||||||
|
"identity_factors": len(self.identity_factors),
|
||||||
"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),
|
||||||
@@ -215,6 +256,8 @@ class InMemoryUserEngineStore:
|
|||||||
"bindings": copy.deepcopy(self.bindings),
|
"bindings": copy.deepcopy(self.bindings),
|
||||||
"catalogs": copy.deepcopy(self.catalogs),
|
"catalogs": copy.deepcopy(self.catalogs),
|
||||||
"family_invitations": copy.deepcopy(self.family_invitations),
|
"family_invitations": copy.deepcopy(self.family_invitations),
|
||||||
|
"registration_sessions": copy.deepcopy(self.registration_sessions),
|
||||||
|
"identity_factors": copy.deepcopy(self.identity_factors),
|
||||||
"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),
|
||||||
@@ -238,6 +281,10 @@ class InMemoryUserEngineStore:
|
|||||||
self.family_invitations = snapshot[
|
self.family_invitations = snapshot[
|
||||||
"family_invitations"
|
"family_invitations"
|
||||||
] # type: ignore[assignment]
|
] # type: ignore[assignment]
|
||||||
|
self.registration_sessions = snapshot[
|
||||||
|
"registration_sessions"
|
||||||
|
] # type: ignore[assignment]
|
||||||
|
self.identity_factors = snapshot["identity_factors"] # 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]
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ from user_engine.domain.models import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
CatalogLifecycle,
|
CatalogLifecycle,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FactorVerification,
|
||||||
FamilyDataspaceRequest,
|
FamilyDataspaceRequest,
|
||||||
FamilyInvitation,
|
FamilyInvitation,
|
||||||
FamilyMemberSpec,
|
FamilyMemberSpec,
|
||||||
FamilyRole,
|
FamilyRole,
|
||||||
|
IdentityFactor,
|
||||||
|
IdentityFactorType,
|
||||||
InvitationStatus,
|
InvitationStatus,
|
||||||
ManagementMode,
|
ManagementMode,
|
||||||
Membership,
|
Membership,
|
||||||
@@ -29,6 +32,8 @@ from user_engine.domain.models import (
|
|||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
|
RegistrationSession,
|
||||||
|
RegistrationStatus,
|
||||||
Sensitivity,
|
Sensitivity,
|
||||||
TenantAccount,
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
@@ -53,10 +58,13 @@ __all__ = [
|
|||||||
"Catalog",
|
"Catalog",
|
||||||
"CatalogLifecycle",
|
"CatalogLifecycle",
|
||||||
"ExternalIdentity",
|
"ExternalIdentity",
|
||||||
|
"FactorVerification",
|
||||||
"FamilyDataspaceRequest",
|
"FamilyDataspaceRequest",
|
||||||
"FamilyInvitation",
|
"FamilyInvitation",
|
||||||
"FamilyMemberSpec",
|
"FamilyMemberSpec",
|
||||||
"FamilyRole",
|
"FamilyRole",
|
||||||
|
"IdentityFactor",
|
||||||
|
"IdentityFactorType",
|
||||||
"InvitationStatus",
|
"InvitationStatus",
|
||||||
"ManagementMode",
|
"ManagementMode",
|
||||||
"Membership",
|
"Membership",
|
||||||
@@ -66,6 +74,8 @@ __all__ = [
|
|||||||
"ProfileScope",
|
"ProfileScope",
|
||||||
"ProfileValue",
|
"ProfileValue",
|
||||||
"ProjectionType",
|
"ProjectionType",
|
||||||
|
"RegistrationSession",
|
||||||
|
"RegistrationStatus",
|
||||||
"Sensitivity",
|
"Sensitivity",
|
||||||
"TenantAccount",
|
"TenantAccount",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ class InvitationStatus(StrEnum):
|
|||||||
REVOKED = "revoked"
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationStatus(StrEnum):
|
||||||
|
STARTED = "started"
|
||||||
|
FACTOR_PENDING = "factor_pending"
|
||||||
|
FACTOR_VERIFIED = "factor_verified"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
ABANDONED = "abandoned"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityFactorType(StrEnum):
|
||||||
|
EMAIL = "email"
|
||||||
|
PHONE = "phone"
|
||||||
|
POSTAL_ADDRESS = "postal_address"
|
||||||
|
EID = "eid"
|
||||||
|
INVITE = "invite"
|
||||||
|
SSO = "sso"
|
||||||
|
|
||||||
|
|
||||||
class ProfileScope(StrEnum):
|
class ProfileScope(StrEnum):
|
||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
TENANT = "tenant"
|
TENANT = "tenant"
|
||||||
@@ -313,6 +332,53 @@ class FamilyInvitation:
|
|||||||
revoked_at: datetime | None = None
|
revoked_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FactorVerification:
|
||||||
|
factor_type: IdentityFactorType
|
||||||
|
normalized_value: str
|
||||||
|
verification_id: str = field(default_factory=lambda: new_id("fvr"))
|
||||||
|
display_value: str | None = None
|
||||||
|
source_system: str = "external-proofing"
|
||||||
|
assurance: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
evidence_refs: tuple[CanonEntityReference, ...] = ()
|
||||||
|
verified_at: datetime = field(default_factory=utc_now)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IdentityFactor:
|
||||||
|
factor_type: IdentityFactorType
|
||||||
|
normalized_value: str
|
||||||
|
factor_id: str = field(default_factory=lambda: new_id("fac"))
|
||||||
|
registration_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
display_value: str | None = None
|
||||||
|
source_system: str = "external-proofing"
|
||||||
|
assurance: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
evidence_refs: tuple[CanonEntityReference, ...] = ()
|
||||||
|
verified_at: datetime = field(default_factory=utc_now)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistrationSession:
|
||||||
|
tenant: str
|
||||||
|
registration_id: str = field(default_factory=lambda: new_id("reg"))
|
||||||
|
status: RegistrationStatus = RegistrationStatus.STARTED
|
||||||
|
required_factor_types: tuple[IdentityFactorType, ...] = ()
|
||||||
|
verified_factor_ids: tuple[str, ...] = ()
|
||||||
|
user_id: str | None = None
|
||||||
|
netkingdom_id: str | None = None
|
||||||
|
started_by_subject: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
created_at: datetime = field(default_factory=utc_now)
|
||||||
|
updated_at: datetime = field(default_factory=utc_now)
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
abandoned_at: datetime | None = None
|
||||||
|
expired_at: datetime | None = None
|
||||||
|
rejected_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuthorizationRequest:
|
class AuthorizationRequest:
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ from user_engine.domain import (
|
|||||||
CanonEntityReference,
|
CanonEntityReference,
|
||||||
Catalog,
|
Catalog,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FactorVerification,
|
||||||
FamilyInvitation,
|
FamilyInvitation,
|
||||||
|
IdentityFactor,
|
||||||
Membership,
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
|
RegistrationSession,
|
||||||
TenantAccount,
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -120,6 +123,31 @@ class UserEngineStore(Protocol):
|
|||||||
) -> tuple[FamilyInvitation, ...]:
|
) -> tuple[FamilyInvitation, ...]:
|
||||||
"""Return family invitations for a user."""
|
"""Return family invitations for a user."""
|
||||||
|
|
||||||
|
def save_registration_session(self, session: RegistrationSession) -> None:
|
||||||
|
"""Create or replace a registration session."""
|
||||||
|
|
||||||
|
def registration_session(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> RegistrationSession | None:
|
||||||
|
"""Return a registration session by id."""
|
||||||
|
|
||||||
|
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
|
||||||
|
"""Return all registration sessions."""
|
||||||
|
|
||||||
|
def save_identity_factor(self, factor: IdentityFactor) -> None:
|
||||||
|
"""Create or replace a verified identity factor."""
|
||||||
|
|
||||||
|
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
|
||||||
|
"""Return a verified identity factor by id."""
|
||||||
|
|
||||||
|
def factors_for_registration(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> tuple[IdentityFactor, ...]:
|
||||||
|
"""Return verified factors attached to a registration session."""
|
||||||
|
|
||||||
|
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
|
||||||
|
"""Return verified factors attached to a user."""
|
||||||
|
|
||||||
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."""
|
||||||
|
|
||||||
@@ -152,6 +180,13 @@ class IdentityClaimsAdapter(Protocol):
|
|||||||
"""Return the stable external identity link key."""
|
"""Return the stable external identity link key."""
|
||||||
|
|
||||||
|
|
||||||
|
class FactorVerificationAdapter(Protocol):
|
||||||
|
"""Normalize external proofing results into safe factor evidence."""
|
||||||
|
|
||||||
|
def normalize(self, proofing_result: Mapping[str, Any]) -> FactorVerification:
|
||||||
|
"""Return normalized verified factor evidence without secret payloads."""
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationCheckPort(Protocol):
|
class AuthorizationCheckPort(Protocol):
|
||||||
"""Ask whether an actor may perform an action."""
|
"""Ask whether an actor may perform an action."""
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from user_engine.domain import (
|
from user_engine.domain import (
|
||||||
Account,
|
Account,
|
||||||
@@ -19,10 +19,13 @@ from user_engine.domain import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
CatalogLifecycle,
|
CatalogLifecycle,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FactorVerification,
|
||||||
FamilyDataspaceRequest,
|
FamilyDataspaceRequest,
|
||||||
FamilyInvitation,
|
FamilyInvitation,
|
||||||
FamilyMemberSpec,
|
FamilyMemberSpec,
|
||||||
FamilyRole,
|
FamilyRole,
|
||||||
|
IdentityFactor,
|
||||||
|
IdentityFactorType,
|
||||||
InvitationStatus,
|
InvitationStatus,
|
||||||
Membership,
|
Membership,
|
||||||
Mutability,
|
Mutability,
|
||||||
@@ -30,6 +33,8 @@ from user_engine.domain import (
|
|||||||
ProfileScope,
|
ProfileScope,
|
||||||
ProfileValue,
|
ProfileValue,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
|
RegistrationSession,
|
||||||
|
RegistrationStatus,
|
||||||
Sensitivity,
|
Sensitivity,
|
||||||
TenantAccount,
|
TenantAccount,
|
||||||
User,
|
User,
|
||||||
@@ -45,6 +50,7 @@ from user_engine.errors import (
|
|||||||
)
|
)
|
||||||
from user_engine.ports import (
|
from user_engine.ports import (
|
||||||
AuthorizationCheckPort,
|
AuthorizationCheckPort,
|
||||||
|
FactorVerificationAdapter,
|
||||||
IdentityClaimsAdapter,
|
IdentityClaimsAdapter,
|
||||||
UserEngineStore,
|
UserEngineStore,
|
||||||
)
|
)
|
||||||
@@ -53,6 +59,12 @@ REDACTED = "<redacted>"
|
|||||||
PLATFORM_TENANT = "platform:root"
|
PLATFORM_TENANT = "platform:root"
|
||||||
PLATFORM_OPERATOR_ROLE = "platform-operator"
|
PLATFORM_OPERATOR_ROLE = "platform-operator"
|
||||||
TENANT_ADMIN_ROLE = "tenant-admin"
|
TENANT_ADMIN_ROLE = "tenant-admin"
|
||||||
|
_TERMINAL_REGISTRATION_STATUSES = {
|
||||||
|
RegistrationStatus.COMPLETED,
|
||||||
|
RegistrationStatus.ABANDONED,
|
||||||
|
RegistrationStatus.EXPIRED,
|
||||||
|
RegistrationStatus.REJECTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -148,6 +160,15 @@ class FamilyInvitationAcceptance:
|
|||||||
claims_projection: Projection
|
claims_projection: Projection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistrationCompletion:
|
||||||
|
session: RegistrationSession
|
||||||
|
user: User
|
||||||
|
account: Account
|
||||||
|
netkingdom_id: str
|
||||||
|
identity_context: IdentityContext
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TenantDiagnostics:
|
class TenantDiagnostics:
|
||||||
tenant: str
|
tenant: str
|
||||||
@@ -163,6 +184,15 @@ class OutboxDiagnostics:
|
|||||||
oldest_correlation_id: str | None
|
oldest_correlation_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegistrationDiagnostics:
|
||||||
|
tenant: str
|
||||||
|
total_sessions: int
|
||||||
|
statuses: Mapping[str, int]
|
||||||
|
verified_factor_count: int
|
||||||
|
pending_session_count: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OperabilitySnapshot:
|
class OperabilitySnapshot:
|
||||||
ready: bool
|
ready: bool
|
||||||
@@ -180,10 +210,12 @@ class UserEngineService:
|
|||||||
store: UserEngineStore,
|
store: UserEngineStore,
|
||||||
identity_adapter: IdentityClaimsAdapter,
|
identity_adapter: IdentityClaimsAdapter,
|
||||||
authorization: AuthorizationCheckPort,
|
authorization: AuthorizationCheckPort,
|
||||||
|
factor_verifier: FactorVerificationAdapter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.identity_adapter = identity_adapter
|
self.identity_adapter = identity_adapter
|
||||||
self.authorization = authorization
|
self.authorization = authorization
|
||||||
|
self.factor_verifier = factor_verifier
|
||||||
if not self.store.ready:
|
if not self.store.ready:
|
||||||
self.store.migrate()
|
self.store.migrate()
|
||||||
|
|
||||||
@@ -217,6 +249,369 @@ class UserEngineService:
|
|||||||
platform_operator=platform_operator,
|
platform_operator=platform_operator,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def start_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
*,
|
||||||
|
tenant: str | None = None,
|
||||||
|
required_factor_types: Iterable[IdentityFactorType | str] = (
|
||||||
|
IdentityFactorType.EMAIL,
|
||||||
|
),
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
required = tuple(_factor_type(item) for item in required_factor_types)
|
||||||
|
decision = self._authorize(
|
||||||
|
actor,
|
||||||
|
action="registration.start",
|
||||||
|
resource_type="user-engine:registration",
|
||||||
|
resource_id="new",
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
context={"required_factor_types": tuple(item.value for item in required)},
|
||||||
|
)
|
||||||
|
session = RegistrationSession(
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
status=(
|
||||||
|
RegistrationStatus.FACTOR_PENDING
|
||||||
|
if required
|
||||||
|
else RegistrationStatus.STARTED
|
||||||
|
),
|
||||||
|
required_factor_types=required,
|
||||||
|
started_by_subject=actor.subject,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
with self.store.transaction():
|
||||||
|
self.store.save_registration_session(session)
|
||||||
|
self._record_mutation(
|
||||||
|
actor,
|
||||||
|
action="registration.start",
|
||||||
|
subject=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
decision_id=decision.decision_id,
|
||||||
|
event_type="registration.started",
|
||||||
|
aggregate_id=session.registration_id,
|
||||||
|
payload={
|
||||||
|
"registration_id": session.registration_id,
|
||||||
|
"status": session.status,
|
||||||
|
"required_factor_types": tuple(
|
||||||
|
item.value for item in session.required_factor_types
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def attach_registration_factor(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
verification: FactorVerification | Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
session = self._require_registration_session(registration_id)
|
||||||
|
self._ensure_registration_mutable(session)
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, session.tenant)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
verified = self._normalize_factor_verification(verification)
|
||||||
|
if not verified.normalized_value:
|
||||||
|
raise ValidationError("verified factor normalized_value is required")
|
||||||
|
decision = self._authorize(
|
||||||
|
actor,
|
||||||
|
action="registration.factor.write",
|
||||||
|
resource_type="user-engine:registration-factor",
|
||||||
|
resource_id=f"{registration_id}:{verified.factor_type.value}",
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
context={
|
||||||
|
"registration_id": registration_id,
|
||||||
|
"factor_type": verified.factor_type.value,
|
||||||
|
"source_system": verified.source_system,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
factor = IdentityFactor(
|
||||||
|
factor_type=verified.factor_type,
|
||||||
|
normalized_value=verified.normalized_value,
|
||||||
|
registration_id=session.registration_id,
|
||||||
|
display_value=verified.display_value,
|
||||||
|
source_system=verified.source_system,
|
||||||
|
assurance=dict(verified.assurance),
|
||||||
|
evidence_refs=verified.evidence_refs,
|
||||||
|
verified_at=verified.verified_at,
|
||||||
|
expires_at=verified.expires_at,
|
||||||
|
)
|
||||||
|
current_factor_ids = tuple(
|
||||||
|
factor.factor_id for factor in self.store.factors_for_registration(
|
||||||
|
session.registration_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
verified_factor_ids = (*current_factor_ids, factor.factor_id)
|
||||||
|
all_factor_types = {
|
||||||
|
item.factor_type
|
||||||
|
for item in self.store.factors_for_registration(session.registration_id)
|
||||||
|
}
|
||||||
|
all_factor_types.add(factor.factor_type)
|
||||||
|
next_status = (
|
||||||
|
RegistrationStatus.FACTOR_VERIFIED
|
||||||
|
if set(session.required_factor_types).issubset(all_factor_types)
|
||||||
|
else RegistrationStatus.FACTOR_PENDING
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
session,
|
||||||
|
status=next_status,
|
||||||
|
verified_factor_ids=verified_factor_ids,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
with self.store.transaction():
|
||||||
|
self.store.save_identity_factor(factor)
|
||||||
|
self.store.save_registration_session(updated)
|
||||||
|
self._record_mutation(
|
||||||
|
actor,
|
||||||
|
action="registration.factor.write",
|
||||||
|
subject=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
decision_id=decision.decision_id,
|
||||||
|
event_type="registration.factor_verified",
|
||||||
|
aggregate_id=session.registration_id,
|
||||||
|
payload={
|
||||||
|
"registration_id": session.registration_id,
|
||||||
|
"factor_id": factor.factor_id,
|
||||||
|
"factor_type": factor.factor_type,
|
||||||
|
"source_system": factor.source_system,
|
||||||
|
"evidence_ref_count": len(factor.evidence_refs),
|
||||||
|
"status": updated.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
*,
|
||||||
|
display_name: str | None = None,
|
||||||
|
primary_email: str | None = None,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationCompletion:
|
||||||
|
session = self._require_registration_session(registration_id)
|
||||||
|
self._ensure_registration_mutable(session)
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, session.tenant)
|
||||||
|
factors = self.store.factors_for_registration(session.registration_id)
|
||||||
|
missing = _missing_required_factor_types(session, factors)
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(
|
||||||
|
"registration is missing required factors: "
|
||||||
|
+ ", ".join(item.value for item in missing)
|
||||||
|
)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
decision = self._authorize(
|
||||||
|
actor,
|
||||||
|
action="registration.complete",
|
||||||
|
resource_type="user-engine:registration",
|
||||||
|
resource_id=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
context={
|
||||||
|
"factor_types": tuple(
|
||||||
|
sorted({factor.factor_type.value for factor in factors})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
existing_identity = self.store.find_identity(*actor.identity_key)
|
||||||
|
if existing_identity is not None:
|
||||||
|
user = self._require_user(existing_identity.user_id)
|
||||||
|
account = self._require_account(user.user_id)
|
||||||
|
tenant_account = self.store.tenant_account(
|
||||||
|
tenant_context.tenant, user.user_id
|
||||||
|
)
|
||||||
|
identity = existing_identity
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
display_name=display_name or actor.preferred_username,
|
||||||
|
primary_email=primary_email or _registration_primary_email(
|
||||||
|
factors, actor
|
||||||
|
),
|
||||||
|
)
|
||||||
|
account = Account(
|
||||||
|
account_id=new_id("acct"),
|
||||||
|
user_id=user.user_id,
|
||||||
|
status=AccountStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
tenant_account = TenantAccount(
|
||||||
|
user_id=user.user_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
status=AccountStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
identity = ExternalIdentity(
|
||||||
|
identity_id=new_id("idn"),
|
||||||
|
user_id=user.user_id,
|
||||||
|
issuer=actor.issuer,
|
||||||
|
subject=actor.subject,
|
||||||
|
provider=actor.authorized_party,
|
||||||
|
)
|
||||||
|
completed_at = utc_now()
|
||||||
|
completed = replace(
|
||||||
|
session,
|
||||||
|
status=RegistrationStatus.COMPLETED,
|
||||||
|
user_id=user.user_id,
|
||||||
|
netkingdom_id=user.user_id,
|
||||||
|
updated_at=completed_at,
|
||||||
|
completed_at=completed_at,
|
||||||
|
)
|
||||||
|
attached_factors = tuple(
|
||||||
|
replace(factor, user_id=user.user_id) for factor in factors
|
||||||
|
)
|
||||||
|
with self.store.transaction():
|
||||||
|
if existing_identity is None:
|
||||||
|
self.store.save_user(user)
|
||||||
|
self.store.save_account(account)
|
||||||
|
self.store.save_tenant_account(tenant_account)
|
||||||
|
self.store.save_identity(identity)
|
||||||
|
elif tenant_account is None:
|
||||||
|
self.store.save_tenant_account(
|
||||||
|
TenantAccount(
|
||||||
|
user_id=user.user_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
status=AccountStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for factor in attached_factors:
|
||||||
|
self.store.save_identity_factor(factor)
|
||||||
|
self.store.save_registration_session(completed)
|
||||||
|
self._record_mutation(
|
||||||
|
actor,
|
||||||
|
action="registration.complete",
|
||||||
|
subject=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
decision_id=decision.decision_id,
|
||||||
|
event_type="registration.completed",
|
||||||
|
aggregate_id=user.user_id,
|
||||||
|
payload={
|
||||||
|
"registration_id": session.registration_id,
|
||||||
|
"netkingdom_id": user.user_id,
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"factor_types": tuple(
|
||||||
|
sorted({factor.factor_type.value for factor in factors})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
identity_context = self.identity_context(
|
||||||
|
actor,
|
||||||
|
user_id=user.user_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
return RegistrationCompletion(
|
||||||
|
session=completed,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
netkingdom_id=user.user_id,
|
||||||
|
identity_context=identity_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
def abandon_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
return self._close_registration(
|
||||||
|
actor,
|
||||||
|
registration_id,
|
||||||
|
status=RegistrationStatus.ABANDONED,
|
||||||
|
action="registration.abandon",
|
||||||
|
event_type="registration.abandoned",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def expire_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
return self._close_registration(
|
||||||
|
actor,
|
||||||
|
registration_id,
|
||||||
|
status=RegistrationStatus.EXPIRED,
|
||||||
|
action="registration.expire",
|
||||||
|
event_type="registration.expired",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resume_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
session = self._require_registration_session(registration_id)
|
||||||
|
if session.status in _TERMINAL_REGISTRATION_STATUSES:
|
||||||
|
raise ValidationError("terminal registration sessions cannot be resumed")
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, session.tenant)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
self._authorize(
|
||||||
|
actor,
|
||||||
|
action="registration.resume",
|
||||||
|
resource_type="user-engine:registration",
|
||||||
|
resource_id=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def registration_diagnostics(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
*,
|
||||||
|
tenant: str,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> RegistrationDiagnostics:
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
self._authorize(
|
||||||
|
actor,
|
||||||
|
action="registration.diagnostics.read",
|
||||||
|
resource_type="user-engine:registration",
|
||||||
|
resource_id=tenant_context.tenant,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
sessions = tuple(
|
||||||
|
session
|
||||||
|
for session in self.store.all_registration_sessions()
|
||||||
|
if session.tenant == tenant_context.tenant
|
||||||
|
)
|
||||||
|
statuses: dict[str, int] = {}
|
||||||
|
verified_factor_count = 0
|
||||||
|
for session in sessions:
|
||||||
|
statuses[session.status.value] = statuses.get(session.status.value, 0) + 1
|
||||||
|
verified_factor_count += len(
|
||||||
|
self.store.factors_for_registration(session.registration_id)
|
||||||
|
)
|
||||||
|
pending_count = sum(
|
||||||
|
statuses.get(status.value, 0)
|
||||||
|
for status in (
|
||||||
|
RegistrationStatus.STARTED,
|
||||||
|
RegistrationStatus.FACTOR_PENDING,
|
||||||
|
RegistrationStatus.FACTOR_VERIFIED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RegistrationDiagnostics(
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
total_sessions=len(sessions),
|
||||||
|
statuses=statuses,
|
||||||
|
verified_factor_count=verified_factor_count,
|
||||||
|
pending_session_count=pending_count,
|
||||||
|
)
|
||||||
|
|
||||||
def me(
|
def me(
|
||||||
self,
|
self,
|
||||||
claims: Mapping[str, Any],
|
claims: Mapping[str, Any],
|
||||||
@@ -1305,6 +1700,76 @@ class UserEngineService:
|
|||||||
context["actor_subject"] = actor.subject
|
context["actor_subject"] = actor.subject
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def _close_registration(
|
||||||
|
self,
|
||||||
|
actor: Actor,
|
||||||
|
registration_id: str,
|
||||||
|
*,
|
||||||
|
status: RegistrationStatus,
|
||||||
|
action: str,
|
||||||
|
event_type: str,
|
||||||
|
correlation_id: str | None,
|
||||||
|
) -> RegistrationSession:
|
||||||
|
session = self._require_registration_session(registration_id)
|
||||||
|
self._ensure_registration_mutable(session)
|
||||||
|
tenant_context = self.resolve_tenant_context(actor, session.tenant)
|
||||||
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
decision = self._authorize(
|
||||||
|
actor,
|
||||||
|
action=action,
|
||||||
|
resource_type="user-engine:registration",
|
||||||
|
resource_id=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
updated = replace(
|
||||||
|
session,
|
||||||
|
status=status,
|
||||||
|
updated_at=now,
|
||||||
|
abandoned_at=now if status == RegistrationStatus.ABANDONED else None,
|
||||||
|
expired_at=now if status == RegistrationStatus.EXPIRED else None,
|
||||||
|
rejected_at=now if status == RegistrationStatus.REJECTED else None,
|
||||||
|
)
|
||||||
|
with self.store.transaction():
|
||||||
|
self.store.save_registration_session(updated)
|
||||||
|
self._record_mutation(
|
||||||
|
actor,
|
||||||
|
action=action,
|
||||||
|
subject=session.registration_id,
|
||||||
|
tenant=tenant_context.tenant,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
decision_id=decision.decision_id,
|
||||||
|
event_type=event_type,
|
||||||
|
aggregate_id=session.registration_id,
|
||||||
|
payload={
|
||||||
|
"registration_id": session.registration_id,
|
||||||
|
"status": updated.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _require_registration_session(
|
||||||
|
self, registration_id: str
|
||||||
|
) -> RegistrationSession:
|
||||||
|
session = self.store.registration_session(registration_id)
|
||||||
|
if session is None:
|
||||||
|
raise NotFoundError("registration session not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _ensure_registration_mutable(self, session: RegistrationSession) -> None:
|
||||||
|
if session.status in _TERMINAL_REGISTRATION_STATUSES:
|
||||||
|
raise ValidationError("registration session is already terminal")
|
||||||
|
|
||||||
|
def _normalize_factor_verification(
|
||||||
|
self, verification: FactorVerification | Mapping[str, Any]
|
||||||
|
) -> FactorVerification:
|
||||||
|
if isinstance(verification, FactorVerification):
|
||||||
|
return verification
|
||||||
|
if self.factor_verifier is None:
|
||||||
|
raise ValidationError("factor verifier adapter is required")
|
||||||
|
return self.factor_verifier.normalize(verification)
|
||||||
|
|
||||||
def _ensure_actor_session(
|
def _ensure_actor_session(
|
||||||
self, actor: Actor, correlation_id: str
|
self, actor: Actor, correlation_id: str
|
||||||
) -> UserSession:
|
) -> UserSession:
|
||||||
@@ -2103,6 +2568,33 @@ def _optional_claim(actor: Actor, key: str) -> str | None:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _factor_type(value: IdentityFactorType | str) -> IdentityFactorType:
|
||||||
|
try:
|
||||||
|
return IdentityFactorType(str(value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError(f"unsupported identity factor type: {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_required_factor_types(
|
||||||
|
session: RegistrationSession, factors: Iterable[IdentityFactor]
|
||||||
|
) -> tuple[IdentityFactorType, ...]:
|
||||||
|
verified = {factor.factor_type for factor in factors}
|
||||||
|
return tuple(
|
||||||
|
factor_type
|
||||||
|
for factor_type in session.required_factor_types
|
||||||
|
if factor_type not in verified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_primary_email(
|
||||||
|
factors: Iterable[IdentityFactor], actor: Actor
|
||||||
|
) -> str | None:
|
||||||
|
for factor in factors:
|
||||||
|
if factor.factor_type == IdentityFactorType.EMAIL:
|
||||||
|
return factor.display_value or factor.normalized_value
|
||||||
|
return _optional_claim(actor, "email")
|
||||||
|
|
||||||
|
|
||||||
def _scope_concept(scope_type: str) -> str:
|
def _scope_concept(scope_type: str) -> str:
|
||||||
concepts = {
|
concepts = {
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ class _ProtocolOnlyStore:
|
|||||||
"catalogs",
|
"catalogs",
|
||||||
"family_invitations",
|
"family_invitations",
|
||||||
"identities",
|
"identities",
|
||||||
|
"identity_factors",
|
||||||
"memberships",
|
"memberships",
|
||||||
"outbox_events",
|
"outbox_events",
|
||||||
"profile_values",
|
"profile_values",
|
||||||
|
"registration_sessions",
|
||||||
"tenant_accounts",
|
"tenant_accounts",
|
||||||
"users",
|
"users",
|
||||||
}
|
}
|
||||||
|
|||||||
175
tests/test_registration_identity.py
Normal file
175
tests/test_registration_identity.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
|
||||||
|
from user_engine.domain import (
|
||||||
|
CanonEntityReference,
|
||||||
|
FactorVerification,
|
||||||
|
IdentityFactorType,
|
||||||
|
RegistrationStatus,
|
||||||
|
)
|
||||||
|
from user_engine.errors import ValidationError
|
||||||
|
from user_engine.service import UserEngineService
|
||||||
|
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationIdentityTests(unittest.TestCase):
|
||||||
|
def test_registration_with_verified_email_creates_netkingdom_id(self):
|
||||||
|
service, store = _service()
|
||||||
|
actor = _actor()
|
||||||
|
|
||||||
|
session = service.start_registration(actor, correlation_id="corr-start")
|
||||||
|
verified = service.attach_registration_factor(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
_verified_email(),
|
||||||
|
correlation_id="corr-factor",
|
||||||
|
)
|
||||||
|
completion = service.complete_registration(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
correlation_id="corr-complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(verified.status, RegistrationStatus.FACTOR_VERIFIED)
|
||||||
|
self.assertEqual(completion.session.status, RegistrationStatus.COMPLETED)
|
||||||
|
self.assertEqual(completion.netkingdom_id, completion.user.user_id)
|
||||||
|
self.assertEqual(completion.session.netkingdom_id, completion.user.user_id)
|
||||||
|
self.assertEqual(completion.user.primary_email, "sample.user@example.test")
|
||||||
|
self.assertEqual(completion.identity_context.user.user_id, completion.user.user_id)
|
||||||
|
self.assertEqual(store.find_identity(*actor.identity_key).user_id, completion.user.user_id)
|
||||||
|
self.assertEqual(
|
||||||
|
store.factors_for_user(completion.user.user_id)[0].factor_type,
|
||||||
|
IdentityFactorType.EMAIL,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"sample.user@example.test",
|
||||||
|
repr([event.payload for event in service.outbox_events()]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registration_requires_all_required_factors_before_completion(self):
|
||||||
|
service, store = _service()
|
||||||
|
actor = _actor()
|
||||||
|
session = service.start_registration(
|
||||||
|
actor,
|
||||||
|
required_factor_types=(IdentityFactorType.EID,),
|
||||||
|
correlation_id="corr-start",
|
||||||
|
)
|
||||||
|
service.attach_registration_factor(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
_verified_email(),
|
||||||
|
correlation_id="corr-factor",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
service.complete_registration(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
correlation_id="corr-complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(store.record_counts()["users"], 0)
|
||||||
|
self.assertIsNone(store.registration_session(session.registration_id).user_id)
|
||||||
|
|
||||||
|
def test_factor_verifier_adapter_normalizes_external_proofing_result(self):
|
||||||
|
service, store = _service(factor_verifier=_FixtureFactorVerifier())
|
||||||
|
actor = _actor()
|
||||||
|
session = service.start_registration(actor, correlation_id="corr-start")
|
||||||
|
|
||||||
|
updated = service.attach_registration_factor(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"value": "Sample.User@Example.Test",
|
||||||
|
"secret_challenge": "do-not-store-this",
|
||||||
|
},
|
||||||
|
correlation_id="corr-factor",
|
||||||
|
)
|
||||||
|
stored = store.factors_for_registration(session.registration_id)[0]
|
||||||
|
|
||||||
|
self.assertEqual(updated.status, RegistrationStatus.FACTOR_VERIFIED)
|
||||||
|
self.assertEqual(stored.normalized_value, "sample.user@example.test")
|
||||||
|
self.assertNotIn("do-not-store-this", repr(stored))
|
||||||
|
self.assertNotIn(
|
||||||
|
"do-not-store-this",
|
||||||
|
repr([event.payload for event in service.outbox_events()]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_abandoned_registration_cannot_resume_or_complete(self):
|
||||||
|
service, _ = _service()
|
||||||
|
actor = _actor()
|
||||||
|
session = service.start_registration(actor, correlation_id="corr-start")
|
||||||
|
|
||||||
|
abandoned = service.abandon_registration(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
correlation_id="corr-abandon",
|
||||||
|
)
|
||||||
|
diagnostics = service.registration_diagnostics(
|
||||||
|
actor,
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr-diagnostics",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(abandoned.status, RegistrationStatus.ABANDONED)
|
||||||
|
self.assertEqual(diagnostics.statuses[RegistrationStatus.ABANDONED.value], 1)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
service.resume_registration(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
correlation_id="corr-resume",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
service.complete_registration(
|
||||||
|
actor,
|
||||||
|
session.registration_id,
|
||||||
|
correlation_id="corr-complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(*, factor_verifier=None):
|
||||||
|
store = InMemoryUserEngineStore()
|
||||||
|
service = UserEngineService(
|
||||||
|
store=store,
|
||||||
|
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||||
|
authorization=LocalAuthorizationCheckPort(),
|
||||||
|
factor_verifier=factor_verifier,
|
||||||
|
)
|
||||||
|
return service, store
|
||||||
|
|
||||||
|
|
||||||
|
def _actor():
|
||||||
|
return FixtureIdentityClaimsAdapter().normalize(human_actor_claims())
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_email() -> FactorVerification:
|
||||||
|
return FactorVerification(
|
||||||
|
factor_type=IdentityFactorType.EMAIL,
|
||||||
|
normalized_value="sample.user@example.test",
|
||||||
|
display_value="sample.user@example.test",
|
||||||
|
source_system="fixture-email",
|
||||||
|
assurance={"level": "email_verified"},
|
||||||
|
evidence_refs=(
|
||||||
|
CanonEntityReference(
|
||||||
|
concept="Evidence Source",
|
||||||
|
identifier="fixture-email-proof",
|
||||||
|
source_system="fixture-email",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FixtureFactorVerifier:
|
||||||
|
def normalize(self, proofing_result):
|
||||||
|
return FactorVerification(
|
||||||
|
factor_type=IdentityFactorType(str(proofing_result["type"])),
|
||||||
|
normalized_value=str(proofing_result["value"]).lower(),
|
||||||
|
display_value=str(proofing_result["value"]).lower(),
|
||||||
|
source_system="fixture-proofing",
|
||||||
|
assurance={"level": "email_verified"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Registration Identity And Factor Model"
|
title: "Registration Identity And Factor Model"
|
||||||
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
|
||||||
@@ -46,7 +46,7 @@ lifecycle, sessions, and tokens remain external adapter concerns.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T1
|
id: USER-WP-0010-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2a6c93de-e320-41e6-8930-7a4099c5757a"
|
state_hub_task_id: "2a6c93de-e320-41e6-8930-7a4099c5757a"
|
||||||
```
|
```
|
||||||
@@ -57,7 +57,7 @@ stability, visibility, privacy, and migration expectations.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T2
|
id: USER-WP-0010-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "31ddb44e-b7d1-406e-9114-78c5e7f92478"
|
state_hub_task_id: "31ddb44e-b7d1-406e-9114-78c5e7f92478"
|
||||||
```
|
```
|
||||||
@@ -67,7 +67,7 @@ factor_pending, factor_verified, completed, abandoned, expired, and rejected.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T3
|
id: USER-WP-0010-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7441f064-eb49-4e66-8c1d-a2626aae020c"
|
state_hub_task_id: "7441f064-eb49-4e66-8c1d-a2626aae020c"
|
||||||
```
|
```
|
||||||
@@ -78,7 +78,7 @@ evidence references without storing secret proofing payloads.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T4
|
id: USER-WP-0010-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7057afda-d585-48cd-bac1-f0bd0f05fef5"
|
state_hub_task_id: "7057afda-d585-48cd-bac1-f0bd0f05fef5"
|
||||||
```
|
```
|
||||||
@@ -88,7 +88,7 @@ proofing results and return normalized factor evidence for user-engine.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T5
|
id: USER-WP-0010-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f4f0da38-9810-45e7-ab4e-0619eb45b3c4"
|
state_hub_task_id: "f4f0da38-9810-45e7-ab4e-0619eb45b3c4"
|
||||||
```
|
```
|
||||||
@@ -98,7 +98,7 @@ complete, abandon, and resume flows.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0010-T6
|
id: USER-WP-0010-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c29b31cd-f2b2-41b6-86ee-9c78470abf01"
|
state_hub_task_id: "c29b31cd-f2b2-41b6-86ee-9c78470abf01"
|
||||||
```
|
```
|
||||||
@@ -123,3 +123,35 @@ factor lifecycle transitions.
|
|||||||
- Registration service facade.
|
- Registration service facade.
|
||||||
- Factor verification adapter ports.
|
- Factor verification adapter ports.
|
||||||
- Documentation and tests for the basic self-registration flow.
|
- Documentation and tests for the basic self-registration flow.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Implemented on 2026-06-15:
|
||||||
|
|
||||||
|
- Defined NetKingdom ID semantics as the existing opaque `User.user_id` for
|
||||||
|
this first slice.
|
||||||
|
- Added `RegistrationStatus`, `IdentityFactorType`, `FactorVerification`,
|
||||||
|
`IdentityFactor`, and `RegistrationSession` domain models.
|
||||||
|
- Added registration and factor persistence to `UserEngineStore` and
|
||||||
|
`InMemoryUserEngineStore`.
|
||||||
|
- Added `FactorVerificationAdapter` for normalizing external proofing results
|
||||||
|
into safe factor evidence.
|
||||||
|
- Added `UserEngineService` registration facade methods:
|
||||||
|
`start_registration`, `attach_registration_factor`,
|
||||||
|
`complete_registration`, `abandon_registration`, `expire_registration`,
|
||||||
|
`resume_registration`, and `registration_diagnostics`.
|
||||||
|
- Added audit/outbox events for registration lifecycle transitions while
|
||||||
|
keeping factor values out of event payloads and diagnostics.
|
||||||
|
- Added `docs/registration-identity-and-factor-model.md` and public contract
|
||||||
|
updates.
|
||||||
|
- Added tests for successful email-backed registration, required-factor
|
||||||
|
enforcement, adapter-normalized factor evidence, and abandoned-session
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
make test
|
||||||
|
Ran 46 tests in 0.162s
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user