diff --git a/README.md b/README.md index ef4ecbd..132fdfa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ make test See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, `docs/canon-mapping.md`, `docs/canon-interface-card.yaml`, `docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`, -`docs/examples.md`, `docs/scenarios.md`, +`docs/netkingdom-registration-onboarding-vision.md`, +`docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.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 diff --git a/SCOPE.md b/SCOPE.md index c5b3a5e..a83f026 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -42,7 +42,8 @@ application catalogs, projections, evidence references, audit, and events. - policy, control, access-review, exception, and organization source-of-truth ownership; - runtime secret custody; -- UI implementation; +- UI implementation in the current MVP; optional registration and access + management UI work is proposed separately under `USER-WP-0014`; - full SCIM server or enterprise directory replacement in the initial product. ## Boundary Rule @@ -56,5 +57,7 @@ truth. ## Current Planning -Implementation work is tracked in `workplans/USER-WP-0001` through -`USER-WP-0006`. +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. diff --git a/docs/contracts.md b/docs/contracts.md index fd91377..8b4ccd5 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -89,6 +89,24 @@ without emitting outbox events. Local audit records may be exported as identity-canon `Evidence Source` references. Durable platform audit custody remains outside user-engine. +## Durable Store Contract + +`UserEngineService` depends on the `UserEngineStore` protocol, not the +in-memory adapter's concrete collections. Store implementations must expose +schema readiness, logical record accessors, audit-log reads, pending-outbox +reads, adapter-neutral record counts, and a `transaction` context for atomic +mutations. + +Mutating writes happen after validation and authorization, inside the store +transaction. Domain changes, local mutation audit records, and outbox events +must commit or roll back together. Authorization-denial audit records must +remain durable without outbox events, including when a denial occurs inside a +composed mutation that rolls back other writes. + +Postgres-specific connection handling, SQL, locks, credentials, tenant +isolation primitives, backup, restore, and platform observability remain +adapter or provider concerns outside the domain service. + ## Migration Contract The isolated store exposes `SCHEMA_VERSION = 0001_initial` and a `migrate` diff --git a/docs/netkingdom-registration-onboarding-vision.md b/docs/netkingdom-registration-onboarding-vision.md new file mode 100644 index 0000000..e6df545 --- /dev/null +++ b/docs/netkingdom-registration-onboarding-vision.md @@ -0,0 +1,259 @@ +# NetKingdom Registration And Onboarding Vision + +Status: vision and proposed roadmap +Date: 2026-06-15 +Related workplans: USER-WP-0010 through USER-WP-0015 + +## Purpose + +NetKingdom needs a convenient way for people to register, receive a stable +NetKingdom identity, claim prepared rights, choose the role or "hat" they are +acting under, and enter services through guided onboarding journeys. + +`user-engine` can support this as the identity-domain and user-domain system +behind a registration UI. It should not become the identity provider, +credential system, MFA provider, final authorization policy engine, or runtime +ACL enforcer. Instead, it should provide the domain model, orchestration +facades, profile and membership facts, identity context, audit/outbox events, +and optional UI/API contracts that NetKingdom IAM, authorization, and service +runtimes can consume. + +## Product Vision + +A new user should be able to arrive at NetKingdom, establish the required +identity factors, receive or claim a NetKingdom ID, and immediately see the +services, realms, groups, and assets they may access. If the user is expected +before registration, an administrator, tenant owner, family owner, or upstream +system should be able to prepare the account and rights in advance. During +registration, verified factors such as email, phone, postal address, or eID can +match those preparations and attach the waiting access package to the new +account. + +The end state is: + +```text +People register once with NetKingdom. +NetKingdom links verified factors and external identities to one canonical user. +Prepared rights can be claimed safely when factor evidence matches. +Users choose an active hat for the context they are entering. +Applications receive claims, profile projections, and membership facts. +Authorization systems evaluate ACLs and policy from explicit facts. +Subsystem onboarding is driven by events, welcome protocols, and journeys. +``` + +## Answer: Can user-engine Provide A UI? + +Yes, but the UI should be an optional NetKingdom registration and onboarding +surface backed by user-engine service contracts. The repo's current intent is +"headless first" and "optional UI, not UI-driven". That means user-engine can +own a small registration UI or UI contract when it is the most convenient way +to operate the domain, as long as source-of-truth boundaries stay explicit: + +- NetKingdom IAM verifies credentials and proofing factors. +- user-engine stores users, accounts, identity links, memberships, profiles, + prepared-account claims, role context, and onboarding state. +- Authorization systems make final access decisions. +- Service runtimes enforce ACLs for their own resources. +- Audit, evidence, and lifecycle systems receive exported records and events. + +## Key Concepts + +### NetKingdom ID + +The NetKingdom ID should be a stable canonical identifier for the person in the +NetKingdom identity domain. It should not expose raw identity-provider +issuer/subject pairs. user-engine can mint or map this identifier through the +`User` record and `ExternalIdentity` links, while IAM continues to authenticate +the subject. + +### Registration Session + +A registration session is a short-lived onboarding workflow. It tracks the +actor, verified factor evidence, selected tenant or realm context, consent, +prepared-account matches, requested roles, and completion state. It should be +auditable and resumable without storing secret credential material. + +### Identity Factors + +Factors are evidence that help establish or link identity: + +- email address; +- phone number; +- postal address; +- eID or government-backed identity; +- organization-issued invite; +- existing SSO identity; +- recovery or delegated caretaker evidence. + +user-engine should store factor references, verification status, assurance +level, expiry, source system, and evidence references. It should not perform +the proofing itself unless a later adapter explicitly owns a local development +mock. + +### Prepared Accounts And Rights + +Prepared accounts allow NetKingdom to create pending user-account intent before +the person registers. A prepared account can contain: + +- expected factor match rules; +- tenant, group, family, realm, service, or asset scope; +- initial account state; +- role or hat templates; +- profile defaults; +- customer-journey steps; +- approval requirements; +- expiry and revocation rules. + +When a registering user proves the required factors, user-engine can link the +prepared account to the real user and convert prepared rights into explicit +memberships, profile values, and onboarding tasks. + +### Hats, Roles, And Profiles + +A "hat" is the active context a user chooses when entering NetKingdom or a +service. Examples include family owner, child, tenant admin, employee, +contractor, service operator, customer, vendor, or agent delegate. + +In user-engine, hats should be represented through tenant-scoped memberships, +role labels, profile layers, and application projections. A user may have many +hats, but only a subset should be active in a given realm, service, or asset +context. + +### Realms, Services, Assets, And ACLs + +user-engine should efficiently manage identity-domain access facts for users +and groups against realms, services, and assets. It should not become the final +ACL enforcement engine. The recommended split is: + +- user-engine owns user, group, membership, role, profile, and access-intent + facts; +- authorization systems evaluate policy and ACLs from those facts; +- services enforce decisions at runtime; +- user-engine exports identity context and claims-enrichment projections for + the active hat. + +## Target Flows + +### Self Registration + +1. User opens the NetKingdom registration UI. +2. IAM verifies one or more required factors. +3. user-engine creates or resolves the canonical user and account. +4. user-engine links verified external identities and factor evidence. +5. user-engine evaluates prepared-account matches. +6. User accepts terms, chooses initial tenant or realm, and selects available + hats. +7. user-engine emits audit and outbox events for downstream onboarding. +8. Services receive identity context and claims projections. + +### Prepared Account Claim + +1. Admin, family owner, tenant admin, HR feed, service owner, or invite source + prepares an account package. +2. The package declares required factor matches and planned roles/profiles. +3. User registers and proves the required factors. +4. user-engine links the preparation to the canonical user. +5. Prepared memberships, profile defaults, and onboarding tasks are activated. +6. Any sensitive or privileged role waits for approval if policy requires it. + +### Hat Selection + +1. User signs in and sees available hats for the current tenant, realm, or + service. +2. User selects an active hat. +3. user-engine returns `identity_context` and a claims-enrichment projection + for that context. +4. IAM or a gateway issues service-facing claims. +5. Authorization and service runtimes evaluate ACLs and policy. + +### Welcome Protocols + +1. Registration or prepared-account claim emits onboarding events. +2. Journey definitions map events to subsystem steps. +3. Welcome protocols send the user to profile completion, family setup, tenant + selection, app tour, evidence collection, approval, or service activation. +4. Each subsystem reports completion, failure, or required manual follow-up. + +## UI Surface + +The first UI should be functional, quiet, and workflow-oriented. It should +support: + +- registration start and resume; +- factor verification status; +- prepared-account claim review; +- terms and consent capture; +- role or hat selection; +- profile completion; +- welcome journey timeline; +- access request and pending approval status; +- administrator views for prepared accounts, invitations, groups, realms, + service bindings, and onboarding diagnostics. + +The UI should call stable service/application APIs. It should not embed IAM, +authorization, proofing, or service-specific ACL logic in browser code. + +## Domain Additions Needed + +The current domain already has users, accounts, tenant accounts, external +identities, memberships, profiles, applications, catalogs, invitations, audit, +outbox, and identity context. The registration vision needs additional +concepts: + +- `RegistrationSession` +- `NetKingdomIdentity` or public NetKingdom ID alias +- `IdentityFactor` +- `FactorVerification` +- `PreparedAccount` +- `PreparedEntitlement` +- `Hat` or active role context +- `Realm` +- `ServiceArea` +- `AssetScope` +- `AccessProfile` +- `AccessIntent` +- `OnboardingJourney` +- `WelcomeProtocol` +- `OnboardingTask` + +These should be introduced incrementally through workplans rather than all at +once. + +## Security And Governance + +- Factor values must be minimized, normalized, and redacted in diagnostics. +- High-assurance factors such as eID should be represented by evidence + references and assurance metadata, not raw proofing payloads. +- Prepared rights must expire, be revocable, and show who prepared them. +- Privileged hats require explicit evidence, approval, or policy/control + references. +- Users should see why a prepared account or role is available before claiming + it. +- Access to realms, services, and assets must fail closed when tenant, hat, or + factor context is missing. +- All lifecycle transitions should be auditable and emit outbox events. + +## Recommended Workplans + +| Workplan | Title | Purpose | +| --- | --- | --- | +| USER-WP-0010 | Registration Identity And Factor Model | Add registration sessions, NetKingdom ID semantics, factor evidence, and verification adapter boundaries. | +| USER-WP-0011 | Prepared Accounts And Entitlement Claims | Allow accounts, roles, profiles, and journeys to be prepared before registration and claimed after factor match. | +| USER-WP-0012 | Hats, Realms, Services, Assets, And Access Profiles | Model active hats and access-control facts for users/groups across realms, services, and assets. | +| USER-WP-0013 | Onboarding Journeys And Welcome Protocols | Orchestrate subsystem welcome flows from registration, invitation, and prepared-account events. | +| USER-WP-0014 | Registration And Access Management UI | Build the optional UI/API surface for registration, factor status, prepared rights, hat selection, and admin setup. | +| USER-WP-0015 | Registration Scenario And Security Conformance | Add end-to-end scenarios, threat-oriented negative tests, redaction checks, and adapter conformance for the full flow. | + +## First Milestone + +The first useful milestone should not be the full UI. It should be a headless +registration facade and scenario tests: + +```text +Start registration -> verify email through adapter evidence -> create +NetKingdom user/account -> claim a prepared tenant role -> choose active hat -> +return identity_context and claims projection -> emit onboarding events. +``` + +After that is stable, a thin UI can use the same facade without inventing its +own registration rules. diff --git a/docs/postgres-durable-store-consumer-requirements.md b/docs/postgres-durable-store-consumer-requirements.md index 1202a91..5dcd536 100644 --- a/docs/postgres-durable-store-consumer-requirements.md +++ b/docs/postgres-durable-store-consumer-requirements.md @@ -1,7 +1,7 @@ # Postgres Durable Store Consumer Requirements -Status: requirements -Date: 2026-06-05 +Status: requirements + store contract boundary +Date: 2026-06-15 Related workplan: USER-WP-0009 ## Purpose @@ -13,6 +13,12 @@ NetKingdom infrastructure repository provides a tenant-aware, security integrated Postgres capability, and `user-engine` consumes that capability through a durable store adapter. +The consumer-side contract is now represented in code by +`user_engine.ports.UserEngineStore`. The protocol is intentionally +adapter-neutral: it names the service behavior a durable store must satisfy +without adding a Postgres dependency or giving this repository ownership of +database provisioning. + ## Consumer Story As a `user-engine` consumer, I want the service to persist identity-domain @@ -85,6 +91,22 @@ the isolated store: - Support the same service-level exceptions for not found, conflict, validation, and authorization-denied flows. +### Store Protocol Boundary + +`UserEngineService` consumes the `UserEngineStore` protocol rather than local +in-memory collections. A future Postgres adapter must provide: + +- Schema readiness through `schema_version`, `ready`, and `migrate`. +- A `transaction` context that makes each mutating write unit atomic. +- Logical read/write methods for users, accounts, tenant accounts, external + identities, memberships, applications, bindings, catalogs, family + invitations, and profile values. +- Audit and outbox append/read methods that preserve write order. +- Adapter-neutral record counts for diagnostics and operability snapshots. + +Concrete tables, SQL, connection pools, and row locks remain adapter details. +Service and domain code should not depend on Postgres-specific concepts. + ### Identity And Account Constraints - `(issuer, subject)` must uniquely identify one external identity link. @@ -157,6 +179,9 @@ the isolated store: them to `ConflictError` where appropriate. - Migration and outbox claiming should use explicit locking strategies that do not require consumers to understand Postgres internals. +- Authorization-denial audit records must persist without outbox events even + when the denied operation occurs inside a composed transaction that rolls + back domain writes. ### Migration Requirements @@ -253,10 +278,16 @@ A future Postgres adapter should pass conformance tests for: ## First Implementation Follow-Ups -After this requirements work is accepted, likely follow-up work should be: +The first consumer-side follow-up is complete: `UserEngineStore` defines the +adapter boundary and the in-memory store acts as the reference implementation +for service-level behavior. + +Likely future follow-up work should be: -- Define the durable store protocol changes, if any. - Add a Postgres adapter behind the existing store boundary. - Add migration files for user-engine tables. +- Add provider-backed conformance tests for locking, uniqueness races, + migration readiness, outbox claiming, redacted diagnostics, and restore + validation. - Add conformance tests that run against both in-memory and Postgres stores. - Integrate the adapter with the future NetKingdom Postgres provider repo. diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index 9f6b937..57c9ed8 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -2,8 +2,10 @@ from __future__ import annotations +import copy +from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Iterable +from typing import Iterable, Iterator, Mapping, cast from user_engine.domain import ( Account, @@ -51,6 +53,10 @@ class InMemoryUserEngineStore: ] = field(default_factory=dict) audit_records: list[AuditRecord] = field(default_factory=list) outbox_events: list[OutboxEvent] = field(default_factory=list) + _transaction_depth: int = field(default=0, init=False, repr=False) + _transaction_snapshot: Mapping[str, object] | None = field( + default=None, init=False, repr=False + ) def migrate(self) -> None: """Apply the standalone schema migration manifest.""" @@ -60,15 +66,42 @@ class InMemoryUserEngineStore: def ready(self) -> bool: return self.schema_version == SCHEMA_VERSION + @contextmanager + def transaction(self) -> Iterator[None]: + """Provide atomic in-memory mutation semantics for conformance tests.""" + if self._transaction_depth == 0: + self._transaction_snapshot = self._snapshot() + self._transaction_depth += 1 + try: + yield + except Exception: + if self._transaction_depth == 1 and self._transaction_snapshot is not None: + self._restore(self._transaction_snapshot) + raise + finally: + self._transaction_depth -= 1 + if self._transaction_depth == 0: + self._transaction_snapshot = None + def save_user(self, user: User) -> None: self.users[user.user_id] = user + def user(self, user_id: str) -> User | None: + return self.users.get(user_id) + def save_account(self, account: Account) -> None: self.accounts[account.user_id] = account def save_identity(self, identity: ExternalIdentity) -> None: self.identities[identity.identity_key] = identity + def identities_for_user(self, user_id: str) -> tuple[ExternalIdentity, ...]: + return tuple( + identity + for identity in self.identities.values() + if identity.user_id == user_id + ) + def save_tenant_account(self, account: TenantAccount) -> None: self.tenant_accounts[(account.tenant, account.user_id)] = account @@ -78,12 +111,24 @@ class InMemoryUserEngineStore: def save_application(self, application: Application) -> None: self.applications[application.application_id] = application + def application(self, application_id: str) -> Application | None: + return self.applications.get(application_id) + def save_binding(self, binding: ApplicationBinding) -> None: self.bindings[binding.application_id] = binding + def binding(self, application_id: str) -> ApplicationBinding | None: + return self.bindings.get(application_id) + def save_catalog(self, catalog: Catalog) -> None: self.catalogs[catalog.catalog_id] = catalog + def catalog(self, catalog_id: str) -> Catalog | None: + return self.catalogs.get(catalog_id) + + def all_catalogs(self) -> tuple[Catalog, ...]: + return tuple(self.catalogs.values()) + def save_family_invitation(self, invitation: FamilyInvitation) -> None: self.family_invitations[invitation.invitation_id] = invitation @@ -136,9 +181,67 @@ class InMemoryUserEngineStore: def append_audit(self, record: AuditRecord) -> None: self.audit_records.append(record) + def audit_log(self) -> tuple[AuditRecord, ...]: + return tuple(self.audit_records) + def append_outbox(self, event: OutboxEvent) -> None: self.outbox_events.append(event) + def pending_outbox(self) -> tuple[OutboxEvent, ...]: + return tuple(self.outbox_events) + + def record_counts(self) -> Mapping[str, int]: + return { + "users": len(self.users), + "accounts": len(self.accounts), + "tenant_accounts": len(self.tenant_accounts), + "memberships": len(self.memberships), + "applications": len(self.applications), + "catalogs": len(self.catalogs), + "family_invitations": len(self.family_invitations), + "profile_values": len(self.profile_values), + "audit_records": len(self.audit_records), + "pending_outbox_events": len(self.outbox_events), + } + + def _snapshot(self) -> Mapping[str, object]: + return { + "users": copy.deepcopy(self.users), + "accounts": copy.deepcopy(self.accounts), + "identities": copy.deepcopy(self.identities), + "tenant_accounts": copy.deepcopy(self.tenant_accounts), + "memberships": copy.deepcopy(self.memberships), + "applications": copy.deepcopy(self.applications), + "bindings": copy.deepcopy(self.bindings), + "catalogs": copy.deepcopy(self.catalogs), + "family_invitations": copy.deepcopy(self.family_invitations), + "profile_values": copy.deepcopy(self.profile_values), + "audit_records": copy.deepcopy(self.audit_records), + "outbox_events": copy.deepcopy(self.outbox_events), + } + + def _restore(self, snapshot: Mapping[str, object]) -> None: + snapshot_audit_records = cast(list[AuditRecord], snapshot["audit_records"]) + denied_audit_records = [ + record + for record in self.audit_records[len(snapshot_audit_records) :] + if record.summary == "authorization denied" + ] + self.users = snapshot["users"] # type: ignore[assignment] + self.accounts = snapshot["accounts"] # type: ignore[assignment] + self.identities = snapshot["identities"] # type: ignore[assignment] + self.tenant_accounts = snapshot["tenant_accounts"] # type: ignore[assignment] + self.memberships = snapshot["memberships"] # type: ignore[assignment] + self.applications = snapshot["applications"] # type: ignore[assignment] + self.bindings = snapshot["bindings"] # type: ignore[assignment] + self.catalogs = snapshot["catalogs"] # type: ignore[assignment] + self.family_invitations = snapshot[ + "family_invitations" + ] # 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] + class LocalAuthorizationCheckPort: """Deterministic local authorization adapter. diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 9f61969..0c8c66f 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -7,20 +7,141 @@ adapters without changing domain code. from __future__ import annotations +from contextlib import AbstractContextManager from typing import Any, Iterable, Mapping, Protocol from user_engine.domain import ( + Account, Actor, + Application, ApplicationBinding, AuditRecord, AuthorizationDecision, AuthorizationRequest, CanonEntityReference, + Catalog, + ExternalIdentity, + FamilyInvitation, Membership, OutboxEvent, + ProfileValue, + TenantAccount, + User, ) +class UserEngineStore(Protocol): + """Durable persistence boundary for user-engine service behavior. + + Implementations may be in-memory, Postgres-backed, or platform-provided, + but must preserve the same logical keys, readiness contract, and atomic + mutation semantics exposed here. + """ + + schema_version: str | None + + @property + def ready(self) -> bool: + """Return whether the store is schema-compatible for service use.""" + + def migrate(self) -> None: + """Apply or verify user-engine-owned schema migrations.""" + + def transaction(self) -> AbstractContextManager[None]: + """Return a context manager for one atomic mutation unit.""" + + def save_user(self, user: User) -> None: + """Create or replace a user record.""" + + def user(self, user_id: str) -> User | None: + """Return a user by id.""" + + def save_account(self, account: Account) -> None: + """Create or replace a primary account record.""" + + def user_account(self, user_id: str) -> Account | None: + """Return the primary account for a user.""" + + def save_identity(self, identity: ExternalIdentity) -> None: + """Create or replace an external identity link.""" + + def find_identity(self, issuer: str, subject: str) -> ExternalIdentity | None: + """Return an external identity by issuer and subject.""" + + def identities_for_user(self, user_id: str) -> tuple[ExternalIdentity, ...]: + """Return all external identities linked to a user.""" + + def save_tenant_account(self, account: TenantAccount) -> None: + """Create or replace a tenant-scoped account record.""" + + def tenant_account(self, tenant: str, user_id: str) -> TenantAccount | None: + """Return a tenant-scoped account record.""" + + def save_membership(self, membership: Membership) -> None: + """Create or replace a membership fact.""" + + def memberships_for_user( + self, user_id: str, *, tenant: str | None = None + ) -> tuple[Membership, ...]: + """Return memberships for a user, optionally scoped to a tenant.""" + + def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]: + """Return memberships scoped to a tenant.""" + + def save_application(self, application: Application) -> None: + """Create or replace an application registration.""" + + def application(self, application_id: str) -> Application | None: + """Return an application by id.""" + + def save_binding(self, binding: ApplicationBinding) -> None: + """Create or replace an application binding.""" + + def binding(self, application_id: str) -> ApplicationBinding | None: + """Return an application binding by application id.""" + + def save_catalog(self, catalog: Catalog) -> None: + """Create or replace a catalog.""" + + def catalog(self, catalog_id: str) -> Catalog | None: + """Return a catalog by id.""" + + def all_catalogs(self) -> tuple[Catalog, ...]: + """Return all catalogs.""" + + def save_family_invitation(self, invitation: FamilyInvitation) -> None: + """Create or replace a family invitation.""" + + def family_invitation(self, invitation_id: str) -> FamilyInvitation | None: + """Return a family invitation by id.""" + + def family_invitations_for_user( + self, user_id: str + ) -> tuple[FamilyInvitation, ...]: + """Return family invitations for a user.""" + + def save_profile_value(self, value: ProfileValue) -> None: + """Create or replace a profile value.""" + + def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]: + """Return profile values for a user.""" + + def append_audit(self, record: AuditRecord) -> None: + """Append a local audit record.""" + + def audit_log(self) -> tuple[AuditRecord, ...]: + """Return local audit records in write order.""" + + def append_outbox(self, event: OutboxEvent) -> None: + """Append an outbox event.""" + + def pending_outbox(self) -> tuple[OutboxEvent, ...]: + """Return pending outbox events in write order.""" + + def record_counts(self) -> Mapping[str, int]: + """Return adapter-neutral record counts for diagnostics.""" + + class IdentityClaimsAdapter(Protocol): """Normalize verified identity claims into a user-engine actor.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 35abe11..4e1690c 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -5,7 +5,6 @@ from __future__ import annotations from dataclasses import dataclass, replace from typing import Any, Mapping -from user_engine.adapters.local import InMemoryUserEngineStore from user_engine.domain import ( Account, AccountStatus, @@ -44,7 +43,11 @@ from user_engine.errors import ( NotFoundError, ValidationError, ) -from user_engine.ports import AuthorizationCheckPort, IdentityClaimsAdapter +from user_engine.ports import ( + AuthorizationCheckPort, + IdentityClaimsAdapter, + UserEngineStore, +) REDACTED = "" PLATFORM_TENANT = "platform:root" @@ -174,7 +177,7 @@ class UserEngineService: def __init__( self, *, - store: InMemoryUserEngineStore, + store: UserEngineStore, identity_adapter: IdentityClaimsAdapter, authorization: AuthorizationCheckPort, ) -> None: @@ -253,25 +256,26 @@ class UserEngineService: subject=actor.subject, provider=actor.authorized_party, ) - self.store.save_user(user) - self.store.save_account(account) - self.store.save_tenant_account(tenant_account) - self.store.save_identity(identity) - self._record_mutation( - actor, - action="user.create_from_identity", - subject=user.user_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="user.created", - aggregate_id=user.user_id, - payload={ - "user_id": user.user_id, - "account_id": account.account_id, - "identity": {"issuer": actor.issuer, "subject": actor.subject}, - }, - ) + with self.store.transaction(): + self.store.save_user(user) + self.store.save_account(account) + self.store.save_tenant_account(tenant_account) + self.store.save_identity(identity) + self._record_mutation( + actor, + action="user.create_from_identity", + subject=user.user_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="user.created", + aggregate_id=user.user_id, + payload={ + "user_id": user.user_id, + "account_id": account.account_id, + "identity": {"issuer": actor.issuer, "subject": actor.subject}, + }, + ) return self._session(actor, user, account) def create_user( @@ -294,20 +298,21 @@ class UserEngineService: user = User(display_name=display_name, primary_email=primary_email) account = Account(account_id=new_id("acct"), user_id=user.user_id) tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant) - self.store.save_user(user) - self.store.save_account(account) - self.store.save_tenant_account(tenant_account) - self._record_mutation( - actor, - action="user.create", - subject=user.user_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="user.created", - aggregate_id=user.user_id, - payload={"user_id": user.user_id, "account_id": account.account_id}, - ) + with self.store.transaction(): + self.store.save_user(user) + self.store.save_account(account) + self.store.save_tenant_account(tenant_account) + self._record_mutation( + actor, + action="user.create", + subject=user.user_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="user.created", + aggregate_id=user.user_id, + payload={"user_id": user.user_id, "account_id": account.account_id}, + ) return user def set_account_status( @@ -330,18 +335,19 @@ class UserEngineService: target_user_id=user_id, ) updated = replace(account, status=status) - self.store.save_account(updated) - self._record_mutation( - actor, - action="account.update", - subject=user_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="account.status_changed", - aggregate_id=account.account_id, - payload={"user_id": user_id, "status": status}, - ) + with self.store.transaction(): + self.store.save_account(updated) + self._record_mutation( + actor, + action="account.update", + subject=user_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="account.status_changed", + aggregate_id=account.account_id, + payload={"user_id": user_id, "status": status}, + ) return updated def set_tenant_account_status( @@ -369,22 +375,23 @@ class UserEngineService: target_user_id=user_id, ) updated = replace(account, status=status) - self.store.save_tenant_account(updated) - self._record_mutation( - actor, - action="tenant.account.update", - subject=user_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="tenant_account.status_changed", - aggregate_id=user_id, - payload={ - "user_id": user_id, - "tenant": tenant_context.tenant, - "status": status, - }, - ) + with self.store.transaction(): + self.store.save_tenant_account(updated) + self._record_mutation( + actor, + action="tenant.account.update", + subject=user_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="tenant_account.status_changed", + aggregate_id=user_id, + payload={ + "user_id": user_id, + "tenant": tenant_context.tenant, + "status": status, + }, + ) return updated def add_membership( @@ -420,25 +427,26 @@ class UserEngineService: kind=kind, freshness_version=correlation_id, ) - self.store.save_membership(membership) - self._record_mutation( - actor, - action="membership.write", - subject=user_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="membership.added", - aggregate_id=membership.membership_id, - payload={ - "membership_id": membership.membership_id, - "user_id": user_id, - "tenant": tenant_context.tenant, - "scope_type": scope_type, - "scope_id": scope_id, - "kind": kind, - }, - ) + with self.store.transaction(): + self.store.save_membership(membership) + self._record_mutation( + actor, + action="membership.write", + subject=user_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="membership.added", + aggregate_id=membership.membership_id, + payload={ + "membership_id": membership.membership_id, + "user_id": user_id, + "tenant": tenant_context.tenant, + "scope_type": scope_type, + "scope_id": scope_id, + "kind": kind, + }, + ) return membership def link_identity( @@ -475,18 +483,19 @@ class UserEngineService: subject=subject, provider=provider, ) - self.store.save_identity(identity) - self._record_mutation( - actor, - action="identity.link", - subject=user_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="identity.linked", - aggregate_id=user_id, - payload={"issuer": issuer, "subject": subject, "user_id": user_id}, - ) + with self.store.transaction(): + self.store.save_identity(identity) + self._record_mutation( + actor, + action="identity.link", + subject=user_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="identity.linked", + aggregate_id=user_id, + payload={"issuer": issuer, "subject": subject, "user_id": user_id}, + ) return identity def register_application( @@ -497,7 +506,7 @@ class UserEngineService: binding: ApplicationBinding | None = None, correlation_id: str | None = None, ) -> Application: - if application.application_id in self.store.applications: + if self.store.application(application.application_id) is not None: raise ConflictError("application already exists") correlation_id = correlation_id or new_id("corr") decision = self._authorize( @@ -509,23 +518,24 @@ class UserEngineService: correlation_id=correlation_id, application_id=application.application_id, ) - self.store.save_application(application) - if binding is not None: - if binding.application_id != application.application_id: - raise ValidationError("binding application id must match application") - self.store.save_binding(binding) - self._record_mutation( - actor, - action="application.register", - subject=application.application_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="application.registered", - aggregate_id=application.application_id, - payload={"application_id": application.application_id}, - application_id=application.application_id, - ) + with self.store.transaction(): + self.store.save_application(application) + if binding is not None: + if binding.application_id != application.application_id: + raise ValidationError("binding application id must match application") + self.store.save_binding(binding) + self._record_mutation( + actor, + action="application.register", + subject=application.application_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="application.registered", + aggregate_id=application.application_id, + payload={"application_id": application.application_id}, + application_id=application.application_id, + ) return application def publish_catalog( @@ -546,23 +556,24 @@ class UserEngineService: correlation_id=correlation_id, application_id=catalog.owning_application_id, ) - self.store.save_catalog(catalog) - self._record_mutation( - actor, - action="catalog.publish", - subject=catalog.catalog_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="catalog.published", - aggregate_id=catalog.catalog_id, - payload={ - "catalog_id": catalog.catalog_id, - "namespace": catalog.namespace, - "version": catalog.version, - }, - application_id=catalog.owning_application_id, - ) + with self.store.transaction(): + self.store.save_catalog(catalog) + self._record_mutation( + actor, + action="catalog.publish", + subject=catalog.catalog_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="catalog.published", + aggregate_id=catalog.catalog_id, + payload={ + "catalog_id": catalog.catalog_id, + "namespace": catalog.namespace, + "version": catalog.version, + }, + application_id=catalog.owning_application_id, + ) return catalog def set_profile_value( @@ -603,24 +614,25 @@ class UserEngineService: scope_id=scope_id, source=f"{actor.issuer}:{actor.subject}", ) - self.store.save_profile_value(profile_value) - self._record_mutation( - actor, - action="profile.write", - subject=user_id, - tenant=operation_tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="profile.value_set", - aggregate_id=user_id, - payload={ - "user_id": user_id, - "attribute_key": attribute_key, - "scope": scope, - "scope_id": scope_id, - }, - application_id=application_id, - ) + with self.store.transaction(): + self.store.save_profile_value(profile_value) + self._record_mutation( + actor, + action="profile.write", + subject=user_id, + tenant=operation_tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="profile.value_set", + aggregate_id=user_id, + payload={ + "user_id": user_id, + "attribute_key": attribute_key, + "scope": scope, + "scope_id": scope_id, + }, + application_id=application_id, + ) return profile_value def effective_profile( @@ -729,11 +741,7 @@ class UserEngineService: target_user_id=user_id, context={"include_profile": include_profile}, ) - identities = tuple( - identity - for identity in self.store.identities.values() - if identity.user_id == user.user_id - ) + identities = self.store.identities_for_user(user.user_id) memberships = self.store.memberships_for_user( user.user_id, tenant=tenant_context.tenant ) @@ -801,68 +809,69 @@ class UserEngineService: application_id=request.application_id, context={"family_scope_id": request.family_scope_id}, ) - owner_session = self._ensure_actor_session(actor, correlation_id) - application, binding = self._ensure_family_dataspace_application( - actor, request, correlation_id - ) - catalog = self._ensure_family_dataspace_catalog( - actor, request, correlation_id - ) - owner_membership = self._ensure_membership( - actor, - owner_session.user.user_id, - tenant=tenant_context.tenant, - scope_type="family", - scope_id=request.family_scope_id, - kind=FamilyRole.OWNER.value, - correlation_id=correlation_id, - ) - owner_defaults = dict(request.owner_profile_defaults) - owner_defaults.setdefault( - "member_display_name", - owner_session.user.display_name - or actor.preferred_username - or owner_session.user.primary_email - or actor.subject, - ) - self._apply_family_profile_defaults( - actor, - owner_session.user.user_id, - tenant=tenant_context.tenant, - application_id=request.application_id, - catalog_namespace=request.catalog_namespace, - values=owner_defaults, - correlation_id=correlation_id, - ) - invitations = tuple( - self.invite_family_member( + with self.store.transaction(): + owner_session = self._ensure_actor_session(actor, correlation_id) + application, binding = self._ensure_family_dataspace_application( + actor, request, correlation_id + ) + catalog = self._ensure_family_dataspace_catalog( + actor, request, correlation_id + ) + owner_membership = self._ensure_membership( actor, + owner_session.user.user_id, tenant=tenant_context.tenant, - family_scope_id=request.family_scope_id, - application_id=request.application_id, - catalog_namespace=request.catalog_namespace, - member=member, + scope_type="family", + scope_id=request.family_scope_id, + kind=FamilyRole.OWNER.value, correlation_id=correlation_id, ) - for member in request.member_specs - ) - self._record_mutation( - actor, - action="family_dataspace.onboard", - subject=request.family_scope_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="family_dataspace.onboarded", - aggregate_id=request.family_scope_id, - payload={ - "tenant": tenant_context.tenant, - "family_scope_id": request.family_scope_id, - "application_id": request.application_id, - "member_invitation_count": len(invitations), - }, - application_id=request.application_id, - ) + owner_defaults = dict(request.owner_profile_defaults) + owner_defaults.setdefault( + "member_display_name", + owner_session.user.display_name + or actor.preferred_username + or owner_session.user.primary_email + or actor.subject, + ) + self._apply_family_profile_defaults( + actor, + owner_session.user.user_id, + tenant=tenant_context.tenant, + application_id=request.application_id, + catalog_namespace=request.catalog_namespace, + values=owner_defaults, + correlation_id=correlation_id, + ) + invitations = tuple( + self.invite_family_member( + actor, + tenant=tenant_context.tenant, + family_scope_id=request.family_scope_id, + application_id=request.application_id, + catalog_namespace=request.catalog_namespace, + member=member, + correlation_id=correlation_id, + ) + for member in request.member_specs + ) + self._record_mutation( + actor, + action="family_dataspace.onboard", + subject=request.family_scope_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="family_dataspace.onboarded", + aggregate_id=request.family_scope_id, + payload={ + "tenant": tenant_context.tenant, + "family_scope_id": request.family_scope_id, + "application_id": request.application_id, + "member_invitation_count": len(invitations), + }, + application_id=request.application_id, + ) identity_context = self.identity_context( actor, user_id=owner_session.user.user_id, @@ -926,81 +935,82 @@ class UserEngineService: "role": role, }, ) - user = self.create_user( - actor, - display_name=member.display_name, - primary_email=member.primary_email, - correlation_id=correlation_id, - ) - tenant_account = self.set_tenant_account_status( - actor, - user.user_id, - AccountStatus.INVITED, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - ) - membership = self._ensure_membership( - actor, - user.user_id, - tenant=tenant_context.tenant, - scope_type="family", - scope_id=family_scope_id, - kind=role, - correlation_id=correlation_id, - ) - profile_defaults = dict(member.profile_defaults) - if member.display_name: - profile_defaults.setdefault("member_display_name", member.display_name) - self._apply_family_profile_defaults( - actor, - user.user_id, - tenant=tenant_context.tenant, - application_id=application_id, - catalog_namespace=catalog_namespace, - values=profile_defaults, - correlation_id=correlation_id, - ) - if member.issuer and member.subject: - self.link_identity( + with self.store.transaction(): + user = self.create_user( actor, - user.user_id, - issuer=member.issuer, - subject=member.subject, - provider=member.provider, + display_name=member.display_name, + primary_email=member.primary_email, correlation_id=correlation_id, ) - invitation = FamilyInvitation( - invitation_id=new_id("inv"), - tenant=tenant_context.tenant, - family_scope_id=family_scope_id, - application_id=application_id, - user_id=user.user_id, - primary_email=member.primary_email, - role=role, - invited_by=actor.subject, - correlation_id=correlation_id, - last_sent_correlation_id=correlation_id, - ) - self.store.save_family_invitation(invitation) - self._record_mutation( - actor, - action="family_member.invite", - subject=invitation.invitation_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="family_member.invited", - aggregate_id=invitation.invitation_id, - payload={ - "invitation_id": invitation.invitation_id, - "user_id": user.user_id, - "tenant": tenant_context.tenant, - "family_scope_id": family_scope_id, - "application_id": application_id, - "role": role, - }, - application_id=application_id, - ) + tenant_account = self.set_tenant_account_status( + actor, + user.user_id, + AccountStatus.INVITED, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + membership = self._ensure_membership( + actor, + user.user_id, + tenant=tenant_context.tenant, + scope_type="family", + scope_id=family_scope_id, + kind=role, + correlation_id=correlation_id, + ) + profile_defaults = dict(member.profile_defaults) + if member.display_name: + profile_defaults.setdefault("member_display_name", member.display_name) + self._apply_family_profile_defaults( + actor, + user.user_id, + tenant=tenant_context.tenant, + application_id=application_id, + catalog_namespace=catalog_namespace, + values=profile_defaults, + correlation_id=correlation_id, + ) + if member.issuer and member.subject: + self.link_identity( + actor, + user.user_id, + issuer=member.issuer, + subject=member.subject, + provider=member.provider, + correlation_id=correlation_id, + ) + invitation = FamilyInvitation( + invitation_id=new_id("inv"), + tenant=tenant_context.tenant, + family_scope_id=family_scope_id, + application_id=application_id, + user_id=user.user_id, + primary_email=member.primary_email, + role=role, + invited_by=actor.subject, + correlation_id=correlation_id, + last_sent_correlation_id=correlation_id, + ) + self.store.save_family_invitation(invitation) + self._record_mutation( + actor, + action="family_member.invite", + subject=invitation.invitation_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="family_member.invited", + aggregate_id=invitation.invitation_id, + payload={ + "invitation_id": invitation.invitation_id, + "user_id": user.user_id, + "tenant": tenant_context.tenant, + "family_scope_id": family_scope_id, + "application_id": application_id, + "role": role, + }, + application_id=application_id, + ) return FamilyMemberInvitation( user=user, tenant_account=tenant_account, @@ -1036,23 +1046,24 @@ class UserEngineService: last_sent_correlation_id=correlation_id, updated_at=utc_now(), ) - self.store.save_family_invitation(updated) - self._record_mutation( - actor, - action="family_invitation.resend", - subject=updated.invitation_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="family_invitation.resent", - aggregate_id=updated.invitation_id, - payload={ - "invitation_id": updated.invitation_id, - "user_id": updated.user_id, - "resend_count": updated.resend_count, - }, - application_id=updated.application_id, - ) + with self.store.transaction(): + self.store.save_family_invitation(updated) + self._record_mutation( + actor, + action="family_invitation.resend", + subject=updated.invitation_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="family_invitation.resent", + aggregate_id=updated.invitation_id, + payload={ + "invitation_id": updated.invitation_id, + "user_id": updated.user_id, + "resend_count": updated.resend_count, + }, + application_id=updated.application_id, + ) return updated def revoke_family_invitation( @@ -1077,36 +1088,37 @@ class UserEngineService: application_id=invitation.application_id, target_user_id=invitation.user_id, ) - self.set_tenant_account_status( - actor, - invitation.user_id, - AccountStatus.DISABLED, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - ) - updated = replace( - invitation, - status=InvitationStatus.REVOKED, - updated_at=utc_now(), - revoked_at=utc_now(), - ) - self.store.save_family_invitation(updated) - self._record_mutation( - actor, - action="family_invitation.revoke", - subject=updated.invitation_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="family_invitation.revoked", - aggregate_id=updated.invitation_id, - payload={ - "invitation_id": updated.invitation_id, - "user_id": updated.user_id, - "status": updated.status, - }, - application_id=updated.application_id, - ) + with self.store.transaction(): + self.set_tenant_account_status( + actor, + invitation.user_id, + AccountStatus.DISABLED, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + updated = replace( + invitation, + status=InvitationStatus.REVOKED, + updated_at=utc_now(), + revoked_at=utc_now(), + ) + self.store.save_family_invitation(updated) + self._record_mutation( + actor, + action="family_invitation.revoke", + subject=updated.invitation_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="family_invitation.revoked", + aggregate_id=updated.invitation_id, + payload={ + "invitation_id": updated.invitation_id, + "user_id": updated.user_id, + "status": updated.status, + }, + application_id=updated.application_id, + ) return updated def accept_family_invitation( @@ -1135,54 +1147,55 @@ class UserEngineService: target_user_id=invitation.user_id, context={"family_scope_id": invitation.family_scope_id}, ) - self.link_identity( - actor, - invitation.user_id, - issuer=actor.issuer, - subject=actor.subject, - provider=actor.authorized_party, - correlation_id=correlation_id, - ) - account = self.set_account_status( - actor, - invitation.user_id, - AccountStatus.ACTIVE, - correlation_id=correlation_id, - ) - self.set_tenant_account_status( - actor, - invitation.user_id, - AccountStatus.ACTIVE, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - ) - accepted_at = utc_now() - accepted = replace( - invitation, - status=InvitationStatus.ACCEPTED, - updated_at=accepted_at, - accepted_at=accepted_at, - ) - self.store.save_family_invitation(accepted) - self._record_mutation( - actor, - action="family_invitation.accept", - subject=accepted.invitation_id, - tenant=tenant_context.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="family_invitation.accepted", - aggregate_id=accepted.invitation_id, - payload={ - "invitation_id": accepted.invitation_id, - "user_id": accepted.user_id, - "tenant": accepted.tenant, - "family_scope_id": accepted.family_scope_id, - "application_id": accepted.application_id, - "status": accepted.status, - }, - application_id=accepted.application_id, - ) + with self.store.transaction(): + self.link_identity( + actor, + invitation.user_id, + issuer=actor.issuer, + subject=actor.subject, + provider=actor.authorized_party, + correlation_id=correlation_id, + ) + account = self.set_account_status( + actor, + invitation.user_id, + AccountStatus.ACTIVE, + correlation_id=correlation_id, + ) + self.set_tenant_account_status( + actor, + invitation.user_id, + AccountStatus.ACTIVE, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + ) + accepted_at = utc_now() + accepted = replace( + invitation, + status=InvitationStatus.ACCEPTED, + updated_at=accepted_at, + accepted_at=accepted_at, + ) + self.store.save_family_invitation(accepted) + self._record_mutation( + actor, + action="family_invitation.accept", + subject=accepted.invitation_id, + tenant=tenant_context.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="family_invitation.accepted", + aggregate_id=accepted.invitation_id, + payload={ + "invitation_id": accepted.invitation_id, + "user_id": accepted.user_id, + "tenant": accepted.tenant, + "family_scope_id": accepted.family_scope_id, + "application_id": accepted.application_id, + "status": accepted.status, + }, + application_id=accepted.application_id, + ) session = self._session(actor, self._require_user(accepted.user_id), account) identity_context = self.identity_context( actor, @@ -1241,43 +1254,33 @@ class UserEngineService: ) def audit_records(self) -> tuple[AuditRecord, ...]: - return tuple(self.store.audit_records) + return self.store.audit_log() def outbox_events(self) -> tuple[OutboxEvent, ...]: - return tuple(self.store.outbox_events) + return self.store.pending_outbox() def outbox_diagnostics(self) -> OutboxDiagnostics: event_types: dict[str, int] = {} - for event in self.store.outbox_events: + pending = self.store.pending_outbox() + for event in pending: event_types[event.event_type] = event_types.get(event.event_type, 0) + 1 - oldest = self.store.outbox_events[0].correlation_id if self.store.outbox_events else None + oldest = pending[0].correlation_id if pending else None return OutboxDiagnostics( - pending_count=len(self.store.outbox_events), + pending_count=len(pending), event_types=event_types, oldest_correlation_id=oldest, ) def operability_snapshot(self) -> OperabilitySnapshot: audit_correlation_ok = all( - bool(record.correlation_id) for record in self.store.audit_records + bool(record.correlation_id) for record in self.store.audit_log() ) checks = { "ready": self.readiness().ready, "audit_correlation": audit_correlation_ok, "outbox_diagnostics": self.outbox_diagnostics().pending_count >= 0, } - metrics = { - "users": len(self.store.users), - "accounts": len(self.store.accounts), - "tenant_accounts": len(self.store.tenant_accounts), - "memberships": len(self.store.memberships), - "applications": len(self.store.applications), - "catalogs": len(self.store.catalogs), - "family_invitations": len(self.store.family_invitations), - "profile_values": len(self.store.profile_values), - "audit_records": len(self.store.audit_records), - "pending_outbox_events": len(self.store.outbox_events), - } + metrics = self.store.record_counts() issues = tuple(key for key, passed in checks.items() if not passed) return OperabilitySnapshot( ready=all(checks.values()), @@ -1336,25 +1339,26 @@ class UserEngineService: subject=actor.subject, provider=actor.authorized_party, ) - self.store.save_user(user) - self.store.save_account(account) - self.store.save_tenant_account(tenant_account) - self.store.save_identity(external_identity) - self._record_mutation( - actor, - action="user.create_from_identity", - subject=user.user_id, - tenant=actor.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="user.created", - aggregate_id=user.user_id, - payload={ - "user_id": user.user_id, - "account_id": account.account_id, - "identity": {"issuer": actor.issuer, "subject": actor.subject}, - }, - ) + with self.store.transaction(): + self.store.save_user(user) + self.store.save_account(account) + self.store.save_tenant_account(tenant_account) + self.store.save_identity(external_identity) + self._record_mutation( + actor, + action="user.create_from_identity", + subject=user.user_id, + tenant=actor.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="user.created", + aggregate_id=user.user_id, + payload={ + "user_id": user.user_id, + "account_id": account.account_id, + "identity": {"issuer": actor.issuer, "subject": actor.subject}, + }, + ) return self._session(actor, user, account) def _ensure_family_dataspace_application( @@ -1363,10 +1367,11 @@ class UserEngineService: request: FamilyDataspaceRequest, correlation_id: str, ) -> tuple[Application, ApplicationBinding]: - binding = _family_dataspace_binding(request) - application = self.store.applications.get(request.application_id) + desired_binding = _family_dataspace_binding(request) + application = self.store.application(request.application_id) if application is not None: - if request.application_id not in self.store.bindings: + binding = self.store.binding(request.application_id) + if binding is None: decision = self._authorize( actor, action="application.bind", @@ -1376,23 +1381,25 @@ class UserEngineService: correlation_id=correlation_id, application_id=request.application_id, ) - self.store.save_binding(binding) - self._record_mutation( - actor, - action="application.bind", - subject=request.application_id, - tenant=request.tenant, - correlation_id=correlation_id, - decision_id=decision.decision_id, - event_type="application.bound", - aggregate_id=request.application_id, - payload={ - "application_id": request.application_id, - "catalog_namespaces": binding.catalog_namespaces, - }, - application_id=request.application_id, - ) - return application, self.store.bindings[request.application_id] + with self.store.transaction(): + self.store.save_binding(desired_binding) + self._record_mutation( + actor, + action="application.bind", + subject=request.application_id, + tenant=request.tenant, + correlation_id=correlation_id, + decision_id=decision.decision_id, + event_type="application.bound", + aggregate_id=request.application_id, + payload={ + "application_id": request.application_id, + "catalog_namespaces": desired_binding.catalog_namespaces, + }, + application_id=request.application_id, + ) + binding = desired_binding + return application, binding application = Application( application_id=request.application_id, @@ -1413,10 +1420,10 @@ class UserEngineService: self.register_application( actor, application, - binding=binding, + binding=desired_binding, correlation_id=correlation_id, ), - binding, + desired_binding, ) def _ensure_family_dataspace_catalog( @@ -1426,7 +1433,7 @@ class UserEngineService: correlation_id: str, ) -> Catalog: catalog_id = f"{request.application_id}.profile" - existing = self.store.catalogs.get(catalog_id) + existing = self.store.catalog(catalog_id) if existing is not None: return existing @@ -1732,7 +1739,7 @@ class UserEngineService: membership_ids = {membership.membership_id for membership in memberships} subjects = {user.user_id, account.account_id, *membership_ids} evidence = [] - for record in self.store.audit_records: + for record in self.store.audit_log(): if record.tenant != tenant: continue if record.subject not in subjects and record.summary != "membership.added": @@ -1749,11 +1756,7 @@ class UserEngineService: return tuple(evidence) def _session(self, actor: Actor, user: User, account: Account) -> UserSession: - identities = tuple( - identity - for identity in self.store.identities.values() - if identity.user_id == user.user_id - ) + identities = self.store.identities_for_user(user.user_id) return UserSession(actor=actor, user=user, account=account, identities=identities) def _authorize( @@ -1938,7 +1941,7 @@ class UserEngineService: return self.resolve_tenant_context(actor, tenant).tenant def _validate_catalog(self, catalog: Catalog) -> None: - if catalog.owning_application_id not in self.store.applications: + if self.store.application(catalog.owning_application_id) is None: raise ValidationError("catalog owning application is not registered") if catalog.lifecycle != CatalogLifecycle.ACTIVE: raise ValidationError("only active catalogs can be published") @@ -1947,11 +1950,11 @@ class UserEngineService: keys = [attribute.key for attribute in catalog.attributes] if len(keys) != len(set(keys)): raise ValidationError("catalog attribute keys must be unique") - binding = self.store.bindings.get(catalog.owning_application_id) + binding = self.store.binding(catalog.owning_application_id) if binding and catalog.namespace not in binding.catalog_namespaces: raise ValidationError("catalog namespace is not bound to application") - existing_catalog = self.store.catalogs.get(catalog.catalog_id) + existing_catalog = self.store.catalog(catalog.catalog_id) if existing_catalog is not None: self._validate_catalog_update(existing_catalog, catalog) @@ -1960,7 +1963,7 @@ class UserEngineService: ) active_catalogs = ( existing - for existing in self.store.catalogs.values() + for existing in self.store.all_catalogs() if existing.catalog_id != catalog.catalog_id and existing.lifecycle == CatalogLifecycle.ACTIVE ) @@ -2022,7 +2025,7 @@ class UserEngineService: if scope == ProfileScope.APPLICATION: if scope_id is None: raise ValidationError("application profile values require scope_id") - if scope_id not in self.store.applications: + if self.store.application(scope_id) is None: raise ValidationError("application profile scope_id is not registered") elif scope == ProfileScope.TENANT: if scope_id is None: @@ -2054,7 +2057,7 @@ class UserEngineService: raise ValidationError(f"{definition.key} is not an allowed value") def _require_user(self, user_id: str) -> User: - user = self.store.users.get(user_id) + user = self.store.user(user_id) if user is None: raise NotFoundError("user not found") return user @@ -2078,7 +2081,7 @@ class UserEngineService: owning_application_id: str | None = None, ) -> dict[str, AttributeDefinition]: definitions: dict[str, AttributeDefinition] = {} - for catalog in self.store.catalogs.values(): + for catalog in self.store.all_catalogs(): if catalog.catalog_id == excluding_catalog_id: continue if ( diff --git a/tests/test_ports_and_fixtures.py b/tests/test_ports_and_fixtures.py index 27ddb7e..8a50381 100644 --- a/tests/test_ports_and_fixtures.py +++ b/tests/test_ports_and_fixtures.py @@ -1,11 +1,26 @@ import unittest +from typing import Any -from user_engine.domain import AuthorizationEffect, AuthorizationRequest +from user_engine.adapters.local import ( + InMemoryUserEngineStore, + LocalAuthorizationCheckPort, +) +from user_engine.domain import ( + AuthorizationEffect, + AuthorizationRequest, + OutboxEvent, + ProfileScope, + ProjectionType, +) +from user_engine.errors import AuthorizationDenied +from user_engine.service import UserEngineService from user_engine.testing.fixtures import ( FixtureIdentityClaimsAdapter, StaticAuthorizationCheckPort, human_actor_claims, + sample_application, sample_application_binding, + sample_catalog, ) @@ -44,6 +59,127 @@ class PortFixtureTests(unittest.TestCase): self.assertEqual(binding.oidc_client_id, "demo-client") self.assertEqual(binding.protected_system_id, "user-engine.demo") + def test_user_engine_service_consumes_store_protocol(self): + store = _ProtocolOnlyStore(InMemoryUserEngineStore()) + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=StaticAuthorizationCheckPort(), + ) + + session = service.me(human_actor_claims(), correlation_id="corr-me") + service.register_application( + session.actor, + sample_application(), + binding=sample_application_binding(), + correlation_id="corr-app", + ) + service.publish_catalog( + session.actor, + sample_catalog(), + correlation_id="corr-catalog", + ) + service.set_profile_value( + session.actor, + session.user.user_id, + "demo.display_density", + "compact", + scope=ProfileScope.APPLICATION, + scope_id="app.demo", + application_id="app.demo", + correlation_id="corr-profile", + ) + projection = service.projection( + session.actor, + session.user.user_id, + ProjectionType.APPLICATION_RUNTIME, + application_id="app.demo", + correlation_id="corr-projection", + ) + + self.assertEqual(projection.values["demo.display_density"], "compact") + self.assertTrue(service.operability_snapshot().ready) + + def test_store_transaction_rolls_back_failed_mutation(self): + store = _FailingOutboxStore() + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=StaticAuthorizationCheckPort(), + ) + + with self.assertRaises(RuntimeError): + service.me(human_actor_claims(), correlation_id="corr-fail") + + self.assertEqual(store.record_counts()["users"], 0) + self.assertEqual(store.audit_log(), ()) + self.assertEqual(store.pending_outbox(), ()) + self.assertIsNone( + store.find_identity("https://issuer.example.test", "user-123") + ) + + def test_denial_audit_survives_outer_transaction_rollback(self): + store = InMemoryUserEngineStore() + service = UserEngineService( + store=store, + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort( + action_effects={"membership.write": AuthorizationEffect.DENY} + ), + ) + session = service.me(human_actor_claims(), correlation_id="corr-me") + before_audit = len(store.audit_log()) + before_outbox = len(store.pending_outbox()) + + with self.assertRaises(AuthorizationDenied): + with store.transaction(): + service.add_membership( + session.actor, + session.user.user_id, + tenant="tenant:coulomb", + scope_type="team", + scope_id="team:demo", + kind="member", + correlation_id="corr-denied-membership", + ) + + self.assertEqual(len(store.audit_log()), before_audit + 1) + self.assertEqual(store.audit_log()[-1].summary, "authorization denied") + self.assertEqual(len(store.pending_outbox()), before_outbox) + self.assertEqual(store.record_counts()["memberships"], 0) + + +class _ProtocolOnlyStore: + """Proxy that fails if service code reaches for local collection fields.""" + + _blocked_fields = { + "accounts", + "applications", + "audit_records", + "bindings", + "catalogs", + "family_invitations", + "identities", + "memberships", + "outbox_events", + "profile_values", + "tenant_accounts", + "users", + } + + def __init__(self, inner: InMemoryUserEngineStore) -> None: + self._inner = inner + + def __getattr__(self, name: str) -> Any: + if name in self._blocked_fields: + raise AssertionError(f"service accessed concrete store field {name}") + return getattr(self._inner, name) + + +class _FailingOutboxStore(InMemoryUserEngineStore): + def append_outbox(self, event: OutboxEvent) -> None: + raise RuntimeError("outbox unavailable") + if __name__ == "__main__": unittest.main() diff --git a/workplans/USER-WP-0009-postgres-durable-store-requirements.md b/workplans/USER-WP-0009-postgres-durable-store-requirements.md index 25f03d4..5672175 100644 --- a/workplans/USER-WP-0009-postgres-durable-store-requirements.md +++ b/workplans/USER-WP-0009-postgres-durable-store-requirements.md @@ -4,13 +4,13 @@ type: workplan title: "Postgres Durable Store Consumer Requirements" domain: netkingdom repo: user-engine -status: proposed +status: finished owner: codex topic_slug: netkingdom planning_priority: high planning_order: 9 created: "2026-06-05" -updated: "2026-06-05" +updated: "2026-06-15" depends_on: - USER-WP-0007 state_hub_workstream_id: "b5c85993-4aa2-4a8d-98b6-d174ab1b4538" @@ -22,9 +22,11 @@ state_hub_workstream_id: "b5c85993-4aa2-4a8d-98b6-d174ab1b4538" Define, from the `user-engine` consumer perspective, what a durable Postgres-backed store must provide before user-engine depends on it in -NetKingdom. This workplan is requirements-only: it should not implement the -Postgres adapter, provision databases, create tenant infrastructure, or choose -the final provider repository design. +NetKingdom. The 2026-06-15 review also identified and closed one missing +durable-store contract in this repository: `UserEngineService` now consumes an +adapter-neutral store protocol instead of the concrete in-memory store. This +workplan still does not implement the Postgres adapter, provision databases, +create tenant infrastructure, or choose the final provider repository design. ## Scope Direction @@ -51,7 +53,7 @@ schema, migrations for its own tables, store semantics, and conformance tests. ```task id: USER-WP-0009-T1 -status: todo +status: done priority: high state_hub_task_id: "64c578e1-e2a1-48d4-8da9-659d4f881ef3" ``` @@ -64,7 +66,7 @@ schema version reporting. ```task id: USER-WP-0009-T2 -status: todo +status: done priority: high state_hub_task_id: "19cfd23e-8a87-416d-b948-c727e8c5a11c" ``` @@ -76,7 +78,7 @@ security, observability, backup/restore expectations, and acceptance tests. ```task id: USER-WP-0009-T3 -status: todo +status: done priority: high state_hub_task_id: "d3b388de-bb79-41d5-805e-d2def88ac926" ``` @@ -88,7 +90,7 @@ secrets, authorization, or audit-platform concerns. ```task id: USER-WP-0009-T4 -status: todo +status: done priority: medium state_hub_task_id: "d0e05af7-d777-4948-b072-79f1ffb9fc3a" ``` @@ -99,7 +101,7 @@ the isolated MVP without leaking Postgres concepts into domain code. ```task id: USER-WP-0009-T5 -status: todo +status: done priority: medium state_hub_task_id: "3c428960-be5b-411e-bd9b-7cba833abba8" ``` @@ -111,7 +113,7 @@ readiness, and redacted diagnostics. ```task id: USER-WP-0009-T6 -status: todo +status: done priority: medium state_hub_task_id: "d606094a-254c-46d5-9bb8-a3449ce61c2c" ``` @@ -133,10 +135,46 @@ expectations, encryption, and operational runbooks. tests. - The provider-repo boundary is explicit and avoids duplicating IAM, secrets, authorization, audit-platform, or infrastructure ownership. +- `UserEngineService` depends on an adapter-neutral store protocol with + readiness, query, transaction, audit, outbox, and diagnostics semantics. - No Postgres implementation code is added as part of this workplan. ## Expected Outputs - `docs/postgres-durable-store-consumer-requirements.md` - Store-boundary notes suitable for a future provider repo. +- `UserEngineStore` protocol and local-store conformance behavior. - Follow-up implementation workplan inputs for a Postgres adapter. + +## Implementation Notes + +Implemented on 2026-06-15: + +- Added `UserEngineStore` in `src/user_engine/ports.py` as the durable + persistence boundary for service behavior. +- Moved `UserEngineService` from the concrete in-memory store type to the + store protocol. +- Replaced service reads of local dict/list fields with protocol accessors for + users, identities, applications, bindings, catalogs, audit, outbox, and + diagnostics. +- Added store transaction boundaries around mutating writes so domain changes, + local audit records, and outbox events commit or roll back together. +- Kept authorization-denial audit records durable without emitting outbox + events, including when a denial happens inside a composed outer transaction. +- Extended `InMemoryUserEngineStore` as the reference adapter with query + helpers, record counts, pending outbox access, audit-log access, and nested + transaction rollback semantics. +- Added conformance tests for protocol-only store consumption, failed-mutation + rollback, and denial-audit persistence across rollback. +- Updated the durable-store and public contract docs to describe the new + adapter boundary. +- No Postgres adapter, database dependency, provisioning, credentials, or + infrastructure ownership was added. + +Verification: + +```text +make test +Ran 42 tests in 0.134s +OK +``` diff --git a/workplans/USER-WP-0010-registration-identity-and-factor-model.md b/workplans/USER-WP-0010-registration-identity-and-factor-model.md new file mode 100644 index 0000000..9bec685 --- /dev/null +++ b/workplans/USER-WP-0010-registration-identity-and-factor-model.md @@ -0,0 +1,125 @@ +--- +id: USER-WP-0010 +type: workplan +title: "Registration Identity And Factor Model" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: high +planning_order: 10 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0007 + - USER-WP-0009 +state_hub_workstream_id: "0d53560b-2b9d-442b-9328-4b2ce5c5bdae" +--- + +# USER-WP-0010 - Registration Identity And Factor Model + +## Goal + +Define and implement the first headless registration domain slice for +NetKingdom users. The slice should let user-engine start and complete a +registration session, establish a stable NetKingdom ID, link verified external +identities, record factor evidence, and return identity context without +becoming an identity provider or factor-proofing service. + +## Scope Direction + +user-engine owns the registration-domain records and service facade. NetKingdom +IAM, identity providers, eID providers, mail/SMS proofing, credential +lifecycle, sessions, and tokens remain external adapter concerns. + +## Non-Goals + +- Do not implement password, passkey, session, MFA, SMS, email, or eID proofing + providers in user-engine. +- Do not issue OIDC/SAML tokens. +- Do not build the registration UI in this workplan. +- Do not implement prepared account claiming, access profiles, or onboarding + journeys beyond the hooks needed for later workplans. + +## Tasks + +```task +id: USER-WP-0010-T1 +status: todo +priority: high +state_hub_task_id: "2a6c93de-e320-41e6-8930-7a4099c5757a" +``` + +Define NetKingdom ID semantics. Decide whether the public NetKingdom ID is the +existing `User.user_id`, an alias, or a separate mapped identifier. Document +stability, visibility, privacy, and migration expectations. + +```task +id: USER-WP-0010-T2 +status: todo +priority: high +state_hub_task_id: "31ddb44e-b7d1-406e-9114-78c5e7f92478" +``` + +Add registration session domain models and lifecycle states: started, +factor_pending, factor_verified, completed, abandoned, expired, and rejected. + +```task +id: USER-WP-0010-T3 +status: todo +priority: high +state_hub_task_id: "7441f064-eb49-4e66-8c1d-a2626aae020c" +``` + +Add identity factor and factor verification models for email, phone, postal +address, eID, invite, and SSO identity evidence. Store assurance metadata and +evidence references without storing secret proofing payloads. + +```task +id: USER-WP-0010-T4 +status: todo +priority: high +state_hub_task_id: "7057afda-d585-48cd-bac1-f0bd0f05fef5" +``` + +Create factor verification adapter ports. The adapters should accept external +proofing results and return normalized factor evidence for user-engine. + +```task +id: USER-WP-0010-T5 +status: todo +priority: high +state_hub_task_id: "f4f0da38-9810-45e7-ab4e-0619eb45b3c4" +``` + +Implement a headless registration facade for start, attach verified factor, +complete, abandon, and resume flows. + +```task +id: USER-WP-0010-T6 +status: todo +priority: medium +state_hub_task_id: "c29b31cd-f2b2-41b6-86ee-9c78470abf01" +``` + +Add audit, outbox, diagnostics, and redaction behavior for registration and +factor lifecycle transitions. + +## Acceptance Criteria + +- A caller can start and complete a headless registration flow from verified + factor evidence. +- Completed registration creates or resolves a stable NetKingdom user/account + and external identity links. +- Factor evidence is inspectable through safe metadata and evidence references, + not raw proofing secrets. +- Registration failure, expiry, and abandon states are auditable. +- No credential, token, or proofing provider ownership moves into user-engine. + +## Expected Outputs + +- Registration and factor domain models. +- Registration service facade. +- Factor verification adapter ports. +- Documentation and tests for the basic self-registration flow. diff --git a/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md b/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md new file mode 100644 index 0000000..9494484 --- /dev/null +++ b/workplans/USER-WP-0011-prepared-accounts-and-entitlement-claims.md @@ -0,0 +1,124 @@ +--- +id: USER-WP-0011 +type: workplan +title: "Prepared Accounts And Entitlement Claims" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: high +planning_order: 11 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0010 +state_hub_workstream_id: "39ac9f87-c61d-42d8-a45f-bece4848ed47" +--- + +# USER-WP-0011 - Prepared Accounts And Entitlement Claims + +## Goal + +Allow NetKingdom operators, tenant admins, family owners, service owners, or +upstream systems to prepare account intent and access packages before the user +registers. When the user later proves matching factors, user-engine can attach +the prepared package to the canonical user and activate the right lifecycle +steps. + +## Scope Direction + +Prepared accounts are not credentials. They are pending user-domain facts: +expected factor matches, tenant or group references, planned memberships, +profile defaults, onboarding journey hints, approval gates, expiry, and audit +history. + +## Non-Goals + +- Do not create login credentials for users who have not registered. +- Do not bypass factor verification or approval policies. +- Do not make user-engine the source of truth for external organization, HR, or + directory records. +- Do not implement final authorization policy decisions. + +## Tasks + +```task +id: USER-WP-0011-T1 +status: todo +priority: high +state_hub_task_id: "11508f77-170b-4b22-bfdc-115a69bfe4db" +``` + +Add prepared account and prepared entitlement models with status, expiry, +preparer identity, tenant/scope references, factor match requirements, and +audit metadata. + +```task +id: USER-WP-0011-T2 +status: todo +priority: high +state_hub_task_id: "86ca36d4-721b-48fe-8c0c-c6a1e6740d2f" +``` + +Implement create, update, revoke, expire, and list operations for prepared +accounts, guarded by the authorization port. + +```task +id: USER-WP-0011-T3 +status: todo +priority: high +state_hub_task_id: "fe5a08e8-1101-4cec-b02f-b2eee8928604" +``` + +Implement claim matching during registration. Match verified factor evidence to +prepared account requirements and produce explicit claim decisions. + +```task +id: USER-WP-0011-T4 +status: todo +priority: high +state_hub_task_id: "8aef6d9e-5e76-4e44-bf81-58049b22a25c" +``` + +Convert claimed prepared entitlements into user-engine-owned facts: +memberships, tenant accounts, profile defaults, application bindings, and +onboarding journey starts. + +```task +id: USER-WP-0011-T5 +status: todo +priority: medium +state_hub_task_id: "527519a1-48ed-45fc-a6fc-739986ae6303" +``` + +Add conflict and safety rules for duplicate prepared accounts, weak factor +matches, expired packages, privileged roles, and manual approval requirements. + +```task +id: USER-WP-0011-T6 +status: todo +priority: medium +state_hub_task_id: "9530c8d6-82af-4635-8af8-aa79c54be94d" +``` + +Add audit/outbox events and evidence references for preparation, claim, +activation, denial, expiry, and revocation. + +## Acceptance Criteria + +- A prepared account can be created before user registration without issuing + credentials. +- A registering user can claim prepared rights only when required factor + evidence matches. +- Claimed rights become explicit user-engine memberships, profile values, + tenant account state, and onboarding events. +- Expired, revoked, ambiguous, or privileged claims fail closed. +- Every preparation and claim decision is auditable. + +## Expected Outputs + +- Prepared account domain model. +- Prepared entitlement activation facade. +- Claim matching rules and tests. +- Documentation for account preparation boundaries. diff --git a/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md b/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md new file mode 100644 index 0000000..13de4a3 --- /dev/null +++ b/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md @@ -0,0 +1,118 @@ +--- +id: USER-WP-0012 +type: workplan +title: "Hats, Realms, Services, Assets, And Access Profiles" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: high +planning_order: 12 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0010 +state_hub_workstream_id: "f3cf0d30-eb6b-4734-a0a3-5a755d4cf150" +--- + +# USER-WP-0012 - Hats, Realms, Services, Assets, And Access Profiles + +## Goal + +Model how users and groups wear different hats across NetKingdom realms, +services, and assets. Provide access-control facts, profile layers, and +claims-enrichment context that authorization systems and service runtimes can +consume without moving final policy decisions into user-engine. + +## Scope Direction + +user-engine owns the identity-domain representation of hats, memberships, +access profiles, and active context. Authorization engines own policy decisions +and protected services own runtime enforcement. + +## Non-Goals + +- Do not implement the final ACL enforcement engine. +- Do not define every service-specific permission in user-engine. +- Do not bypass the authorization port. +- Do not make browser/UI state the source of truth for active access context. + +## Tasks + +```task +id: USER-WP-0012-T1 +status: todo +priority: high +state_hub_task_id: "b86f0072-e666-479b-9b90-96d4015bbfa0" +``` + +Define realm, service area, asset scope, access profile, group, and hat +vocabulary. Map each concept to current user-engine membership, profile, and +canon reference patterns. + +```task +id: USER-WP-0012-T2 +status: todo +priority: high +state_hub_task_id: "66117083-8e85-44e1-9a76-cfd10dd24d23" +``` + +Add hat selection and active context models. A user should be able to choose an +active hat for a tenant, realm, service, or asset context when allowed. + +```task +id: USER-WP-0012-T3 +status: todo +priority: high +state_hub_task_id: "1dffda4c-f979-480e-9d6d-12ec9576780d" +``` + +Implement access profile templates that combine memberships, factor assurance +requirements, profile defaults, and claims projection rules. + +```task +id: USER-WP-0012-T4 +status: todo +priority: high +state_hub_task_id: "b07494fe-f301-49e2-8ea8-267a4c5219ee" +``` + +Extend `identity_context` and claims-enrichment projections with active hat, +realm, service, asset, group, access profile, and evidence references. + +```task +id: USER-WP-0012-T5 +status: todo +priority: medium +state_hub_task_id: "c78e10c4-b245-4a83-a75d-4b46a6073fd2" +``` + +Add ports for exporting access-control facts to authorization engines or ACL +systems while preserving source-of-truth boundaries. + +```task +id: USER-WP-0012-T6 +status: todo +priority: medium +state_hub_task_id: "f9f32165-3a12-424e-a370-bb2ab8348c21" +``` + +Add tests for hat selection, cross-tenant denial, missing factor assurance, +group-derived access, service-specific projection, and redacted diagnostics. + +## Acceptance Criteria + +- Users can have multiple hats without collapsing them into one account state. +- Active hat context is explicit in identity context and projections. +- Access profile facts can be exported to authorization systems. +- Missing tenant, realm, service, asset, factor, or approval context fails + closed. +- Final policy and ACL enforcement remain outside user-engine. + +## Expected Outputs + +- Hat and access profile domain model. +- Active context service facade. +- Identity-context and claims projection updates. +- Access-control fact export tests. diff --git a/workplans/USER-WP-0013-onboarding-journeys-and-welcome-protocols.md b/workplans/USER-WP-0013-onboarding-journeys-and-welcome-protocols.md new file mode 100644 index 0000000..a29f3ec --- /dev/null +++ b/workplans/USER-WP-0013-onboarding-journeys-and-welcome-protocols.md @@ -0,0 +1,107 @@ +--- +id: USER-WP-0013 +type: workplan +title: "Onboarding Journeys And Welcome Protocols" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: medium +planning_order: 13 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0011 + - USER-WP-0012 +state_hub_workstream_id: "1dc82dfd-be68-4585-b6c9-6d24aebd3e27" +--- + +# USER-WP-0013 - Onboarding Journeys And Welcome Protocols + +## Goal + +Create a journey layer that helps newly registered or newly entitled users +enter the right NetKingdom subsystems. Welcome protocols should be driven by +registration, prepared-account, invitation, role, profile, and access events. + +## Scope Direction + +user-engine owns journey state, task references, event correlation, and user +context. Delivery systems, protected services, help content, notification +channels, and external task systems remain adapters or downstream systems. + +## Non-Goals + +- Do not build a notification platform. +- Do not embed service-specific tours or support content in core domain code. +- Do not replace external workflow/task systems. +- Do not build the UI in this workplan. + +## Tasks + +```task +id: USER-WP-0013-T1 +status: todo +priority: high +state_hub_task_id: "30ef8507-eebc-4b96-8aa6-c530bef05739" +``` + +Define onboarding journey, welcome protocol, journey step, task, and subsystem +handoff models. + +```task +id: USER-WP-0013-T2 +status: todo +priority: high +state_hub_task_id: "7c6e53d4-ff96-4036-a413-f04b4b73d266" +``` + +Add journey templates keyed by registration outcome, prepared entitlement, +tenant, realm, service, application, role, hat, and factor requirements. + +```task +id: USER-WP-0013-T3 +status: todo +priority: high +state_hub_task_id: "d9c2983a-45d1-4b1b-a416-63e180ca74b3" +``` + +Implement journey start, progress, complete, skip, fail, and resume operations +with authorization, audit, and outbox behavior. + +```task +id: USER-WP-0013-T4 +status: todo +priority: medium +state_hub_task_id: "7155c2eb-4e32-46f0-ad33-961784cb9a03" +``` + +Add adapter ports for notifications, task systems, support content, subsystem +welcome callbacks, and lifecycle task linking. + +```task +id: USER-WP-0013-T5 +status: todo +priority: medium +state_hub_task_id: "c5e42dd6-207a-4b1e-a0d8-35701e9f71bc" +``` + +Expose onboarding status through identity context, diagnostics, and optional UI +contracts. + +## Acceptance Criteria + +- Registration or prepared-account claim can start an onboarding journey. +- Journey state is resumable, auditable, and correlated with outbox events. +- Subsystem welcome steps are adapter-driven, not hard-coded into core + registration logic. +- Users and admins can inspect pending onboarding work and blocked steps. +- Missing subsystem callbacks produce explicit lifecycle gaps. + +## Expected Outputs + +- Onboarding journey domain model. +- Welcome protocol service facade. +- Adapter ports for notifications and subsystem handoff. +- Scenario tests for successful, blocked, and resumed onboarding. diff --git a/workplans/USER-WP-0014-registration-and-access-management-ui.md b/workplans/USER-WP-0014-registration-and-access-management-ui.md new file mode 100644 index 0000000..676c3cc --- /dev/null +++ b/workplans/USER-WP-0014-registration-and-access-management-ui.md @@ -0,0 +1,125 @@ +--- +id: USER-WP-0014 +type: workplan +title: "Registration And Access Management UI" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: medium +planning_order: 14 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0010 + - USER-WP-0011 + - USER-WP-0012 + - USER-WP-0013 +state_hub_workstream_id: "011f7d20-5c9d-42a9-b7a3-b20a8ae9f557" +--- + +# USER-WP-0014 - Registration And Access Management UI + +## Goal + +Build an optional NetKingdom registration and access management UI backed by +user-engine APIs. The UI should make registration, factor status, prepared +rights, hat selection, profile completion, and onboarding journeys convenient +without hiding IAM, authorization, proofing, or service-runtime boundaries. + +## Scope Direction + +The UI is an operating surface over user-engine domain APIs. It should be thin, +workflow-oriented, and suitable for self-service users, tenant admins, family +owners, and operators. + +## Non-Goals + +- Do not implement credential entry, password reset, passkeys, MFA challenges, + or token issuance in the UI. +- Do not embed final authorization policy rules in frontend code. +- Do not replace service-specific admin consoles. +- Do not make UI state authoritative over domain records. + +## Tasks + +```task +id: USER-WP-0014-T1 +status: todo +priority: high +state_hub_task_id: "983087e1-c512-419f-86a6-b954d0a1ab54" +``` + +Define UI information architecture for registration, factor status, +prepared-account claim, hat selection, profile completion, onboarding journey, +and admin setup views. + +```task +id: USER-WP-0014-T2 +status: todo +priority: high +state_hub_task_id: "0af5d8ef-0d1e-44bd-b807-bc40e87afef2" +``` + +Define UI API contracts or route handlers over the headless service facades. +Keep proofing, IAM, authorization, and notification calls behind adapters. + +```task +id: USER-WP-0014-T3 +status: todo +priority: high +state_hub_task_id: "a2e00aa3-5849-469c-a3a3-f4f5bd2df6c8" +``` + +Implement the self-service registration flow with resume, prepared rights +review, factor status, terms/consent, and completion states. + +```task +id: USER-WP-0014-T4 +status: todo +priority: medium +state_hub_task_id: "36d49049-cfe7-4f87-9a7f-78e37de9188a" +``` + +Implement hat selection and active access context views for realms, services, +groups, and assets. + +```task +id: USER-WP-0014-T5 +status: todo +priority: medium +state_hub_task_id: "e58038fc-6138-40cc-bb6b-4cbf7a8b0b87" +``` + +Implement admin views for prepared accounts, invitations, access profiles, +group membership, realms/services/assets, and onboarding diagnostics. + +```task +id: USER-WP-0014-T6 +status: todo +priority: medium +state_hub_task_id: "4de949d6-e330-41b2-87cf-9b9425f0f8be" +``` + +Add usability, accessibility, error-state, redaction, and mobile/desktop tests +for the registration and admin flows. + +## Acceptance Criteria + +- A new user can complete a registration flow through the UI using adapter + supplied factor evidence. +- A prepared account claim can be reviewed and accepted or denied through the + UI. +- Users can choose an active hat and see available realms/services without + exposing internal policy logic. +- Admins can prepare accounts and inspect onboarding state. +- The UI does not store or display secrets, raw proofing payloads, or hidden + authorization decisions. + +## Expected Outputs + +- Registration UI and API contract. +- Hat/access management UI views. +- Admin prepared-account and onboarding views. +- Frontend verification artifacts. diff --git a/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md b/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md new file mode 100644 index 0000000..ba99b76 --- /dev/null +++ b/workplans/USER-WP-0015-registration-scenario-and-security-conformance.md @@ -0,0 +1,121 @@ +--- +id: USER-WP-0015 +type: workplan +title: "Registration Scenario And Security Conformance" +domain: netkingdom +repo: user-engine +status: proposed +owner: codex +topic_slug: netkingdom +planning_priority: medium +planning_order: 15 +created: "2026-06-15" +updated: "2026-06-15" +depends_on: + - USER-WP-0010 + - USER-WP-0011 + - USER-WP-0012 + - USER-WP-0013 + - USER-WP-0014 +state_hub_workstream_id: "4f21e1c9-ad27-4ac9-888f-8f78c6abfb3b" +--- + +# USER-WP-0015 - Registration Scenario And Security Conformance + +## Goal + +Prove the full NetKingdom registration and onboarding model through executable +scenarios, security negative paths, redaction checks, adapter conformance, and +operability diagnostics. + +## Scope Direction + +This workplan turns the registration roadmap into a testable contract. It +should cover both headless APIs and the optional UI surface where present. + +## Non-Goals + +- Do not add new product surface unless a test exposes a missing contract. +- Do not assert provider-specific IAM, eID, SMS, email, or authorization engine + internals. +- Do not require production infrastructure for local conformance tests. + +## Tasks + +```task +id: USER-WP-0015-T1 +status: todo +priority: high +state_hub_task_id: "5ca0a269-559d-4138-b702-9984a411f2ed" +``` + +Define the registration scenario matrix: self-registration, prepared account +claim, privileged role requiring approval, eID-backed assurance, family invite, +tenant admin invite, group access, and denied cross-tenant claim. + +```task +id: USER-WP-0015-T2 +status: todo +priority: high +state_hub_task_id: "6ee492b1-923f-4aa0-8e17-b69f522c4898" +``` + +Add end-to-end headless tests covering registration through identity context, +claims enrichment, active hat selection, and onboarding event emission. + +```task +id: USER-WP-0015-T3 +status: todo +priority: high +state_hub_task_id: "b813a88f-ced6-40ce-9a25-d1c666fb73c9" +``` + +Add security negative tests for weak factor evidence, duplicate identity links, +prepared-account hijack attempts, expired claims, missing tenant context, +privileged role escalation, and stale approvals. + +```task +id: USER-WP-0015-T4 +status: todo +priority: medium +state_hub_task_id: "5a03ac1a-1f8e-455b-8f75-691e8bdda286" +``` + +Add redaction and diagnostics tests for factor values, profile sensitivity, +prepared-account metadata, active hat context, and access-profile evidence. + +```task +id: USER-WP-0015-T5 +status: todo +priority: medium +state_hub_task_id: "fcf32b4d-d050-4989-bb05-844e0d13e548" +``` + +Add adapter conformance tests for factor verification, authorization checks, +access fact export, onboarding handoff, audit export, outbox replay, and +durable store behavior. + +```task +id: USER-WP-0015-T6 +status: todo +priority: medium +state_hub_task_id: "a7850784-3b86-453f-bbc7-1d53d0813f82" +``` + +Add UI flow tests once USER-WP-0014 exists: registration happy path, resume, +prepared rights review, hat selection, admin preparation, and blocked journey. + +## Acceptance Criteria + +- The main registration and onboarding journeys are executable as tests. +- Security negative paths fail closed and leave audit evidence. +- Sensitive factor and profile data is redacted from diagnostics and UI output. +- Adapter contracts are testable without production infrastructure. +- The registration UI, if implemented, is covered by workflow-level tests. + +## Expected Outputs + +- Registration scenario matrix. +- Headless and UI conformance tests. +- Security negative-path test suite. +- Adapter conformance harness for registration dependencies.