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/netkingdom-registration-onboarding-vision.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/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
||||
for implementation boundaries, contracts, canon mappings, examples, and release
|
||||
|
||||
7
SCOPE.md
7
SCOPE.md
@@ -58,6 +58,7 @@ truth.
|
||||
## Current Planning
|
||||
|
||||
Implementation and planning work is tracked in `workplans/USER-WP-0001`
|
||||
through `USER-WP-0015`. `USER-WP-0010` through `USER-WP-0015` are proposed
|
||||
future workplans for NetKingdom registration, prepared accounts, hats/access
|
||||
profiles, onboarding journeys, optional UI, and security conformance.
|
||||
through `USER-WP-0015`. `USER-WP-0010` implements the first headless
|
||||
registration and factor-evidence slice; `USER-WP-0011` through `USER-WP-0015`
|
||||
remain proposed future workplans for prepared accounts, hats/access profiles,
|
||||
onboarding journeys, optional UI, and security conformance.
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
HTTP or RPC adapters should preserve these operation names:
|
||||
|
||||
- `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`
|
||||
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
|
||||
`tenant_diagnostics`
|
||||
@@ -16,6 +19,27 @@ HTTP or RPC adapters should preserve these operation names:
|
||||
`accept_family_invitation`
|
||||
- `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` 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,
|
||||
ExternalIdentity,
|
||||
FamilyInvitation,
|
||||
IdentityFactor,
|
||||
Membership,
|
||||
OutboxEvent,
|
||||
ProfileScope,
|
||||
ProfileValue,
|
||||
RegistrationSession,
|
||||
TenantAccount,
|
||||
User,
|
||||
)
|
||||
@@ -48,6 +50,10 @@ class InMemoryUserEngineStore:
|
||||
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
||||
catalogs: dict[str, Catalog] = 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[
|
||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||
] = field(default_factory=dict)
|
||||
@@ -142,6 +148,39 @@ class InMemoryUserEngineStore:
|
||||
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:
|
||||
self.profile_values[
|
||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||
@@ -199,6 +238,8 @@ class InMemoryUserEngineStore:
|
||||
"applications": len(self.applications),
|
||||
"catalogs": len(self.catalogs),
|
||||
"family_invitations": len(self.family_invitations),
|
||||
"registration_sessions": len(self.registration_sessions),
|
||||
"identity_factors": len(self.identity_factors),
|
||||
"profile_values": len(self.profile_values),
|
||||
"audit_records": len(self.audit_records),
|
||||
"pending_outbox_events": len(self.outbox_events),
|
||||
@@ -215,6 +256,8 @@ class InMemoryUserEngineStore:
|
||||
"bindings": copy.deepcopy(self.bindings),
|
||||
"catalogs": copy.deepcopy(self.catalogs),
|
||||
"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),
|
||||
"audit_records": copy.deepcopy(self.audit_records),
|
||||
"outbox_events": copy.deepcopy(self.outbox_events),
|
||||
@@ -238,6 +281,10 @@ class InMemoryUserEngineStore:
|
||||
self.family_invitations = snapshot[
|
||||
"family_invitations"
|
||||
] # 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.audit_records = [*snapshot_audit_records, *denied_audit_records]
|
||||
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]
|
||||
|
||||
@@ -16,10 +16,13 @@ from user_engine.domain.models import (
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
ExternalIdentity,
|
||||
FactorVerification,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyInvitation,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
IdentityFactor,
|
||||
IdentityFactorType,
|
||||
InvitationStatus,
|
||||
ManagementMode,
|
||||
Membership,
|
||||
@@ -29,6 +32,8 @@ from user_engine.domain.models import (
|
||||
ProfileScope,
|
||||
ProfileValue,
|
||||
ProjectionType,
|
||||
RegistrationSession,
|
||||
RegistrationStatus,
|
||||
Sensitivity,
|
||||
TenantAccount,
|
||||
User,
|
||||
@@ -53,10 +58,13 @@ __all__ = [
|
||||
"Catalog",
|
||||
"CatalogLifecycle",
|
||||
"ExternalIdentity",
|
||||
"FactorVerification",
|
||||
"FamilyDataspaceRequest",
|
||||
"FamilyInvitation",
|
||||
"FamilyMemberSpec",
|
||||
"FamilyRole",
|
||||
"IdentityFactor",
|
||||
"IdentityFactorType",
|
||||
"InvitationStatus",
|
||||
"ManagementMode",
|
||||
"Membership",
|
||||
@@ -66,6 +74,8 @@ __all__ = [
|
||||
"ProfileScope",
|
||||
"ProfileValue",
|
||||
"ProjectionType",
|
||||
"RegistrationSession",
|
||||
"RegistrationStatus",
|
||||
"Sensitivity",
|
||||
"TenantAccount",
|
||||
"User",
|
||||
|
||||
@@ -59,6 +59,25 @@ class InvitationStatus(StrEnum):
|
||||
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):
|
||||
GLOBAL = "global"
|
||||
TENANT = "tenant"
|
||||
@@ -313,6 +332,53 @@ class FamilyInvitation:
|
||||
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)
|
||||
class AuthorizationRequest:
|
||||
actor: Actor
|
||||
|
||||
@@ -21,10 +21,13 @@ from user_engine.domain import (
|
||||
CanonEntityReference,
|
||||
Catalog,
|
||||
ExternalIdentity,
|
||||
FactorVerification,
|
||||
FamilyInvitation,
|
||||
IdentityFactor,
|
||||
Membership,
|
||||
OutboxEvent,
|
||||
ProfileValue,
|
||||
RegistrationSession,
|
||||
TenantAccount,
|
||||
User,
|
||||
)
|
||||
@@ -120,6 +123,31 @@ class UserEngineStore(Protocol):
|
||||
) -> tuple[FamilyInvitation, ...]:
|
||||
"""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:
|
||||
"""Create or replace a profile value."""
|
||||
|
||||
@@ -152,6 +180,13 @@ class IdentityClaimsAdapter(Protocol):
|
||||
"""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):
|
||||
"""Ask whether an actor may perform an action."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from user_engine.domain import (
|
||||
Account,
|
||||
@@ -19,10 +19,13 @@ from user_engine.domain import (
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
ExternalIdentity,
|
||||
FactorVerification,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyInvitation,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
IdentityFactor,
|
||||
IdentityFactorType,
|
||||
InvitationStatus,
|
||||
Membership,
|
||||
Mutability,
|
||||
@@ -30,6 +33,8 @@ from user_engine.domain import (
|
||||
ProfileScope,
|
||||
ProfileValue,
|
||||
ProjectionType,
|
||||
RegistrationSession,
|
||||
RegistrationStatus,
|
||||
Sensitivity,
|
||||
TenantAccount,
|
||||
User,
|
||||
@@ -45,6 +50,7 @@ from user_engine.errors import (
|
||||
)
|
||||
from user_engine.ports import (
|
||||
AuthorizationCheckPort,
|
||||
FactorVerificationAdapter,
|
||||
IdentityClaimsAdapter,
|
||||
UserEngineStore,
|
||||
)
|
||||
@@ -53,6 +59,12 @@ REDACTED = "<redacted>"
|
||||
PLATFORM_TENANT = "platform:root"
|
||||
PLATFORM_OPERATOR_ROLE = "platform-operator"
|
||||
TENANT_ADMIN_ROLE = "tenant-admin"
|
||||
_TERMINAL_REGISTRATION_STATUSES = {
|
||||
RegistrationStatus.COMPLETED,
|
||||
RegistrationStatus.ABANDONED,
|
||||
RegistrationStatus.EXPIRED,
|
||||
RegistrationStatus.REJECTED,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -148,6 +160,15 @@ class FamilyInvitationAcceptance:
|
||||
claims_projection: Projection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationCompletion:
|
||||
session: RegistrationSession
|
||||
user: User
|
||||
account: Account
|
||||
netkingdom_id: str
|
||||
identity_context: IdentityContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantDiagnostics:
|
||||
tenant: str
|
||||
@@ -163,6 +184,15 @@ class OutboxDiagnostics:
|
||||
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)
|
||||
class OperabilitySnapshot:
|
||||
ready: bool
|
||||
@@ -180,10 +210,12 @@ class UserEngineService:
|
||||
store: UserEngineStore,
|
||||
identity_adapter: IdentityClaimsAdapter,
|
||||
authorization: AuthorizationCheckPort,
|
||||
factor_verifier: FactorVerificationAdapter | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.identity_adapter = identity_adapter
|
||||
self.authorization = authorization
|
||||
self.factor_verifier = factor_verifier
|
||||
if not self.store.ready:
|
||||
self.store.migrate()
|
||||
|
||||
@@ -217,6 +249,369 @@ class UserEngineService:
|
||||
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(
|
||||
self,
|
||||
claims: Mapping[str, Any],
|
||||
@@ -1305,6 +1700,76 @@ class UserEngineService:
|
||||
context["actor_subject"] = actor.subject
|
||||
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(
|
||||
self, actor: Actor, correlation_id: str
|
||||
) -> UserSession:
|
||||
@@ -2103,6 +2568,33 @@ def _optional_claim(actor: Actor, key: str) -> str | None:
|
||||
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:
|
||||
concepts = {
|
||||
"team": "Team",
|
||||
|
||||
@@ -160,9 +160,11 @@ class _ProtocolOnlyStore:
|
||||
"catalogs",
|
||||
"family_invitations",
|
||||
"identities",
|
||||
"identity_factors",
|
||||
"memberships",
|
||||
"outbox_events",
|
||||
"profile_values",
|
||||
"registration_sessions",
|
||||
"tenant_accounts",
|
||||
"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"
|
||||
domain: netkingdom
|
||||
repo: user-engine
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
@@ -46,7 +46,7 @@ lifecycle, sessions, and tokens remain external adapter concerns.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2a6c93de-e320-41e6-8930-7a4099c5757a"
|
||||
```
|
||||
@@ -57,7 +57,7 @@ stability, visibility, privacy, and migration expectations.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T2
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "31ddb44e-b7d1-406e-9114-78c5e7f92478"
|
||||
```
|
||||
@@ -67,7 +67,7 @@ factor_pending, factor_verified, completed, abandoned, expired, and rejected.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7441f064-eb49-4e66-8c1d-a2626aae020c"
|
||||
```
|
||||
@@ -78,7 +78,7 @@ evidence references without storing secret proofing payloads.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7057afda-d585-48cd-bac1-f0bd0f05fef5"
|
||||
```
|
||||
@@ -88,7 +88,7 @@ proofing results and return normalized factor evidence for user-engine.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T5
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f4f0da38-9810-45e7-ab4e-0619eb45b3c4"
|
||||
```
|
||||
@@ -98,7 +98,7 @@ complete, abandon, and resume flows.
|
||||
|
||||
```task
|
||||
id: USER-WP-0010-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "c29b31cd-f2b2-41b6-86ee-9c78470abf01"
|
||||
```
|
||||
@@ -123,3 +123,35 @@ factor lifecycle transitions.
|
||||
- Registration service facade.
|
||||
- Factor verification adapter ports.
|
||||
- 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