Implement registration identity model

This commit is contained in:
2026-06-15 22:06:39 +02:00
parent 2c94b40fc4
commit a36a25898e
12 changed files with 1012 additions and 12 deletions

View File

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

View File

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

View File

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

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -160,9 +160,11 @@ class _ProtocolOnlyStore:
"catalogs",
"family_invitations",
"identities",
"identity_factors",
"memberships",
"outbox_events",
"profile_values",
"registration_sessions",
"tenant_accounts",
"users",
}

View 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()

View File

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