Compare commits

..

21 Commits

Author SHA1 Message Date
9c6ad74f6b Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:28 +02:00
3544a1b9d6 Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:43 +02:00
d13bc3ad8a chore: sync user wp 0019 statehub ids 2026-06-16 07:34:49 +02:00
a1692c62e3 test: add provider postgres conformance 2026-06-16 07:33:34 +02:00
1f2ac6666f chore: sync user wp 0018 statehub ids 2026-06-16 07:16:42 +02:00
0d50ad294d feat: add postgres user engine store 2026-06-16 07:14:37 +02:00
c494511a2e chore: sync user wp 0017 statehub ids 2026-06-16 03:45:31 +02:00
6810d9a3aa feat: add durable store record serialization 2026-06-16 03:43:55 +02:00
abb3c5bd34 chore: sync user wp 0016 task ids 2026-06-16 03:37:11 +02:00
32cf819305 chore: sync user wp 0016 statehub id 2026-06-16 03:27:44 +02:00
d6e23aadda Add capability registry scaffold (REUSE-WP-0014-T08 B06) 2026-06-16 02:02:43 +02:00
886874d0f6 feat: add durable store conformance harness 2026-06-16 00:20:29 +02:00
2ceecf6463 test: add registration security conformance 2026-06-15 23:59:45 +02:00
aaefa48212 feat: add registration access ui contracts 2026-06-15 23:39:34 +02:00
5d7685dc8d feat: implement onboarding journeys 2026-06-15 23:24:59 +02:00
660ce24995 feat: implement access profiles and hats 2026-06-15 23:12:25 +02:00
97cd03b551 feat: implement prepared account claims 2026-06-15 22:37:31 +02:00
a36a25898e Implement registration identity model 2026-06-15 22:06:39 +02:00
2c94b40fc4 Implement durable store contract and registration roadmap 2026-06-15 16:33:24 +02:00
05596146c8 Restore USER-WP-0002 StateHub refs 2026-06-05 19:19:33 +02:00
3cbe281335 Sync USER-WP-0008 and USER-WP-0009 with StateHub 2026-06-05 18:54:27 +02:00
76 changed files with 12690 additions and 495 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=user-engine` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("communication")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/communication/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/communication/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/USER-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured communication into N workstreams, M tasks",
event_type="milestone",
topic_id="a6c6e745-bf54-4465-9340-1534a2be493e",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **user-engine** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Headless user-domain/profile service for accounts, preferences, memberships, catalogs, projections, audit, and events.
**Domain:** communication
**Repo slug:** user-engine
**Topic ID:** a6c6e745-bf54-4465-9340-1534a2be493e

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("communication")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="user-engine", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=user-engine&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `communication` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:user-engine]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"a6c6e745-bf54-4465-9340-1534a2be493e","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=user-engine
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=user-engine
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/USER-WP-NNNN-<slug>.md`
ID prefix: `USER-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-USER-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:user-engine]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: USER-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

16
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,16 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: agent
category: project
domain: communication
secondary_domains: []
capability_tags: []
business_stake:
- product
- experience
- technology
business_mechanics:
- coordination
- operation

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# user-engine — Agent Instructions
## Repo Identity
**Purpose:** Headless user-domain/profile service for accounts, preferences, memberships, catalogs, projections, audit, and events.
**Domain:** communication
**Repo slug:** user-engine
**Topic ID:** `a6c6e745-bf54-4465-9340-1534a2be493e`
**Workplan prefix:** `USER-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=a6c6e745-bf54-4465-9340-1534a2be493e&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=user-engine&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=user-engine&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=user-engine
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=user-engine` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/USER-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-USER-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: USER-WP-NNNN
type: workplan
title: "..."
domain: communication
repo: user-engine
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: USER-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=user-engine`
(or send a message to the hub agent via `POST /messages/`)

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# user-engine — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -9,7 +9,14 @@ 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/registration-identity-and-factor-model.md`,
`docs/prepared-accounts-and-entitlement-claims.md`,
`docs/hats-realms-services-assets-access-profiles.md`,
`docs/onboarding-journeys-and-welcome-protocols.md`,
`docs/registration-and-access-management-ui.md`, `docs/scenarios.md`,
`docs/registration-scenario-and-security-conformance.md`,
`docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, canon mappings, examples, and release

View File

@@ -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,12 @@ 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` implements the first headless
registration and factor-evidence slice. `USER-WP-0011` implements prepared
accounts and entitlement claims. `USER-WP-0012` implements hats, realms,
services, assets, access profiles, active context, and exportable
access-control facts. `USER-WP-0013` implements onboarding journeys and
welcome protocols. `USER-WP-0014` implements the optional registration and
access-management UI contract facade. `USER-WP-0015` implements registration
scenario and security conformance tests.

View File

@@ -6,6 +6,20 @@
HTTP or RPC adapters should preserve these operation names:
- `health`, `readiness`, `operability_snapshot`, `outbox_diagnostics`
- `start_registration`, `attach_registration_factor`, `complete_registration`,
`abandon_registration`, `expire_registration`, `resume_registration`,
`registration_diagnostics`
- `prepare_account`, `update_prepared_account`, `list_prepared_accounts`,
`revoke_prepared_account`, `expire_prepared_account`,
`claim_prepared_account`
- `register_access_profile`, `list_access_profiles`, `select_active_hat`,
`export_access_control_facts`, `access_profile_diagnostics`
- `register_welcome_protocol`, `list_welcome_protocols`,
`start_onboarding_journey`, `start_onboarding_for_registration`,
`start_onboarding_for_prepared_account`, `progress_onboarding_step`,
`complete_onboarding_step`, `skip_onboarding_step`,
`fail_onboarding_step`, `resume_onboarding_journey`,
`onboarding_diagnostics`
- `me`, `create_user`, `set_account_status`, `link_identity`
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
`tenant_diagnostics`
@@ -16,12 +30,120 @@ HTTP or RPC adapters should preserve these operation names:
`accept_family_invitation`
- `audit_records`, `outbox_events`
## UI Contract Surface
`RegistrationAccessManagementUi` is the optional UI-facing contract facade for
registration and access management. It returns screen/view models and route
definitions over `UserEngineService`; transport adapters may serve those as
HTTP, RPC, desktop, CLI, or rendered HTML.
The facade covers self-service registration, factor status, terms/consent,
prepared-rights review and claim, active hat selection, admin diagnostics, and
accessible HTML verification. It does not handle credential entry, MFA
challenges, token issuance, hidden policy decisions, notifications, or
service-specific admin consoles.
## Scenario And Security Conformance Contract
`user_engine.testing.scenarios` defines `SCENARIO_MATRIX` and
`REGISTRATION_SCENARIO_MATRIX` for local conformance. The matrix covers
self-registration, prepared-account claims, privileged approval gates,
eID-backed assurance, family invite, tenant admin invite, group access,
cross-tenant denial, and USER-WP-0014 UI workflows.
Conformance tests must run without production IAM, proofing, notification,
workflow, authorization-engine, or database infrastructure. They exercise
adapter seams with local harnesses and assert fail-closed behavior, audit
evidence, outbox replay, redaction, and durable transaction semantics.
## Registration Contract
Registration is a headless user-entry facade. It creates a
`RegistrationSession`, accepts safe `FactorVerification` evidence from external
proofing adapters, records persisted `IdentityFactor` metadata, and completes
the session into a stable NetKingdom ID.
The first NetKingdom ID contract is `User.user_id`: an opaque, stable user
identifier that must not encode IAM issuer/subject pairs, email addresses,
phone numbers, postal addresses, eID payloads, tenant names, or other proofing
data.
Registration completion creates or resolves a `User`, `Account`,
`TenantAccount`, and `ExternalIdentity` link for the verified actor, attaches
verified factors to that user, emits audit/outbox records, and returns
`identity_context`.
user-engine does not verify factors itself, issue credentials, perform MFA,
run eID proofing, or issue tokens. Those remain external IAM/proofing adapter
responsibilities.
## Prepared Account Contract
Prepared accounts are pending user-domain facts for people who have not yet
registered or have not yet claimed their prepared rights. They can carry
required factor matches, entitlement intent, preparer metadata, expiry, and
claim lifecycle state, but they do not create credentials.
`claim_prepared_account` requires a completed registration session and
unexpired verified `IdentityFactor` records that satisfy every prepared factor
requirement. A successful claim marks the package claimed and converts
prepared entitlements into user-engine-owned facts: tenant account state,
memberships, catalog validated profile values, application bindings, and
onboarding-request events.
Expired, revoked, claimed, mismatching, ambiguous, duplicate, or
approval-required packages fail closed. Denied claim decisions are audited
without outbox events. Mutation outbox payloads include ids, counts, statuses,
factor types, and journey names, but not normalized factor values.
## Access Profile And Hat Contract
Access profiles are tenant-scoped templates for selecting an active hat across
tenant, realm, service, asset, or group contexts. A profile combines required
memberships, required verified factor types, profile defaults, projection
claims, optional group references, and explicit realm/service/asset scope ids.
`select_active_hat` requires an active tenant account, satisfied membership
requirements, unexpired verified factor evidence, and authorization-port
approval. The selected hat is persisted as `ActiveAccessContext` and is exposed
through `identity_context` and claims-enrichment projections.
`export_access_control_facts` returns adapter-neutral `AccessControlFact`
records for authorization engines and ACL systems. These facts include direct
membership facts, group-derived facts, and active-context facts, but
user-engine still does not make final access decisions or enforce protected
service runtime policy.
Access-profile diagnostics report counts, factor requirement types, and
approval-required issues without exposing profile default values, projection
claim values, or raw factor values.
## Onboarding Journey Contract
Welcome protocols are tenant-scoped onboarding templates. They can match
registration completion, prepared-account claims, invitations, access-profile
events, or manual starts by trigger type and optional context keys.
Onboarding journeys are persisted user state. They track protocol, source
event, trigger type, ordered steps, task references, subsystem handoff
references, lifecycle gaps, active step, status, and correlation ids.
Registration completion and prepared-account claim automatically start matching
welcome protocols. Manual start/progress/complete/skip/fail/resume operations
are also exposed through `UserEngineService` and authorization-gated.
Missing required subsystem callbacks produce explicit lifecycle gaps and block
the journey. The service records audit/outbox events with ids, statuses, step
keys, source ids, and lifecycle gap identifiers, but not factor values, support
content, notification payloads, or subsystem-specific tour data.
## Identity Context Contract
`identity_context` is the first canon-facing read model for NetKingdom
identity-domain consumers. It resolves a verified actor into the local user,
account, external identity links, tenant scope, memberships, optional
application scope, optional effective profile, canon entity references,
application scope, optional effective profile, optional active access context,
exportable access-control facts, onboarding journeys, canon entity references,
relationship references, grant-like membership facts, and evidence references.
The method keeps these concepts distinct:
@@ -89,8 +211,59 @@ 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`
hook. Database-backed stores must expose equivalent readiness semantics before
they are accepted by platform adapters.
`user_engine.migrations` exposes the ordered durable-store manifest,
`LATEST_SCHEMA_VERSION`, logical record types, and adapter-neutral diagnostic
count keys. The isolated store's `SCHEMA_VERSION` is derived from that manifest
and its `migrate` hook must be idempotent. Database-backed stores must expose
equivalent readiness semantics before they are accepted by platform adapters.
Provider-backed Postgres adapters can use
`migrations/postgres/0001_user_engine_store.sql` as the bootstrap contract or
translate it into their own migration framework while preserving schema-version
tracking, logical record uniqueness, audit durability, and pending-outbox
reads.
Future adapters should run
`user_engine.testing.assert_user_engine_store_conformance(testcase, factory)`
with a factory that returns a fresh store. The harness covers readiness,
idempotent migration, core save/read/query behavior, transaction rollback,
outbox ordering, and diagnostics that expose counts without raw factor or
profile values.
`user_engine.store_records` defines the JSONB serialization contract for the
generic record table. `store_record_for` turns supported domain dataclasses
into `StoreRecord` envelopes with deterministic keys and index metadata, while
`domain_record_from_store_record` restores those payloads to domain objects.
These payloads are durable state and may contain sensitive values, so they must
not be emitted as diagnostics.
`user_engine.adapters.postgres.PostgresUserEngineStore` is the optional
Postgres implementation. It accepts a provider-owned DB-API or psycopg-like
connection, applies the bootstrap SQL in `migrate`, and persists generic
records, audit records, and pending outbox events without depending on a
specific driver package.
`user_engine.testing.postgres_provider` provides env-gated live conformance
helpers for provider repositories. They require a dedicated test DSN plus
`USER_ENGINE_POSTGRES_TEST_RESET=1` before deleting rows from bootstrap-owned
tables.

View File

@@ -10,12 +10,14 @@ tested immediately in local and agent environments.
```text
src/user_engine/
adapters/ local standalone adapters and deterministic test doubles
adapters/ local standalone adapters, Postgres adapter, and test doubles
domain/ transport- and persistence-neutral domain schemas
errors.py typed service exceptions for callers and future transports
migrations.py ordered durable-store migration manifest
ports.py adapter protocols for identity, authorization, events, audit,
membership export, application bindings, and secrets
service.py headless service API for the isolated MVP
store_records.py JSON-safe durable-store record serialization
testing/ local fixtures for tests and examples
tests/ standard-library unittest suite
```
@@ -35,6 +37,19 @@ The command runs:
PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py'
```
Live Postgres conformance tests are skipped by default. To run them against a
dedicated disposable database, install `psycopg` or `psycopg2` in the active
environment and set:
```bash
USER_ENGINE_POSTGRES_TEST_DSN='postgresql://...' \
USER_ENGINE_POSTGRES_TEST_RESET=1 \
make test
```
The reset flag is required because those tests delete rows from the
bootstrap-owned `user_engine_*` tables.
## Implementation Rule
Add new behavior in this order:
@@ -50,8 +65,11 @@ The initial headless API is `UserEngineService`. It exposes health,
readiness, `me`, user/account lifecycle, identity linking, application
registration, catalog publication, profile writes, effective profile
resolution, projections, audit inspection, and outbox inspection. The first
store is `InMemoryUserEngineStore`, which carries an explicit schema version
and migration hook so later database-backed stores have a contract to match.
store is `InMemoryUserEngineStore`, which carries a schema version from
`user_engine.migrations` and a migration hook so later database-backed stores
have a contract to match. Future store adapters should run
`user_engine.testing.assert_user_engine_store_conformance` with their own
factory before being wired into service tests.
## Tenant Surface

View File

@@ -0,0 +1,106 @@
# Hats, Realms, Services, Assets, And Access Profiles
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0012
## Purpose
This slice models how a NetKingdom user can wear different hats across tenant,
realm, service, asset, and group contexts. It gives authorization systems and
service runtimes explicit access-control facts and claims-enrichment context
without moving final policy decisions into user-engine.
## Vocabulary
The USER-WP-0012 vocabulary maps onto existing user-engine facts:
- tenant: isolation boundary and tenant account state;
- realm: broad domain or community scope represented by membership scope
`realm`;
- service: protected application or service scope represented by membership
scope `service` or an access profile `service_id`;
- asset: protected resource scope represented by membership scope `asset` or
an access profile `asset_id`;
- group: group membership represented by membership scope `group`;
- hat: active role persona selected from an access profile;
- access profile: template that combines membership requirements, factor
requirements, profile defaults, and projection claim rules.
## Domain Model
`AccessProfile` defines a claimable hat for a tenant context. It stores the
hat name, scope type/id, optional realm/service/asset ids, required membership
facts, required factor types, profile defaults, claims, group ids, and an
approval flag.
`ActiveAccessContext` records the user's currently selected hat for a tenant.
It stores the selected access profile, active scope, matched membership ids,
verified factor ids, group ids, projection claims, and profile defaults.
`AccessControlFact` is the export shape for policy and ACL systems. Facts can
represent direct user memberships, group-derived facts, and active-context
facts over realm, service, or asset scopes.
## Public Facade
`UserEngineService` exposes:
- `register_access_profile(...)`
- `list_access_profiles(...)`
- `select_active_hat(...)`
- `export_access_control_facts(...)`
- `access_profile_diagnostics(...)`
All mutating and read/export operations pass through the authorization port.
## Selection Rules
Hat selection fails closed unless all of these are true:
- the actor is allowed to operate in the tenant context;
- the target user has an active tenant account;
- the access profile belongs to the tenant and is not approval-required;
- every profile membership requirement is satisfied by existing memberships;
- every required factor type has unexpired verified user evidence;
- the authorization port allows the active-context selection.
Selecting a hat records an `ActiveAccessContext`, emits
`active_access_context.selected`, and keeps raw factor values out of events and
projections.
## Identity Context And Projections
`identity_context` now includes:
- `active_access_context`;
- `access_control_facts`;
- canon references for active hat, access profile, realm, service area, asset
scope, and groups;
- relationship references such as `wears_hat` and
`selected_access_profile`.
Claims-enrichment projections include an `access_context` mapping when the
active context applies to the requested application/service. Service-specific
contexts are omitted from projections for other applications.
## Export Boundary
`export_access_control_facts` returns an adapter-neutral manifest plus facts.
External authorization engines or ACL systems can consume these facts, but
they remain responsible for final policy decisions and runtime enforcement.
## Redaction And Diagnostics
Diagnostics report counts, required factor types, and approval-required issues.
They deliberately do not return profile default values, projection claim
values, factor values, phone numbers, postal addresses, eID payloads, or other
proofing data.
## Current Limits
- user-engine does not implement a policy engine or ACL evaluator.
- Approval workflows for privileged hats remain a later slice.
- Access profile profile-default values are carried into active context and
projections, but this slice does not persist them as catalog profile values.
- UI selection flow contracts are implemented by USER-WP-0014.

View File

@@ -0,0 +1,263 @@
# 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
As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`,
`USER-WP-0013`, `USER-WP-0014`, and `USER-WP-0015` are implemented as
user-engine slices.
| 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.

View File

@@ -0,0 +1,91 @@
# Onboarding Journeys And Welcome Protocols
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0013
## Purpose
This slice adds resumable onboarding journeys for newly registered or newly
entitled users. Welcome protocols are templates that can be triggered by
registration completion, prepared-account claims, invitations, access-profile
events, or explicit manual starts.
user-engine owns journey state, audit/event correlation, lifecycle gaps, and
adapter references. Notification delivery, support content, protected service
tours, and external workflow/task systems remain outside the core domain.
## Domain Model
`WelcomeProtocol` is the tenant-scoped template. It stores trigger type,
optional matching keys such as prepared journey, application, realm, service,
role, hat, factor requirements, and ordered `WelcomeProtocolStep` definitions.
`WelcomeProtocolStep` names the subsystem, task kind, optional external task
reference, optional callback reference, support reference, and whether a
subsystem callback is required.
`OnboardingJourney` is the persisted user state. It records tenant, user,
protocol, trigger type, source id/event, journey key, status, active step,
correlation id, and timestamps.
`OnboardingStep` carries step status plus optional `OnboardingTask` and
`SubsystemHandoff` references. Missing required subsystem callbacks produce
explicit lifecycle gaps such as
`subsystem-callback-missing:<subsystem>:<step>`.
## Public Facade
`UserEngineService` exposes:
- `register_welcome_protocol(...)`
- `list_welcome_protocols(...)`
- `start_onboarding_journey(...)`
- `start_onboarding_for_registration(...)`
- `start_onboarding_for_prepared_account(...)`
- `progress_onboarding_step(...)`
- `complete_onboarding_step(...)`
- `skip_onboarding_step(...)`
- `fail_onboarding_step(...)`
- `resume_onboarding_journey(...)`
- `onboarding_diagnostics(...)`
Registration completion auto-starts matching registration protocols. Prepared
account claim auto-starts matching prepared-account protocols when the claimed
package includes a matching onboarding journey key.
## Lifecycle Rules
Journey status is derived from step state:
- a missing required callback starts the journey as `blocked`;
- an active non-blocked first step starts as `in_progress`;
- failed steps fail the journey;
- all completed/skipped steps complete the journey;
- all skipped steps skip the journey.
Resuming a pending, blocked, or failed journey can attach callback references
and returns blocked/failed steps to `in_progress`.
## Adapter Boundary
The service defines ports for notification delivery, task systems, support
content, subsystem welcome callbacks, and lifecycle task linking. The current
headless slice stores adapter references but does not call external systems.
## Identity Context And Diagnostics
`identity_context` includes onboarding journeys for the resolved user/tenant.
`onboarding_diagnostics` reports protocol count, journey count, status counts,
blocked step ids, and lifecycle gaps.
Diagnostics and outbox events avoid factor values and service content. Payloads
carry ids, statuses, trigger types, source ids, active step keys, and lifecycle
gap identifiers.
## Current Limits
- No notification platform or support-content renderer is implemented.
- No protected subsystem tour is hard-coded into user-engine.
- External task and callback execution is left to adapters.
- UI surface contracts are implemented by USER-WP-0014.

View File

@@ -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,45 @@ 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.
- 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 conformance tests that run against both in-memory and Postgres stores.
USER-WP-0016 adds the next consumer-side slice: `user_engine.migrations`
declares the ordered migration manifest and latest schema version,
`migrations/postgres/0001_user_engine_store.sql` defines a provider-facing
bootstrap schema, and `user_engine.testing.store_conformance` exposes a
reusable harness that future adapters can run with their own store factory.
The standard local suite runs that harness against `InMemoryUserEngineStore`.
USER-WP-0017 adds the provider-neutral serialization layer. Future Postgres
adapters should use `user_engine.store_records.store_record_for` before writing
to `user_engine_records` and `domain_record_from_store_record` after reading
JSONB payloads back. The `StoreRecord` envelope maps directly to the generic
record table columns: `record_type`, `record_key`, `tenant`, `user_id`,
`application_id`, `scope_type`, `scope_id`, and `payload`.
Durable payloads are raw state, not diagnostics. They can include factor
values, profile values, prepared-account matches, and access-profile defaults.
Adapters must avoid logging payloads and should use `record_counts` or other
redacted diagnostics for observability.
USER-WP-0018 adds `PostgresUserEngineStore`, a dependency-free adapter that
accepts a provider-supplied DB-API or psycopg-like connection. It writes generic
records through `StoreRecord`, keeps audit and outbox payloads in their
dedicated bootstrap tables, applies the bootstrap SQL through `migrate`, and
uses the shared conformance harness with a fake Postgres connection for local
unit coverage.
USER-WP-0019 adds optional provider-backed conformance tests. They are skipped
by default and run only when a dedicated test database is supplied through
`USER_ENGINE_POSTGRES_TEST_DSN` and destructive cleanup is acknowledged with
`USER_ENGINE_POSTGRES_TEST_RESET=1`. The helper supports either `psycopg` or
`psycopg2` when a provider repository installs one of them. Cleanup touches
only the bootstrap-owned `user_engine_*` tables.
Likely future follow-up work should be:
- Add provider-backed conformance tests for locking, uniqueness races, outbox
claiming, redacted diagnostics, and restore validation.
- Integrate the adapter with the future NetKingdom Postgres provider repo.

View File

@@ -0,0 +1,118 @@
# Prepared Accounts And Entitlement Claims
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0011
## Purpose
Prepared accounts let a tenant admin, operator, family owner, service owner, or
upstream system prepare user-domain intent before a person registers. The
package can name expected factor matches, tenant account state, memberships,
profile defaults, application bindings, and onboarding journey hints.
Prepared accounts are not credentials. A package is claimable only after a
completed registration presents matching verified factor evidence.
## Domain Model
`PreparedAccount` stores pending account intent:
- tenant
- required factor matches
- prepared entitlements
- status: `pending`, `claimed`, `revoked`, or `expired`
- preparer subject
- optional display name and primary email hints
- optional expiry
- claim metadata and lifecycle timestamps
`PreparedFactorRequirement` stores the factor type and normalized value to
match against verified registration factors. The model also carries optional
source-system and evidence references.
`PreparedEntitlement` stores the activation intent. Supported kinds are:
- `tenant_account`
- `membership`
- `profile_value`
- `application_binding`
- `onboarding_journey`
Entitlements may be marked `requires_approval`. Those packages fail closed in
the current claim facade until an explicit approval workflow is added.
## Public Facade
`UserEngineService` exposes:
- `prepare_account(...)`
- `update_prepared_account(...)`
- `list_prepared_accounts(...)`
- `revoke_prepared_account(...)`
- `expire_prepared_account(...)`
- `claim_prepared_account(...)`
Create, update, list, revoke, expire, and claim operations all pass through the
authorization port. The service depends on `UserEngineStore` protocol methods,
not the in-memory adapter internals.
## Claim Rules
Claims are only evaluated for completed registration sessions with a resolved
canonical user. A prepared account matches when every required factor is
present as unexpired verified `IdentityFactor` evidence on the registration.
The claim facade fails closed when:
- the caller names a missing, revoked, expired, claimed, or mismatching package;
- no prepared account matches the registration factors;
- multiple pending prepared accounts match the same verified factors;
- any entitlement in the package requires manual approval;
- entitlement activation references an invalid profile attribute or
unregistered application.
Factor requirements must include non-empty normalized values. Duplicate
pending packages with the same tenant and factor-signature are blocked during
create/update. Expired packages are ignored by duplicate checks and cannot be
claimed.
## Activation
Successful claim converts prepared entitlements into user-engine-owned facts:
- `TenantAccount` for tenant access state;
- `Membership` for scoped role facts;
- `ProfileValue` for catalog-validated profile defaults;
- `ApplicationBinding` for registered protected-system mappings;
- `prepared_account.onboarding_requested` outbox events for journey starts.
The prepared account is then marked `claimed` with the claiming user and
registration id.
## Audit, Outbox, And Redaction
Prepared-account mutations emit audit and outbox records:
- `prepared_account.created`
- `prepared_account.updated`
- `prepared_account.revoked`
- `prepared_account.expired`
- `prepared_account.claimed`
- `prepared_account.onboarding_requested`
Denied claim decisions are audited without outbox events. Outbox payloads use
ids, counts, factor types, statuses, and journey names. They deliberately avoid
normalized factor values such as email addresses, phone numbers, postal
addresses, and eID payloads.
## Current Limits
- Prepared accounts do not issue credentials, invitations, MFA challenges, or
tokens.
- Approval-required entitlement packages are blocked until a later workplan
adds explicit approval decisions.
- Final authorization policy and ACL evaluation remains outside user-engine;
user-engine only activates owned facts for policy systems to consume.
- Journey orchestration from prepared-account onboarding requests is
implemented by USER-WP-0013.

View File

@@ -0,0 +1,128 @@
# Registration And Access Management UI
Status: implemented headless UI contract slice
Date: 2026-06-15
Related workplan: USER-WP-0014
## Purpose
This slice adds an optional NetKingdom registration and access-management UI
contract over the headless `UserEngineService`. It is not a credential UI and
does not own browser state. It provides transport-neutral screen models,
route contracts, and an accessible HTML renderer that web, desktop, or CLI
adapters can serve.
## Information Architecture
The implemented UI facade exposes these primary areas:
- registration
- prepared rights
- active hat
- profile
- onboarding
- admin
The supported views cover registration start/resume, factor status,
registration completion, prepared-account review and claim, active hat
selection, onboarding status, admin prepared accounts, admin access profiles,
and admin onboarding diagnostics.
## Public Facade
`RegistrationAccessManagementUi` lives in `user_engine.ui` and is exported from
`user_engine`.
It exposes:
- `information_architecture()`
- `api_contract()`
- `start_registration(...)`
- `attach_factor(...)`
- `complete_registration(...)`
- `registration_screen(...)`
- `prepared_rights_review(...)`
- `accept_prepared_claim(...)`
- `deny_prepared_claim(...)`
- `hat_selection_view(...)`
- `select_hat(...)`
- `admin_dashboard(...)`
- `render_html(...)`
## Route Contract
The UI route contract maps thin transport routes to existing headless service
facades:
- `registration.start` -> `start_registration`
- `registration.factor` -> `attach_registration_factor`
- `registration.complete` -> `complete_registration`
- `prepared_account.review` -> `list_prepared_accounts`
- `prepared_account.accept` -> `claim_prepared_account`
- `prepared_account.deny` -> UI dismiss decision
- `access_profile.select_hat` -> `select_active_hat`
- `admin.dashboard` -> diagnostics/list views
Proofing, IAM, authorization, notification, and protected service consoles
remain adapter boundaries.
## Registration Flow
The self-service UI facade can start registration, attach adapter-supplied
factor evidence, show safe factor status by type, enforce UI terms/consent
before completion, and show the resulting NetKingdom ID.
Factor values are never rendered. The flow displays factor types and status,
not email addresses, phone numbers, postal addresses, eID payloads, provider
tokens, or challenge material.
## Prepared Rights
Prepared-account review displays pending packages by id, display name,
required factor types, entitlement kinds, and status. Required factor values
are redacted. Accepting a package calls `claim_prepared_account`. Denying a
package is an explicit UI dismiss state and does not mutate prepared-account
domain state.
## Hats And Active Context
The active-hat view lists access profiles and the current active context.
Selecting a hat calls `select_active_hat`; the domain service still enforces
tenant, membership, factor, approval, and authorization rules. The UI does not
display hidden policy logic or final authorization decisions.
## Admin Surface
The admin dashboard composes existing diagnostics and list operations:
- registration diagnostics
- tenant diagnostics
- prepared-account counts
- access-profile counts
- onboarding diagnostics
The dashboard intentionally reports counts and lifecycle gaps without exposing
factor values, prepared-account factor matches, profile default values, claim
values, or raw proofing payloads.
## Accessibility And Layout
The renderer emits semantic landmarks:
- `banner`
- `navigation`
- `main`
Sections are linked from navigation, action controls expose `aria-label`, and
mobile/desktop layout metadata is available through the screen model. Mobile
screens use one column with a 44px minimum touch target; desktop screens use a
two-column workbench layout.
## Current Limits
- This slice does not ship a web server, JavaScript client, or CSS bundle.
- Browser persistence is not authoritative over domain state.
- The HTML renderer is a verification artifact and adapter starting point, not
a final branded application.
- Credential entry, password reset, passkeys, MFA challenges, token issuance,
notifications, and service-specific consoles remain outside user-engine.

View File

@@ -0,0 +1,120 @@
# Registration Identity And Factor Model
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0010
## Purpose
This document defines the first NetKingdom registration slice in
`user-engine`. The slice lets a caller start a registration session, attach
externally verified identity-factor evidence, complete registration into a
stable NetKingdom user/account, and inspect safe diagnostics.
The design keeps user-engine in its identity-domain lane:
- IAM and proofing providers verify credentials, email, phone, eID, invite, or
SSO evidence.
- user-engine stores the registration session, normalized factor metadata,
user/account records, external identity links, audit records, and outbox
events.
- Authorization systems continue to make final policy and ACL decisions.
## NetKingdom ID
For this slice, the NetKingdom ID is the existing stable `User.user_id`.
That choice keeps the first implementation simple and avoids adding a second
identifier before a concrete public-ID requirement exists. Consumers should
treat the ID as opaque. It must not encode identity-provider issuer/subject
pairs, factor values, tenant names, email addresses, phone numbers, or eID
payloads.
If NetKingdom later needs a public alias, vanity handle, or migration-safe
external identifier, that can be added as a separate mapped identifier without
changing the registration facade's `netkingdom_id` contract.
## Domain Model
`RegistrationSession` tracks the workflow:
- `started`
- `factor_pending`
- `factor_verified`
- `completed`
- `abandoned`
- `expired`
- `rejected`
`FactorVerification` is adapter output from an external proofing system. It is
safe metadata: factor type, normalized value, optional display value, source
system, assurance metadata, evidence references, verification time, and
optional expiry.
`IdentityFactor` is the persisted user-engine record. During registration it
is attached to a registration session. After completion it is also attached to
the canonical user.
Supported factor types are:
- `email`
- `phone`
- `postal_address`
- `eid`
- `invite`
- `sso`
## Public Facade
`UserEngineService` exposes:
- `start_registration(...)`
- `attach_registration_factor(...)`
- `complete_registration(...)`
- `abandon_registration(...)`
- `expire_registration(...)`
- `resume_registration(...)`
- `registration_diagnostics(...)`
The completion result includes the completed session, user, account,
`netkingdom_id`, and `identity_context`.
## Factor Adapter Boundary
`FactorVerificationAdapter` normalizes external proofing results into
`FactorVerification`. A caller may also pass an already-normalized
`FactorVerification` directly.
Adapters must strip secret challenge material, proofing payloads, provider
tokens, and raw documents before data reaches user-engine.
## Audit, Outbox, And Redaction
Registration mutations emit local audit records and outbox events:
- `registration.started`
- `registration.factor_verified`
- `registration.completed`
- `registration.abandoned`
- `registration.expired`
Outbox payloads include registration ids, factor ids, factor types, source
systems, status, and user ids. They deliberately do not include normalized
factor values such as email addresses, phone numbers, postal addresses, or eID
payloads.
Diagnostics report counts by status and total verified factors. They do not
return factor values.
## Follow-On Boundaries
- Prepared account claiming is implemented by USER-WP-0011 and documented in
`docs/prepared-accounts-and-entitlement-claims.md`.
- Hats, realms, services, assets, and access profiles are implemented by
USER-WP-0012 and documented in
`docs/hats-realms-services-assets-access-profiles.md`.
- Welcome protocols and onboarding journeys are implemented by USER-WP-0013
and documented in `docs/onboarding-journeys-and-welcome-protocols.md`.
- Registration UI contracts are implemented by USER-WP-0014 and documented in
`docs/registration-and-access-management-ui.md`.
- Provider-backed proofing and credential flows remain external adapters.

View File

@@ -0,0 +1,108 @@
# Registration Scenario And Security Conformance
Status: implemented conformance slice
Date: 2026-06-15
Related workplan: USER-WP-0015
## Purpose
This slice turns the NetKingdom registration and onboarding roadmap into an
executable local conformance contract. It proves that the headless APIs and the
optional UI contract can complete the main registration journey, fail closed on
security negative paths, redact sensitive values, and exercise adapter seams
without production infrastructure.
## Scenario Matrix
The registration matrix is defined in `user_engine.testing.scenarios` as
`REGISTRATION_SCENARIO_MATRIX`.
It covers:
- self-registration;
- prepared account claim;
- privileged role requiring approval;
- eID-backed assurance;
- family invite;
- tenant admin invite;
- group access;
- denied cross-tenant claim.
The broader `SCENARIO_MATRIX` now also names registration/onboarding,
prepared-claim, group-access hat, denied cross-tenant claim, and UI workflow
coverage.
## End-To-End Conformance
`tests/test_registration_security_conformance.py` includes a full local flow:
```text
register application/catalog
prepare account with onboarding hint
complete email-backed registration
claim prepared account
select active hat
read claims projection and identity context
export access-control facts
render admin UI
assert onboarding event emission and redaction
```
This proves the path from registration through identity context, claims
enrichment, active access context, access fact export, onboarding, and UI
diagnostics.
## Security Negative Paths
The conformance suite exercises fail-closed behavior for:
- weak factor requirements;
- duplicate identity links;
- prepared-account hijack attempts;
- expired prepared claims;
- missing or cross-tenant context;
- privileged prepared roles requiring approval;
- stale approval through approval-required access profiles.
Denied prepared-account claim decisions leave audit evidence without creating
memberships for the attacker or emitting successful activation events.
## Redaction And Diagnostics
Conformance checks assert that these values do not leak through diagnostics,
events, or UI output:
- normalized factor values;
- email addresses used for prepared account matching;
- sensitive profile values;
- access profile claim/default values;
- proofing adapter secret input.
Sensitive profile values are redacted in runtime projections as
`<redacted>`.
## Adapter Conformance
The local adapter conformance path covers:
- factor verification adapter normalization;
- authorization harness request capture and obligations;
- access-control fact export;
- onboarding handoff lifecycle gaps and resume;
- audit record availability;
- outbox replay through pending events;
- in-memory durable-store rollback behavior in existing port tests.
No provider-specific IAM, eID, SMS, email, authorization-engine, notification,
or workflow infrastructure is required.
## Commands
```bash
make test
make test-conformance
```
The Makefile targets currently run the same standard-library test suite. They
remain separate entry points so CI can split unit, integration, scenario, and
conformance execution later.

View File

@@ -16,6 +16,27 @@ projection, audit, and event behavior testable without a UI.
| audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. |
| identity_canon_context | Actor, user, account, authenticated subject, authorization principal, tenant, membership, grant-like facts, and evidence references stay distinguishable. |
| family_dataspace_onboarding | A family tenant can register a personal dataspace, invite members, accept SSO identities, project claims context, and deny cross-family access. |
| registration_onboarding_full | Registration, prepared claim, active hat, claims projection, onboarding, access fact export, and UI diagnostics work as one local flow. |
| prepared_account_claim | Prepared rights can be claimed only after matching verified factors. |
| privileged_role_requires_approval | Privileged prepared roles fail closed without approval. |
| eid_assurance_registration | eID-backed factor evidence can participate in registration conformance. |
| tenant_admin_invite | Tenant admins can prepare users and inspect diagnostics without issuing credentials. |
| group_access_hat | Group-derived memberships can produce active hat and access-control facts. |
| denied_cross_tenant_claim | Cross-tenant prepared claims and tenant overreach fail closed. |
| ui_registration_access_flow | USER-WP-0014 UI contracts cover registration, prepared rights, hats, admin diagnostics, redaction, and responsive metadata. |
## Registration Scenario Matrix
`REGISTRATION_SCENARIO_MATRIX` covers:
- self-registration;
- prepared account claim;
- privileged role requiring approval;
- eID-backed assurance;
- family invite;
- tenant admin invite;
- group access;
- denied cross-tenant claim.
## Fixture Actors

View File

@@ -3,11 +3,26 @@
Future self-service and scope-admin UIs should consume user-engine through a
transport adapter that preserves the service shapes below.
USER-WP-0014 adds `RegistrationAccessManagementUi` as the first implemented
headless UI contract facade. It returns transport-neutral screen models,
route definitions, responsive layout metadata, and an accessible HTML
verification renderer.
## Self-Service Account UI
Required backend operations:
- `me` to resolve the current actor, user, account, and identity links.
- `RegistrationAccessManagementUi.start_registration` to create a UI-backed
registration session.
- `RegistrationAccessManagementUi.attach_factor` to attach adapter-supplied
factor evidence without rendering factor values.
- `RegistrationAccessManagementUi.complete_registration` to enforce UI
terms/consent and complete the headless registration flow.
- `RegistrationAccessManagementUi.prepared_rights_review` and
`accept_prepared_claim` to review and claim prepared rights.
- `RegistrationAccessManagementUi.hat_selection_view` and `select_hat` to show
available hats and select active access context.
- `effective_profile` with the actor tenant and optional application id.
- `projection` with `SELF_SERVICE` for editable user-visible fields.
- `set_profile_value` for fields whose catalog mutability includes `USER`.
@@ -19,11 +34,37 @@ Required backend operations:
Required backend operations:
- `resolve_tenant_context` before all tenant-scoped screens.
- `RegistrationAccessManagementUi.admin_dashboard` for registration,
prepared-account, access-profile, and onboarding diagnostics.
- `set_tenant_account_status` for in-scope account state.
- `add_membership` for tenant/team membership changes.
- `projection` with `ADMIN` or a future admin transport projection.
- `tenant_diagnostics` for onboarding and support readiness checks.
## UI Route Contract
`RegistrationAccessManagementUi.api_contract()` defines these route ids:
- `registration.start`
- `registration.factor`
- `registration.complete`
- `prepared_account.review`
- `prepared_account.accept`
- `prepared_account.deny`
- `access_profile.select_hat`
- `admin.dashboard`
Transport adapters may map these ids to HTTP, RPC, desktop, or CLI routes.
The route contract marks factor values, prepared-account factor matches,
profile defaults, claim values, and hidden policy details as redacted.
## Accessibility And Responsive Contract
`render_html` emits `banner`, `navigation`, and `main` landmarks. Section
navigation uses labels, controls expose `aria-label`, and screen models include
mobile and desktop layout metadata. Mobile screens use a one-column layout and
44px minimum touch target. Desktop screens use a two-column workbench layout.
## Fixtures
Use `user_engine.testing.scenarios` for human, tenant admin, platform

View File

@@ -0,0 +1,78 @@
-- USER-WP-0016 durable-store bootstrap for provider-backed Postgres adapters.
-- Provider repositories may apply this file directly or translate it into
-- their migration framework while preserving the table semantics.
CREATE TABLE IF NOT EXISTS user_engine_schema_versions (
version text PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
checksum text
);
CREATE TABLE IF NOT EXISTS user_engine_records (
record_type text NOT NULL,
record_key text NOT NULL,
tenant text,
user_id text,
application_id text,
scope_type text,
scope_id text,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (record_type, record_key)
);
CREATE INDEX IF NOT EXISTS user_engine_records_tenant_idx
ON user_engine_records (tenant, record_type);
CREATE INDEX IF NOT EXISTS user_engine_records_user_idx
ON user_engine_records (user_id, record_type);
CREATE INDEX IF NOT EXISTS user_engine_records_application_idx
ON user_engine_records (application_id, record_type);
CREATE INDEX IF NOT EXISTS user_engine_records_scope_idx
ON user_engine_records (scope_type, scope_id, record_type);
CREATE TABLE IF NOT EXISTS user_engine_audit_records (
audit_id text PRIMARY KEY,
tenant text NOT NULL,
actor_issuer text NOT NULL,
actor_subject text NOT NULL,
action text NOT NULL,
subject text NOT NULL,
correlation_id text NOT NULL,
summary text,
payload jsonb NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS user_engine_audit_records_tenant_idx
ON user_engine_audit_records (tenant, recorded_at);
CREATE INDEX IF NOT EXISTS user_engine_audit_records_subject_idx
ON user_engine_audit_records (subject, recorded_at);
CREATE TABLE IF NOT EXISTS user_engine_outbox_events (
event_id text PRIMARY KEY,
tenant text NOT NULL,
event_type text NOT NULL,
aggregate_id text NOT NULL,
correlation_id text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
claimed_at timestamptz,
claimed_by text,
delivered_at timestamptz,
failed_at timestamptz,
failure_reason text
);
CREATE INDEX IF NOT EXISTS user_engine_outbox_events_pending_idx
ON user_engine_outbox_events (occurred_at)
WHERE claimed_at IS NULL AND delivered_at IS NULL;
INSERT INTO user_engine_schema_versions (version, name)
VALUES ('0001_initial', 'initial durable store')
ON CONFLICT (version) DO NOTHING;

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

View File

@@ -2,11 +2,13 @@
from user_engine.projections import CacheStatus, ClaimsEnrichmentProjectionCache
from user_engine.service import PLATFORM_TENANT, UserEngineService
from user_engine.ui import RegistrationAccessManagementUi
__all__ = [
"CacheStatus",
"ClaimsEnrichmentProjectionCache",
"PLATFORM_TENANT",
"RegistrationAccessManagementUi",
"UserEngineService",
"__version__",
]

View File

@@ -4,8 +4,10 @@ from user_engine.adapters.local import (
InMemoryUserEngineStore,
LocalAuthorizationCheckPort,
)
from user_engine.adapters.postgres import PostgresUserEngineStore
__all__ = [
"InMemoryUserEngineStore",
"LocalAuthorizationCheckPort",
"PostgresUserEngineStore",
]

View File

@@ -2,11 +2,15 @@
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,
AccessProfile,
ActiveAccessContext,
Application,
ApplicationBinding,
AuditRecord,
@@ -16,15 +20,21 @@ from user_engine.domain import (
Catalog,
ExternalIdentity,
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileScope,
ProfileValue,
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
from user_engine.migrations import LATEST_SCHEMA_VERSION
SCHEMA_VERSION = "0001_initial"
SCHEMA_VERSION = LATEST_SCHEMA_VERSION
@dataclass
@@ -46,11 +56,26 @@ class InMemoryUserEngineStore:
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
catalogs: dict[str, Catalog] = field(default_factory=dict)
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
registration_sessions: dict[str, RegistrationSession] = field(
default_factory=dict
)
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
prepared_accounts: dict[str, PreparedAccount] = field(default_factory=dict)
access_profiles: dict[str, AccessProfile] = field(default_factory=dict)
active_access_contexts: dict[
tuple[str, str], ActiveAccessContext
] = field(default_factory=dict)
welcome_protocols: dict[str, WelcomeProtocol] = field(default_factory=dict)
onboarding_journeys: dict[str, OnboardingJourney] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = 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 +85,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 +130,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
@@ -97,6 +161,123 @@ class InMemoryUserEngineStore:
if invitation.user_id == user_id
)
def save_registration_session(self, session: RegistrationSession) -> None:
self.registration_sessions[session.registration_id] = session
def registration_session(
self, registration_id: str
) -> RegistrationSession | None:
return self.registration_sessions.get(registration_id)
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
return tuple(self.registration_sessions.values())
def save_identity_factor(self, factor: IdentityFactor) -> None:
self.identity_factors[factor.factor_id] = factor
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
return self.identity_factors.get(factor_id)
def factors_for_registration(
self, registration_id: str
) -> tuple[IdentityFactor, ...]:
return tuple(
factor
for factor in self.identity_factors.values()
if factor.registration_id == registration_id
)
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
return tuple(
factor
for factor in self.identity_factors.values()
if factor.user_id == user_id
)
def save_prepared_account(self, account: PreparedAccount) -> None:
self.prepared_accounts[account.prepared_account_id] = account
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
return self.prepared_accounts.get(prepared_account_id)
def prepared_accounts_for_tenant(
self, tenant: str
) -> tuple[PreparedAccount, ...]:
return tuple(
account
for account in self.prepared_accounts.values()
if account.tenant == tenant
)
def save_access_profile(self, profile: AccessProfile) -> None:
self.access_profiles[profile.access_profile_id] = profile
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
return self.access_profiles.get(access_profile_id)
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
return tuple(
profile
for profile in self.access_profiles.values()
if profile.tenant == tenant
)
def save_active_access_context(self, context: ActiveAccessContext) -> None:
self.active_access_contexts[(context.user_id, context.tenant)] = context
def active_access_context(
self, user_id: str, tenant: str
) -> ActiveAccessContext | None:
return self.active_access_contexts.get((user_id, tenant))
def active_access_contexts_for_tenant(
self, tenant: str
) -> tuple[ActiveAccessContext, ...]:
return tuple(
context
for context in self.active_access_contexts.values()
if context.tenant == tenant
)
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
self.welcome_protocols[protocol.protocol_id] = protocol
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
return self.welcome_protocols.get(protocol_id)
def welcome_protocols_for_tenant(
self, tenant: str
) -> tuple[WelcomeProtocol, ...]:
return tuple(
protocol
for protocol in self.welcome_protocols.values()
if protocol.tenant == tenant
)
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
self.onboarding_journeys[journey.journey_id] = journey
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
return self.onboarding_journeys.get(journey_id)
def onboarding_journeys_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[OnboardingJourney, ...]:
return tuple(
journey
for journey in self.onboarding_journeys.values()
if journey.user_id == user_id and (tenant is None or journey.tenant == tenant)
)
def onboarding_journeys_for_tenant(
self, tenant: str
) -> tuple[OnboardingJourney, ...]:
return tuple(
journey
for journey in self.onboarding_journeys.values()
if journey.tenant == tenant
)
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)
@@ -136,9 +317,95 @@ 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),
"bindings": len(self.bindings),
"catalogs": len(self.catalogs),
"family_invitations": len(self.family_invitations),
"registration_sessions": len(self.registration_sessions),
"identity_factors": len(self.identity_factors),
"prepared_accounts": len(self.prepared_accounts),
"access_profiles": len(self.access_profiles),
"active_access_contexts": len(self.active_access_contexts),
"welcome_protocols": len(self.welcome_protocols),
"onboarding_journeys": len(self.onboarding_journeys),
"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),
"registration_sessions": copy.deepcopy(self.registration_sessions),
"identity_factors": copy.deepcopy(self.identity_factors),
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
"access_profiles": copy.deepcopy(self.access_profiles),
"active_access_contexts": copy.deepcopy(self.active_access_contexts),
"welcome_protocols": copy.deepcopy(self.welcome_protocols),
"onboarding_journeys": copy.deepcopy(self.onboarding_journeys),
"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.registration_sessions = snapshot[
"registration_sessions"
] # type: ignore[assignment]
self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment]
self.prepared_accounts = snapshot["prepared_accounts"] # type: ignore[assignment]
self.access_profiles = snapshot["access_profiles"] # type: ignore[assignment]
self.active_access_contexts = snapshot[
"active_access_contexts"
] # type: ignore[assignment]
self.welcome_protocols = snapshot["welcome_protocols"] # type: ignore[assignment]
self.onboarding_journeys = snapshot[
"onboarding_journeys"
] # 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.

View File

@@ -0,0 +1,638 @@
"""Postgres-backed store adapter.
The adapter is dependency-free: callers provide a DB-API or psycopg-like
connection object. Provider repositories remain responsible for creating,
pooling, securing, and observing those connections.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
from user_engine.domain import (
Account,
AccessProfile,
ActiveAccessContext,
Application,
ApplicationBinding,
AuditRecord,
Catalog,
ExternalIdentity,
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileValue,
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
from user_engine.migrations import LATEST_SCHEMA_VERSION, USER_ENGINE_RECORD_COUNT_KEYS
from user_engine.store_records import (
StoreRecord,
composite_record_key,
domain_record_from_store_record,
store_record_for,
)
_RECORD_COLUMNS = (
"record_type, record_key, tenant, user_id, application_id, "
"scope_type, scope_id, payload"
)
_RECORD_COUNT_KEY_BY_TYPE = {
"application_bindings": "bindings",
}
class PostgresCursor(Protocol):
def execute(self, sql: str, params: Iterable[Any] | None = None) -> Any:
"""Execute a SQL statement."""
def fetchone(self) -> Any | None:
"""Fetch one row from the previous query."""
def fetchall(self) -> Iterable[Any]:
"""Fetch all rows from the previous query."""
def close(self) -> Any:
"""Close the cursor."""
class PostgresConnection(Protocol):
def cursor(self) -> PostgresCursor:
"""Return a DB-API-like cursor."""
def commit(self) -> Any:
"""Commit the current transaction."""
def rollback(self) -> Any:
"""Roll back the current transaction."""
class PostgresUserEngineStore:
"""Postgres implementation of the `UserEngineStore` protocol."""
def __init__(self, connection: PostgresConnection) -> None:
self.connection = connection
@property
def schema_version(self) -> str | None:
return LATEST_SCHEMA_VERSION if self._has_latest_schema() else None
@property
def ready(self) -> bool:
return self.schema_version == LATEST_SCHEMA_VERSION
def migrate(self) -> None:
with self._cursor() as cursor:
for statement in _bootstrap_sql_statements():
cursor.execute(statement)
self.connection.commit()
@contextmanager
def transaction(self) -> Iterator[None]:
begin = getattr(self.connection, "begin", None)
if callable(begin):
begin()
try:
yield
except Exception:
self.connection.rollback()
raise
else:
self.connection.commit()
def save_user(self, user: User) -> None:
self._upsert_record(user)
def user(self, user_id: str) -> User | None:
return cast(User | None, self._get_record("users", user_id))
def save_account(self, account: Account) -> None:
self._upsert_record(account)
def user_account(self, user_id: str) -> Account | None:
return cast(Account | None, self._get_record("accounts", user_id))
def save_identity(self, identity: ExternalIdentity) -> None:
self._upsert_record(identity)
def find_identity(self, issuer: str, subject: str) -> ExternalIdentity | None:
key = composite_record_key(issuer, subject)
return cast(ExternalIdentity | None, self._get_record("external_identities", key))
def identities_for_user(self, user_id: str) -> tuple[ExternalIdentity, ...]:
return cast(
tuple[ExternalIdentity, ...],
self._query_records("external_identities", user_id=user_id),
)
def save_tenant_account(self, account: TenantAccount) -> None:
self._upsert_record(account)
def tenant_account(self, tenant: str, user_id: str) -> TenantAccount | None:
key = composite_record_key(tenant, user_id)
return cast(TenantAccount | None, self._get_record("tenant_accounts", key))
def save_membership(self, membership: Membership) -> None:
self._upsert_record(membership)
def memberships_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[Membership, ...]:
return cast(
tuple[Membership, ...],
self._query_records("memberships", user_id=user_id, tenant=tenant),
)
def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]:
return cast(
tuple[Membership, ...],
self._query_records("memberships", tenant=tenant),
)
def save_application(self, application: Application) -> None:
self._upsert_record(application)
def application(self, application_id: str) -> Application | None:
return cast(Application | None, self._get_record("applications", application_id))
def save_binding(self, binding: ApplicationBinding) -> None:
self._upsert_record(binding)
def binding(self, application_id: str) -> ApplicationBinding | None:
return cast(
ApplicationBinding | None,
self._get_record("application_bindings", application_id),
)
def save_catalog(self, catalog: Catalog) -> None:
self._upsert_record(catalog)
def catalog(self, catalog_id: str) -> Catalog | None:
return cast(Catalog | None, self._get_record("catalogs", catalog_id))
def all_catalogs(self) -> tuple[Catalog, ...]:
return cast(tuple[Catalog, ...], self._query_records("catalogs"))
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
self._upsert_record(invitation)
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
return cast(
FamilyInvitation | None,
self._get_record("family_invitations", invitation_id),
)
def family_invitations_for_user(
self, user_id: str
) -> tuple[FamilyInvitation, ...]:
return cast(
tuple[FamilyInvitation, ...],
self._query_records("family_invitations", user_id=user_id),
)
def save_registration_session(self, session: RegistrationSession) -> None:
self._upsert_record(session)
def registration_session(
self, registration_id: str
) -> RegistrationSession | None:
return cast(
RegistrationSession | None,
self._get_record("registration_sessions", registration_id),
)
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
return cast(
tuple[RegistrationSession, ...],
self._query_records("registration_sessions"),
)
def save_identity_factor(self, factor: IdentityFactor) -> None:
self._upsert_record(factor)
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
return cast(
IdentityFactor | None,
self._get_record("identity_factors", factor_id),
)
def factors_for_registration(
self, registration_id: str
) -> tuple[IdentityFactor, ...]:
return cast(
tuple[IdentityFactor, ...],
self._query_records(
"identity_factors",
scope_type="registration",
scope_id=registration_id,
),
)
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
return cast(
tuple[IdentityFactor, ...],
self._query_records("identity_factors", user_id=user_id),
)
def save_prepared_account(self, account: PreparedAccount) -> None:
self._upsert_record(account)
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
return cast(
PreparedAccount | None,
self._get_record("prepared_accounts", prepared_account_id),
)
def prepared_accounts_for_tenant(
self, tenant: str
) -> tuple[PreparedAccount, ...]:
return cast(
tuple[PreparedAccount, ...],
self._query_records("prepared_accounts", tenant=tenant),
)
def save_access_profile(self, profile: AccessProfile) -> None:
self._upsert_record(profile)
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
return cast(
AccessProfile | None,
self._get_record("access_profiles", access_profile_id),
)
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
return cast(
tuple[AccessProfile, ...],
self._query_records("access_profiles", tenant=tenant),
)
def save_active_access_context(self, context: ActiveAccessContext) -> None:
self._upsert_record(context)
def active_access_context(
self, user_id: str, tenant: str
) -> ActiveAccessContext | None:
key = composite_record_key(user_id, tenant)
return cast(
ActiveAccessContext | None,
self._get_record("active_access_contexts", key),
)
def active_access_contexts_for_tenant(
self, tenant: str
) -> tuple[ActiveAccessContext, ...]:
return cast(
tuple[ActiveAccessContext, ...],
self._query_records("active_access_contexts", tenant=tenant),
)
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
self._upsert_record(protocol)
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
return cast(
WelcomeProtocol | None,
self._get_record("welcome_protocols", protocol_id),
)
def welcome_protocols_for_tenant(
self, tenant: str
) -> tuple[WelcomeProtocol, ...]:
return cast(
tuple[WelcomeProtocol, ...],
self._query_records("welcome_protocols", tenant=tenant),
)
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
self._upsert_record(journey)
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
return cast(
OnboardingJourney | None,
self._get_record("onboarding_journeys", journey_id),
)
def onboarding_journeys_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[OnboardingJourney, ...]:
return cast(
tuple[OnboardingJourney, ...],
self._query_records("onboarding_journeys", user_id=user_id, tenant=tenant),
)
def onboarding_journeys_for_tenant(
self, tenant: str
) -> tuple[OnboardingJourney, ...]:
return cast(
tuple[OnboardingJourney, ...],
self._query_records("onboarding_journeys", tenant=tenant),
)
def save_profile_value(self, value: ProfileValue) -> None:
self._upsert_record(value)
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
return cast(
tuple[ProfileValue, ...],
self._query_records("profile_values", user_id=user_id),
)
def append_audit(self, record: AuditRecord) -> None:
store_record = store_record_for(record)
with self._cursor() as cursor:
cursor.execute(
"""
INSERT INTO user_engine_audit_records (
audit_id, tenant, actor_issuer, actor_subject, action,
subject, correlation_id, summary, payload
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (audit_id) DO NOTHING
""",
(
record.audit_id,
record.tenant,
record.actor.issuer,
record.actor.subject,
record.action,
record.subject,
record.correlation_id,
record.summary,
json.dumps(store_record.payload),
),
)
def audit_log(self) -> tuple[AuditRecord, ...]:
with self._cursor() as cursor:
cursor.execute(
"""
SELECT payload
FROM user_engine_audit_records
ORDER BY recorded_at, audit_id
"""
)
return tuple(
cast(AuditRecord, self._decode_payload_row("audit_records", row))
for row in cursor.fetchall()
)
def append_outbox(self, event: OutboxEvent) -> None:
store_record = store_record_for(event)
with self._cursor() as cursor:
cursor.execute(
"""
INSERT INTO user_engine_outbox_events (
event_id, tenant, event_type, aggregate_id, correlation_id,
payload, occurred_at
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
ON CONFLICT (event_id) DO NOTHING
""",
(
event.event_id,
event.tenant,
event.event_type,
event.aggregate_id,
event.correlation_id,
json.dumps(store_record.payload),
event.occurred_at,
),
)
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
with self._cursor() as cursor:
cursor.execute(
"""
SELECT payload
FROM user_engine_outbox_events
WHERE claimed_at IS NULL AND delivered_at IS NULL
ORDER BY occurred_at, event_id
"""
)
return tuple(
cast(OutboxEvent, self._decode_payload_row("outbox_events", row))
for row in cursor.fetchall()
)
def record_counts(self) -> Mapping[str, int]:
counts = {key: 0 for key in USER_ENGINE_RECORD_COUNT_KEYS}
with self._cursor() as cursor:
cursor.execute(
"""
SELECT record_type, COUNT(*)
FROM user_engine_records
GROUP BY record_type
"""
)
for record_type, count in cursor.fetchall():
key = _RECORD_COUNT_KEY_BY_TYPE.get(record_type, record_type)
if key in counts:
counts[key] = int(count)
cursor.execute("SELECT COUNT(*) FROM user_engine_audit_records")
counts["audit_records"] = int(_first_column(cursor.fetchone()) or 0)
cursor.execute(
"""
SELECT COUNT(*)
FROM user_engine_outbox_events
WHERE claimed_at IS NULL AND delivered_at IS NULL
"""
)
counts["pending_outbox_events"] = int(
_first_column(cursor.fetchone()) or 0
)
return counts
def _upsert_record(self, value: Any) -> None:
record = store_record_for(value)
with self._cursor() as cursor:
cursor.execute(
f"""
INSERT INTO user_engine_records (
record_type, record_key, tenant, user_id, application_id,
scope_type, scope_id, payload
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (record_type, record_key) DO UPDATE SET
tenant = EXCLUDED.tenant,
user_id = EXCLUDED.user_id,
application_id = EXCLUDED.application_id,
scope_type = EXCLUDED.scope_type,
scope_id = EXCLUDED.scope_id,
payload = EXCLUDED.payload,
updated_at = now()
""",
(
record.record_type,
record.record_key,
record.tenant,
record.user_id,
record.application_id,
record.scope_type,
record.scope_id,
json.dumps(record.payload),
),
)
def _get_record(self, record_type: str, record_key: str) -> Any | None:
with self._cursor() as cursor:
cursor.execute(
f"""
SELECT {_RECORD_COLUMNS}
FROM user_engine_records
WHERE record_type = %s AND record_key = %s
""",
(record_type, record_key),
)
row = cursor.fetchone()
if row is None:
return None
return domain_record_from_store_record(_store_record_from_row(row))
def _query_records(
self,
record_type: str,
*,
tenant: str | None = None,
user_id: str | None = None,
application_id: str | None = None,
scope_type: str | None = None,
scope_id: str | None = None,
) -> tuple[Any, ...]:
filters = {
"tenant": tenant,
"user_id": user_id,
"application_id": application_id,
"scope_type": scope_type,
"scope_id": scope_id,
}
clauses = ["record_type = %s"]
params: list[Any] = [record_type]
for column, value in filters.items():
if value is not None:
clauses.append(f"{column} = %s")
params.append(value)
where_clause = " AND ".join(clauses)
with self._cursor() as cursor:
cursor.execute(
f"""
SELECT {_RECORD_COLUMNS}
FROM user_engine_records
WHERE {where_clause}
ORDER BY record_key
""",
tuple(params),
)
rows = cursor.fetchall()
return tuple(
domain_record_from_store_record(_store_record_from_row(row))
for row in rows
)
def _decode_payload_row(self, record_type: str, row: Any) -> Any:
payload = _first_column(row)
if isinstance(payload, str):
payload = json.loads(payload)
record_key = str(cast(Mapping[str, Any], payload).get("event_id") or "")
if record_type == "audit_records":
record_key = str(cast(Mapping[str, Any], payload).get("audit_id") or "")
return domain_record_from_store_record(
StoreRecord(record_type=record_type, record_key=record_key, payload=payload)
)
def _has_latest_schema(self) -> bool:
try:
with self._cursor() as cursor:
cursor.execute(
"""
SELECT 1
FROM user_engine_schema_versions
WHERE version = %s
""",
(LATEST_SCHEMA_VERSION,),
)
return cursor.fetchone() is not None
except Exception:
self.connection.rollback()
return False
@contextmanager
def _cursor(self) -> Iterator[PostgresCursor]:
cursor = self.connection.cursor()
try:
yield cursor
finally:
close = getattr(cursor, "close", None)
if callable(close):
close()
def _store_record_from_row(row: Any) -> StoreRecord:
if isinstance(row, Mapping):
payload = row["payload"]
if isinstance(payload, str):
payload = json.loads(payload)
return StoreRecord(
record_type=str(row["record_type"]),
record_key=str(row["record_key"]),
tenant=cast(str | None, row.get("tenant")),
user_id=cast(str | None, row.get("user_id")),
application_id=cast(str | None, row.get("application_id")),
scope_type=cast(str | None, row.get("scope_type")),
scope_id=cast(str | None, row.get("scope_id")),
payload=payload,
)
(
record_type,
record_key,
tenant,
user_id,
application_id,
scope_type,
scope_id,
payload,
) = row
if isinstance(payload, str):
payload = json.loads(payload)
return StoreRecord(
record_type=str(record_type),
record_key=str(record_key),
tenant=tenant,
user_id=user_id,
application_id=application_id,
scope_type=scope_type,
scope_id=scope_id,
payload=payload,
)
def _first_column(row: Any) -> Any:
if row is None:
return None
if isinstance(row, Mapping):
return next(iter(row.values()))
return row[0]
def _load_bootstrap_sql() -> str:
repo_root = Path(__file__).resolve().parents[3]
return (repo_root / "migrations/postgres/0001_user_engine_store.sql").read_text(
encoding="utf-8"
)
def _bootstrap_sql_statements() -> tuple[str, ...]:
return tuple(
f"{statement.strip()};"
for statement in _load_bootstrap_sql().split(";")
if statement.strip()
)

View File

@@ -1,8 +1,13 @@
"""Domain schemas for user-engine."""
from user_engine.domain.models import (
AccessControlFact,
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
Account,
AccountStatus,
ActiveAccessContext,
Actor,
Application,
ApplicationBinding,
@@ -16,30 +21,54 @@ from user_engine.domain.models import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
FactorVerification,
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
IdentityFactor,
IdentityFactorType,
InvitationStatus,
ManagementMode,
Membership,
Mutability,
OnboardingJourney,
OnboardingJourneyStatus,
OnboardingStep,
OnboardingStepStatus,
OnboardingTask,
OnboardingTriggerType,
OutboxEvent,
PrincipalType,
PreparedAccount,
PreparedAccountStatus,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
ProfileValue,
ProjectionType,
RegistrationSession,
RegistrationStatus,
Sensitivity,
SubsystemHandoff,
TenantAccount,
User,
Visibility,
WelcomeProtocol,
WelcomeProtocolStep,
new_id,
utc_now,
)
__all__ = [
"Account",
"AccessControlFact",
"AccessMembershipRequirement",
"AccessProfile",
"AccessScopeType",
"AccountStatus",
"ActiveAccessContext",
"Actor",
"Application",
"ApplicationBinding",
@@ -53,23 +82,42 @@ __all__ = [
"Catalog",
"CatalogLifecycle",
"ExternalIdentity",
"FactorVerification",
"FamilyDataspaceRequest",
"FamilyInvitation",
"FamilyMemberSpec",
"FamilyRole",
"IdentityFactor",
"IdentityFactorType",
"InvitationStatus",
"ManagementMode",
"Membership",
"Mutability",
"OnboardingJourney",
"OnboardingJourneyStatus",
"OnboardingStep",
"OnboardingStepStatus",
"OnboardingTask",
"OnboardingTriggerType",
"OutboxEvent",
"PrincipalType",
"PreparedAccount",
"PreparedAccountStatus",
"PreparedEntitlement",
"PreparedEntitlementKind",
"PreparedFactorRequirement",
"ProfileScope",
"ProfileValue",
"ProjectionType",
"RegistrationSession",
"RegistrationStatus",
"Sensitivity",
"SubsystemHandoff",
"TenantAccount",
"User",
"Visibility",
"WelcomeProtocol",
"WelcomeProtocolStep",
"new_id",
"utc_now",
]

View File

@@ -59,6 +59,74 @@ class InvitationStatus(StrEnum):
REVOKED = "revoked"
class RegistrationStatus(StrEnum):
STARTED = "started"
FACTOR_PENDING = "factor_pending"
FACTOR_VERIFIED = "factor_verified"
COMPLETED = "completed"
ABANDONED = "abandoned"
EXPIRED = "expired"
REJECTED = "rejected"
class PreparedAccountStatus(StrEnum):
PENDING = "pending"
CLAIMED = "claimed"
REVOKED = "revoked"
EXPIRED = "expired"
class IdentityFactorType(StrEnum):
EMAIL = "email"
PHONE = "phone"
POSTAL_ADDRESS = "postal_address"
EID = "eid"
INVITE = "invite"
SSO = "sso"
class PreparedEntitlementKind(StrEnum):
TENANT_ACCOUNT = "tenant_account"
MEMBERSHIP = "membership"
PROFILE_VALUE = "profile_value"
APPLICATION_BINDING = "application_binding"
ONBOARDING_JOURNEY = "onboarding_journey"
class AccessScopeType(StrEnum):
TENANT = "tenant"
REALM = "realm"
SERVICE = "service"
ASSET = "asset"
GROUP = "group"
class OnboardingTriggerType(StrEnum):
REGISTRATION = "registration"
PREPARED_ACCOUNT = "prepared_account"
INVITATION = "invitation"
ACCESS_PROFILE = "access_profile"
MANUAL = "manual"
class OnboardingJourneyStatus(StrEnum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
COMPLETED = "completed"
SKIPPED = "skipped"
FAILED = "failed"
class OnboardingStepStatus(StrEnum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
COMPLETED = "completed"
SKIPPED = "skipped"
FAILED = "failed"
class ProfileScope(StrEnum):
GLOBAL = "global"
TENANT = "tenant"
@@ -266,6 +334,158 @@ class Membership:
created_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class AccessMembershipRequirement:
scope_type: str
scope_id: str
kind: str
@dataclass(frozen=True)
class AccessProfile:
tenant: str
display_name: str
hat: str
access_profile_id: str = field(default_factory=lambda: new_id("apf"))
scope_type: AccessScopeType = AccessScopeType.TENANT
scope_id: str | None = None
realm_id: str | None = None
service_id: str | None = None
asset_id: str | None = None
membership_requirements: tuple[AccessMembershipRequirement, ...] = ()
required_factor_types: tuple[IdentityFactorType, ...] = ()
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
claims: Mapping[str, Any] = field(default_factory=dict)
group_scope_ids: tuple[str, ...] = ()
requires_approval: bool = False
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class ActiveAccessContext:
user_id: str
tenant: str
access_profile_id: str
hat: str
scope_type: AccessScopeType
scope_id: str
active_context_id: str = field(default_factory=lambda: new_id("actx"))
realm_id: str | None = None
service_id: str | None = None
asset_id: str | None = None
membership_ids: tuple[str, ...] = ()
factor_ids: tuple[str, ...] = ()
group_ids: tuple[str, ...] = ()
claims: Mapping[str, Any] = field(default_factory=dict)
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
selected_by_subject: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class AccessControlFact:
tenant: str
subject_type: str
subject_id: str
scope_type: str
scope_id: str
role: str
fact_id: str = field(default_factory=lambda: new_id("acf"))
user_id: str | None = None
hat: str | None = None
access_profile_id: str | None = None
membership_id: str | None = None
evidence_refs: tuple[CanonEntityReference, ...] = ()
source_system: str = "user-engine"
@dataclass(frozen=True)
class OnboardingTask:
subsystem: str
task_kind: str
task_id: str = field(default_factory=lambda: new_id("otask"))
external_ref: str | None = None
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
@dataclass(frozen=True)
class SubsystemHandoff:
subsystem: str
handoff_id: str = field(default_factory=lambda: new_id("hnd"))
callback_ref: str | None = None
lifecycle_gap: str | None = None
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
@dataclass(frozen=True)
class WelcomeProtocolStep:
step_key: str
title: str
subsystem: str
task_kind: str = "welcome"
task_ref: str | None = None
callback_ref: str | None = None
support_ref: str | None = None
requires_subsystem_callback: bool = False
@dataclass(frozen=True)
class WelcomeProtocol:
tenant: str
name: str
trigger_type: OnboardingTriggerType
steps: tuple[WelcomeProtocolStep, ...]
protocol_id: str = field(default_factory=lambda: new_id("wpro"))
journey_key: str | None = None
registration_status: RegistrationStatus | None = None
prepared_journey: str | None = None
application_id: str | None = None
realm_id: str | None = None
service_id: str | None = None
role: str | None = None
hat: str | None = None
required_factor_types: tuple[IdentityFactorType, ...] = ()
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class OnboardingStep:
step_key: str
title: str
subsystem: str
step_id: str = field(default_factory=lambda: new_id("ostep"))
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
task: OnboardingTask | None = None
handoff: SubsystemHandoff | None = None
support_ref: str | None = None
lifecycle_gap: str | None = None
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class OnboardingJourney:
tenant: str
user_id: str
protocol_id: str
trigger_type: OnboardingTriggerType
steps: tuple[OnboardingStep, ...]
journey_id: str = field(default_factory=lambda: new_id("ojrn"))
status: OnboardingJourneyStatus = OnboardingJourneyStatus.PENDING
source_id: str | None = None
source_event_type: str | None = None
journey_key: str | None = None
active_step_key: str | None = None
correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
completed_at: datetime | None = None
failed_at: datetime | None = None
skipped_at: datetime | None = None
@dataclass(frozen=True)
class FamilyMemberSpec:
primary_email: str
@@ -313,6 +533,101 @@ class FamilyInvitation:
revoked_at: datetime | None = None
@dataclass(frozen=True)
class FactorVerification:
factor_type: IdentityFactorType
normalized_value: str
verification_id: str = field(default_factory=lambda: new_id("fvr"))
display_value: str | None = None
source_system: str = "external-proofing"
assurance: Mapping[str, Any] = field(default_factory=dict)
evidence_refs: tuple[CanonEntityReference, ...] = ()
verified_at: datetime = field(default_factory=utc_now)
expires_at: datetime | None = None
@dataclass(frozen=True)
class IdentityFactor:
factor_type: IdentityFactorType
normalized_value: str
factor_id: str = field(default_factory=lambda: new_id("fac"))
registration_id: str | None = None
user_id: str | None = None
display_value: str | None = None
source_system: str = "external-proofing"
assurance: Mapping[str, Any] = field(default_factory=dict)
evidence_refs: tuple[CanonEntityReference, ...] = ()
verified_at: datetime = field(default_factory=utc_now)
expires_at: datetime | None = None
@dataclass(frozen=True)
class RegistrationSession:
tenant: str
registration_id: str = field(default_factory=lambda: new_id("reg"))
status: RegistrationStatus = RegistrationStatus.STARTED
required_factor_types: tuple[IdentityFactorType, ...] = ()
verified_factor_ids: tuple[str, ...] = ()
user_id: str | None = None
netkingdom_id: str | None = None
started_by_subject: str | None = None
correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
completed_at: datetime | None = None
abandoned_at: datetime | None = None
expired_at: datetime | None = None
rejected_at: datetime | None = None
@dataclass(frozen=True)
class PreparedFactorRequirement:
factor_type: IdentityFactorType
normalized_value: str
source_system: str | None = None
evidence_refs: tuple[CanonEntityReference, ...] = ()
@dataclass(frozen=True)
class PreparedEntitlement:
kind: PreparedEntitlementKind
tenant: str
entitlement_id: str = field(default_factory=lambda: new_id("pent"))
scope_type: str | None = None
scope_id: str | None = None
role: str | None = None
attribute_key: str | None = None
value: Any = None
profile_scope: ProfileScope = ProfileScope.GLOBAL
profile_scope_id: str | None = None
tenant_account_status: AccountStatus = AccountStatus.ACTIVE
application_binding: ApplicationBinding | None = None
onboarding_journey: str | None = None
requires_approval: bool = False
evidence_refs: tuple[CanonEntityReference, ...] = ()
@dataclass(frozen=True)
class PreparedAccount:
tenant: str
required_factor_matches: tuple[PreparedFactorRequirement, ...]
entitlements: tuple[PreparedEntitlement, ...]
prepared_account_id: str = field(default_factory=lambda: new_id("pacct"))
status: PreparedAccountStatus = PreparedAccountStatus.PENDING
display_name: str | None = None
primary_email: str | None = None
prepared_by_subject: str | None = None
correlation_id: str | None = None
expires_at: datetime | None = None
claimed_by_user_id: str | None = None
claimed_registration_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
claimed_at: datetime | None = None
revoked_at: datetime | None = None
expired_at: datetime | None = None
@dataclass(frozen=True)
class AuthorizationRequest:
actor: Actor

View File

@@ -0,0 +1,124 @@
"""Migration manifest for durable user-engine store adapters."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
@dataclass(frozen=True)
class MigrationStep:
"""One ordered durable-store migration known to user-engine."""
version: str
name: str
description: str
record_types: tuple[str, ...]
sql_path: str | None = None
USER_ENGINE_STORE_RECORD_TYPES = (
"users",
"accounts",
"external_identities",
"tenant_accounts",
"memberships",
"applications",
"application_bindings",
"catalogs",
"family_invitations",
"registration_sessions",
"identity_factors",
"prepared_accounts",
"access_profiles",
"active_access_contexts",
"welcome_protocols",
"onboarding_journeys",
"profile_values",
"audit_records",
"outbox_events",
)
USER_ENGINE_RECORD_COUNT_KEYS = (
"users",
"accounts",
"tenant_accounts",
"memberships",
"applications",
"bindings",
"catalogs",
"family_invitations",
"registration_sessions",
"identity_factors",
"prepared_accounts",
"access_profiles",
"active_access_contexts",
"welcome_protocols",
"onboarding_journeys",
"profile_values",
"audit_records",
"pending_outbox_events",
)
USER_ENGINE_MIGRATIONS = (
MigrationStep(
version="0001_initial",
name="initial durable store",
description=(
"Create the schema-version ledger, logical record table, audit "
"log table, and pending outbox table used by user-engine store "
"adapters."
),
record_types=USER_ENGINE_STORE_RECORD_TYPES,
sql_path="migrations/postgres/0001_user_engine_store.sql",
),
)
LATEST_SCHEMA_VERSION = USER_ENGINE_MIGRATIONS[-1].version
def migration_manifest() -> tuple[MigrationStep, ...]:
"""Return the ordered durable-store migration manifest."""
return USER_ENGINE_MIGRATIONS
def validate_migration_manifest(
migrations: tuple[MigrationStep, ...] = USER_ENGINE_MIGRATIONS,
) -> tuple[str, ...]:
"""Return manifest validation errors without touching provider resources."""
errors: list[str] = []
if not migrations:
return ("migration manifest must not be empty",)
seen_versions: set[str] = set()
previous_version = ""
for migration in migrations:
if not migration.version:
errors.append("migration version must not be empty")
if migration.version in seen_versions:
errors.append(f"duplicate migration version {migration.version}")
if previous_version and migration.version <= previous_version:
errors.append(
f"migration {migration.version} must sort after {previous_version}"
)
if not migration.name:
errors.append(f"migration {migration.version} must have a name")
if not migration.record_types:
errors.append(
f"migration {migration.version} must declare logical record types"
)
if len(set(migration.record_types)) != len(migration.record_types):
errors.append(f"migration {migration.version} has duplicate record types")
if migration.sql_path is not None:
path = PurePosixPath(migration.sql_path)
if path.is_absolute() or ".." in path.parts:
errors.append(
f"migration {migration.version} sql_path must stay repo-relative"
)
if path.suffix != ".sql":
errors.append(
f"migration {migration.version} sql_path must reference SQL"
)
seen_versions.add(migration.version)
previous_version = migration.version
return tuple(errors)

View File

@@ -7,20 +7,235 @@ 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,
AccessControlFact,
AccessProfile,
ActiveAccessContext,
Actor,
Application,
ApplicationBinding,
AuditRecord,
AuthorizationDecision,
AuthorizationRequest,
CanonEntityReference,
Catalog,
ExternalIdentity,
FactorVerification,
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileValue,
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
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_registration_session(self, session: RegistrationSession) -> None:
"""Create or replace a registration session."""
def registration_session(
self, registration_id: str
) -> RegistrationSession | None:
"""Return a registration session by id."""
def all_registration_sessions(self) -> tuple[RegistrationSession, ...]:
"""Return all registration sessions."""
def save_identity_factor(self, factor: IdentityFactor) -> None:
"""Create or replace a verified identity factor."""
def identity_factor(self, factor_id: str) -> IdentityFactor | None:
"""Return a verified identity factor by id."""
def factors_for_registration(
self, registration_id: str
) -> tuple[IdentityFactor, ...]:
"""Return verified factors attached to a registration session."""
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
"""Return verified factors attached to a user."""
def save_prepared_account(self, account: PreparedAccount) -> None:
"""Create or replace a prepared account package."""
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
"""Return a prepared account package by id."""
def prepared_accounts_for_tenant(
self, tenant: str
) -> tuple[PreparedAccount, ...]:
"""Return prepared account packages for a tenant."""
def save_access_profile(self, profile: AccessProfile) -> None:
"""Create or replace an access profile template."""
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
"""Return an access profile template by id."""
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
"""Return access profile templates for a tenant."""
def save_active_access_context(self, context: ActiveAccessContext) -> None:
"""Create or replace the user's active access context for a tenant."""
def active_access_context(
self, user_id: str, tenant: str
) -> ActiveAccessContext | None:
"""Return the user's active access context for a tenant."""
def active_access_contexts_for_tenant(
self, tenant: str
) -> tuple[ActiveAccessContext, ...]:
"""Return active access contexts for a tenant."""
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
"""Create or replace a welcome protocol template."""
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
"""Return a welcome protocol template by id."""
def welcome_protocols_for_tenant(
self, tenant: str
) -> tuple[WelcomeProtocol, ...]:
"""Return welcome protocol templates for a tenant."""
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
"""Create or replace an onboarding journey."""
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
"""Return an onboarding journey by id."""
def onboarding_journeys_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[OnboardingJourney, ...]:
"""Return onboarding journeys for a user."""
def onboarding_journeys_for_tenant(
self, tenant: str
) -> tuple[OnboardingJourney, ...]:
"""Return onboarding journeys for a tenant."""
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."""
@@ -31,6 +246,13 @@ class IdentityClaimsAdapter(Protocol):
"""Return the stable external identity link key."""
class FactorVerificationAdapter(Protocol):
"""Normalize external proofing results into safe factor evidence."""
def normalize(self, proofing_result: Mapping[str, Any]) -> FactorVerification:
"""Return normalized verified factor evidence without secret payloads."""
class AuthorizationCheckPort(Protocol):
"""Ask whether an actor may perform an action."""
@@ -60,6 +282,48 @@ class MembershipFactExporter(Protocol):
"""Return an adapter-neutral membership fact manifest."""
class AccessControlFactExporter(Protocol):
"""Export access-control facts to an external policy or ACL system."""
def export(self, facts: Iterable[AccessControlFact]) -> Mapping[str, Any]:
"""Return an adapter-neutral access-control fact manifest."""
class OnboardingNotificationPort(Protocol):
"""Notify a delivery system about onboarding journey state."""
def notify(self, journey: OnboardingJourney) -> Mapping[str, Any]:
"""Return adapter metadata for a notification request."""
class OnboardingTaskPort(Protocol):
"""Create or link external lifecycle tasks for onboarding steps."""
def link_task(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
"""Return task-link metadata for one onboarding step."""
class SupportContentPort(Protocol):
"""Resolve support or help content references for onboarding."""
def content_ref(self, protocol: WelcomeProtocol, step_key: str) -> str | None:
"""Return an adapter-owned content reference for a protocol step."""
class SubsystemWelcomePort(Protocol):
"""Call a protected subsystem welcome callback."""
def start(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
"""Return callback metadata for a subsystem welcome step."""
class LifecycleTaskLinkPort(Protocol):
"""Link onboarding journeys to external lifecycle task systems."""
def link(self, journey: OnboardingJourney) -> Mapping[str, Any]:
"""Return lifecycle task references for an onboarding journey."""
class EventOutbox(Protocol):
"""Persist and publish durable domain events."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,357 @@
"""JSONB-oriented durable store record serialization."""
from __future__ import annotations
import json
from collections.abc import Mapping as MappingABC
from dataclasses import dataclass, fields, is_dataclass
from datetime import datetime
from enum import Enum
from types import NoneType, UnionType
from typing import Any, Callable, Mapping, Union, get_args, get_origin, get_type_hints
from user_engine.domain import (
Account,
AccessProfile,
ActiveAccessContext,
Application,
ApplicationBinding,
AuditRecord,
Catalog,
ExternalIdentity,
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileValue,
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
from user_engine.migrations import USER_ENGINE_STORE_RECORD_TYPES
@dataclass(frozen=True)
class StoreRecord:
"""One generic durable-store row for a domain object payload."""
record_type: str
record_key: str
payload: Mapping[str, Any]
tenant: str | None = None
user_id: str | None = None
application_id: str | None = None
scope_type: str | None = None
scope_id: str | None = None
@dataclass(frozen=True)
class StoreRecordCodec:
"""Codec rule for one user-engine store record type."""
record_type: str
model_type: type[Any]
record_key: Callable[[Any], str]
metadata: Callable[[Any], Mapping[str, str | None]]
def store_record_for(value: Any) -> StoreRecord:
"""Return a generic durable-store record for a supported domain object."""
codec = _CODECS_BY_MODEL.get(type(value))
if codec is None:
raise TypeError(f"unsupported store record type: {type(value).__name__}")
metadata = dict(codec.metadata(value))
return StoreRecord(
record_type=codec.record_type,
record_key=codec.record_key(value),
payload=_encode_dataclass(value),
tenant=metadata.get("tenant"),
user_id=metadata.get("user_id"),
application_id=metadata.get("application_id"),
scope_type=metadata.get("scope_type"),
scope_id=metadata.get("scope_id"),
)
def composite_record_key(*parts: str | None) -> str:
"""Return the deterministic composite key used by durable store records."""
return _composite_key(*parts)
def domain_record_from_store_record(record: StoreRecord) -> Any:
"""Decode a durable-store record payload into its domain dataclass."""
codec = _CODECS_BY_RECORD_TYPE.get(record.record_type)
if codec is None:
raise ValueError(f"unsupported store record type: {record.record_type}")
return _decode_dataclass(codec.model_type, record.payload)
def validate_store_record_codecs() -> tuple[str, ...]:
"""Return codec coverage errors against the durable-store manifest."""
errors: list[str] = []
manifest_types = set(USER_ENGINE_STORE_RECORD_TYPES)
codec_types = set(_CODECS_BY_RECORD_TYPE)
missing = sorted(manifest_types - codec_types)
extra = sorted(codec_types - manifest_types)
if missing:
errors.append(f"missing codecs for: {', '.join(missing)}")
if extra:
errors.append(f"extra codecs for: {', '.join(extra)}")
return tuple(errors)
def _encode_dataclass(value: Any) -> Mapping[str, Any]:
if not is_dataclass(value):
raise TypeError(f"expected dataclass, got {type(value).__name__}")
return {
field.name: _encode_value(getattr(value, field.name))
for field in fields(value)
}
def _encode_value(value: Any) -> Any:
if is_dataclass(value):
return _encode_dataclass(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Enum):
return value.value
if isinstance(value, tuple):
return [_encode_value(item) for item in value]
if isinstance(value, list):
return [_encode_value(item) for item in value]
if isinstance(value, MappingABC):
return {str(key): _encode_value(item) for key, item in value.items()}
return value
def _decode_dataclass(model_type: type[Any], payload: Mapping[str, Any]) -> Any:
hints = get_type_hints(model_type)
kwargs = {
field.name: _decode_value(payload[field.name], hints[field.name])
for field in fields(model_type)
if field.name in payload
}
return model_type(**kwargs)
def _decode_value(value: Any, type_hint: Any) -> Any:
if value is None:
return None
if type_hint is Any:
return value
origin = get_origin(type_hint)
args = get_args(type_hint)
if origin in (UnionType, Union):
non_none_args = tuple(arg for arg in args if arg is not NoneType)
if len(non_none_args) == 1 and len(non_none_args) != len(args):
return _decode_value(value, non_none_args[0])
for arg in non_none_args:
try:
return _decode_value(value, arg)
except (TypeError, ValueError):
continue
return value
if type_hint is datetime:
return datetime.fromisoformat(str(value))
if isinstance(type_hint, type) and issubclass(type_hint, Enum):
return type_hint(value)
if isinstance(type_hint, type) and is_dataclass(type_hint):
return _decode_dataclass(type_hint, value)
if origin is tuple:
if not args:
return tuple(value)
item_hint = args[0] if len(args) == 2 and args[1] is Ellipsis else None
if item_hint is not None:
return tuple(_decode_value(item, item_hint) for item in value)
return tuple(
_decode_value(item, args[index])
for index, item in enumerate(value)
)
if origin is list:
item_hint = args[0] if args else Any
return [_decode_value(item, item_hint) for item in value]
if origin in (dict, Mapping, MappingABC):
return dict(value)
if type_hint in (str, int, float, bool):
return type_hint(value)
return value
def _single_key(value: str) -> str:
return value
def _composite_key(*parts: str | None) -> str:
return json.dumps(list(parts), separators=(",", ":"), ensure_ascii=True)
def _enum_value(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, Enum):
return str(value.value)
return str(value)
_CODECS = (
StoreRecordCodec(
"users",
User,
lambda value: _single_key(value.user_id),
lambda value: {"user_id": value.user_id},
),
StoreRecordCodec(
"accounts",
Account,
lambda value: _single_key(value.user_id),
lambda value: {"user_id": value.user_id},
),
StoreRecordCodec(
"external_identities",
ExternalIdentity,
lambda value: _composite_key(value.issuer, value.subject),
lambda value: {"user_id": value.user_id},
),
StoreRecordCodec(
"tenant_accounts",
TenantAccount,
lambda value: _composite_key(value.tenant, value.user_id),
lambda value: {"tenant": value.tenant, "user_id": value.user_id},
),
StoreRecordCodec(
"memberships",
Membership,
lambda value: _single_key(value.membership_id),
lambda value: {
"tenant": value.tenant,
"user_id": value.user_id,
"scope_type": value.scope_type,
"scope_id": value.scope_id,
},
),
StoreRecordCodec(
"applications",
Application,
lambda value: _single_key(value.application_id),
lambda value: {"application_id": value.application_id},
),
StoreRecordCodec(
"application_bindings",
ApplicationBinding,
lambda value: _single_key(value.application_id),
lambda value: {"application_id": value.application_id},
),
StoreRecordCodec(
"catalogs",
Catalog,
lambda value: _single_key(value.catalog_id),
lambda value: {"application_id": value.owning_application_id},
),
StoreRecordCodec(
"family_invitations",
FamilyInvitation,
lambda value: _single_key(value.invitation_id),
lambda value: {
"tenant": value.tenant,
"user_id": value.user_id,
"application_id": value.application_id,
"scope_type": "family",
"scope_id": value.family_scope_id,
},
),
StoreRecordCodec(
"registration_sessions",
RegistrationSession,
lambda value: _single_key(value.registration_id),
lambda value: {"tenant": value.tenant, "user_id": value.user_id},
),
StoreRecordCodec(
"identity_factors",
IdentityFactor,
lambda value: _single_key(value.factor_id),
lambda value: {
"user_id": value.user_id,
"scope_type": "registration" if value.registration_id else None,
"scope_id": value.registration_id,
},
),
StoreRecordCodec(
"prepared_accounts",
PreparedAccount,
lambda value: _single_key(value.prepared_account_id),
lambda value: {"tenant": value.tenant},
),
StoreRecordCodec(
"access_profiles",
AccessProfile,
lambda value: _single_key(value.access_profile_id),
lambda value: {
"tenant": value.tenant,
"scope_type": _enum_value(value.scope_type),
"scope_id": value.scope_id,
},
),
StoreRecordCodec(
"active_access_contexts",
ActiveAccessContext,
lambda value: _composite_key(value.user_id, value.tenant),
lambda value: {
"tenant": value.tenant,
"user_id": value.user_id,
"scope_type": _enum_value(value.scope_type),
"scope_id": value.scope_id,
},
),
StoreRecordCodec(
"welcome_protocols",
WelcomeProtocol,
lambda value: _single_key(value.protocol_id),
lambda value: {
"tenant": value.tenant,
"application_id": value.application_id,
},
),
StoreRecordCodec(
"onboarding_journeys",
OnboardingJourney,
lambda value: _single_key(value.journey_id),
lambda value: {"tenant": value.tenant, "user_id": value.user_id},
),
StoreRecordCodec(
"profile_values",
ProfileValue,
lambda value: _composite_key(
value.user_id,
value.attribute_key,
_enum_value(value.scope),
value.scope_id,
),
lambda value: {
"user_id": value.user_id,
"scope_type": _enum_value(value.scope),
"scope_id": value.scope_id,
},
),
StoreRecordCodec(
"audit_records",
AuditRecord,
lambda value: _single_key(value.audit_id),
lambda value: {"tenant": value.tenant, "user_id": value.subject},
),
StoreRecordCodec(
"outbox_events",
OutboxEvent,
lambda value: _single_key(value.event_id),
lambda value: {"tenant": value.tenant},
),
)
_CODECS_BY_RECORD_TYPE = {codec.record_type: codec for codec in _CODECS}
_CODECS_BY_MODEL = {codec.model_type: codec for codec in _CODECS}

View File

@@ -1 +1,11 @@
"""Testing helpers and local fixtures for user-engine."""
from user_engine.testing.store_conformance import (
assert_user_engine_migration_contract,
assert_user_engine_store_conformance,
)
__all__ = [
"assert_user_engine_migration_contract",
"assert_user_engine_store_conformance",
]

View File

@@ -0,0 +1,82 @@
"""Opt-in live Postgres conformance helpers for provider repositories."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Mapping
from user_engine.adapters.postgres import PostgresConnection, PostgresUserEngineStore
POSTGRES_TEST_DSN_ENV = "USER_ENGINE_POSTGRES_TEST_DSN"
POSTGRES_TEST_RESET_ENV = "USER_ENGINE_POSTGRES_TEST_RESET"
_TRUTHY = {"1", "true", "yes", "on"}
_TABLES = (
"user_engine_outbox_events",
"user_engine_audit_records",
"user_engine_records",
"user_engine_schema_versions",
)
@dataclass(frozen=True)
class PostgresProviderTestConfig:
"""Configuration for destructive provider-backed Postgres tests."""
dsn: str
def postgres_provider_test_config(
environ: Mapping[str, str] | None = None,
) -> tuple[PostgresProviderTestConfig | None, str | None]:
"""Return live test config or a skip reason."""
env = environ or os.environ
dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip()
if not dsn:
return None, f"{POSTGRES_TEST_DSN_ENV} is not set"
reset_value = env.get(POSTGRES_TEST_RESET_ENV, "").strip().lower()
if reset_value not in _TRUTHY:
return (
None,
f"{POSTGRES_TEST_RESET_ENV}=1 is required because tests reset "
"user_engine_* tables",
)
return PostgresProviderTestConfig(dsn=dsn), None
def connect_postgres_provider(dsn: str) -> PostgresConnection:
"""Connect with psycopg3 or psycopg2 when a provider installs either one."""
try:
import psycopg # type: ignore[import-not-found]
return psycopg.connect(dsn) # type: ignore[no-any-return]
except ImportError:
pass
try:
import psycopg2 # type: ignore[import-not-found]
return psycopg2.connect(dsn) # type: ignore[no-any-return]
except ImportError as exc:
raise RuntimeError("install psycopg or psycopg2 to run live tests") from exc
def reset_user_engine_postgres_tables(connection: PostgresConnection) -> None:
"""Create then empty user-engine tables in a dedicated provider test DB."""
PostgresUserEngineStore(connection).migrate()
cursor = connection.cursor()
try:
for table in _TABLES:
cursor.execute(f"DELETE FROM {table}")
finally:
close = getattr(cursor, "close", None)
if callable(close):
close()
connection.commit()
def close_postgres_provider_connection(connection: Any) -> None:
"""Close provider connections that expose a close method."""
close = getattr(connection, "close", None)
if callable(close):
close()

View File

@@ -26,7 +26,67 @@ SCENARIO_MATRIX = (
"two_applications",
"sensitive_redaction",
"audit_event_replay",
"identity_canon_context",
"family_dataspace_onboarding",
"registration_onboarding_full",
"prepared_account_claim",
"privileged_role_requires_approval",
"eid_assurance_registration",
"tenant_admin_invite",
"group_access_hat",
"denied_cross_tenant_claim",
"ui_registration_access_flow",
)
REGISTRATION_SCENARIO_MATRIX = (
{
"id": "self_registration",
"actor": "human",
"factors": ("email",),
"expects": ("registration.completed", "identity_context", "netkingdom_id"),
},
{
"id": "prepared_account_claim",
"actor": "human",
"factors": ("email",),
"expects": ("prepared_account.claimed", "membership", "onboarding_journey"),
},
{
"id": "privileged_role_requires_approval",
"actor": "human",
"factors": ("email",),
"expects": ("authorization_denied", "no_membership_mutation"),
},
{
"id": "eid_assurance_registration",
"actor": "human",
"factors": ("eid",),
"expects": ("registration.completed", "high_assurance_factor"),
},
{
"id": "family_invite",
"actor": "family-owner",
"factors": ("sso",),
"expects": ("family_invitation.accepted", "claims_projection"),
},
{
"id": "tenant_admin_invite",
"actor": "tenant-admin",
"factors": ("email",),
"expects": ("prepared_account.created", "tenant_diagnostics"),
},
{
"id": "group_access",
"actor": "human",
"factors": ("email",),
"expects": ("active_access_context", "access_control_fact"),
},
{
"id": "denied_cross_tenant_claim",
"actor": "human",
"factors": ("email",),
"expects": ("authorization_denied", "audit_record", "no_outbox_event"),
},
)

View File

@@ -0,0 +1,527 @@
"""Reusable conformance checks for user-engine store adapters."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from unittest import TestCase
from user_engine.domain import (
Account,
AccountStatus,
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
ActiveAccessContext,
Actor,
Application,
ApplicationBinding,
AttributeDefinition,
AuditRecord,
Catalog,
CatalogLifecycle,
ExternalIdentity,
FamilyInvitation,
IdentityFactor,
IdentityFactorType,
InvitationStatus,
Membership,
Mutability,
OnboardingJourney,
OnboardingStep,
OnboardingTriggerType,
OutboxEvent,
PreparedAccount,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
PrincipalType,
ProfileScope,
ProfileValue,
ProjectionType,
RegistrationSession,
RegistrationStatus,
Sensitivity,
TenantAccount,
User,
Visibility,
WelcomeProtocol,
WelcomeProtocolStep,
)
from user_engine.migrations import (
LATEST_SCHEMA_VERSION,
USER_ENGINE_RECORD_COUNT_KEYS,
migration_manifest,
validate_migration_manifest,
)
from user_engine.ports import UserEngineStore
StoreFactory = Callable[[], UserEngineStore]
TENANT = "tenant:store-conformance"
USER_ID = "usr_store_conformance"
RAW_FACTOR_VALUE = "store.user@example.test"
PROFILE_SECRET_VALUE = "quiet-secret-profile-value"
def assert_user_engine_migration_contract(testcase: TestCase) -> None:
"""Assert the migration manifest is ordered and provider-safe."""
testcase.assertEqual(validate_migration_manifest(), ())
manifest = migration_manifest()
testcase.assertGreaterEqual(len(manifest), 1)
testcase.assertEqual(manifest[-1].version, LATEST_SCHEMA_VERSION)
testcase.assertEqual(
manifest[0].sql_path,
"migrations/postgres/0001_user_engine_store.sql",
)
def assert_user_engine_store_conformance(
testcase: TestCase,
store_factory: StoreFactory,
) -> None:
"""Run the core durable-store behavior contract for one store factory."""
assert_user_engine_migration_contract(testcase)
_assert_readiness_contract(testcase, store_factory)
_assert_save_read_and_query_contract(testcase, store_factory)
_assert_transaction_rollback_contract(testcase, store_factory)
_assert_outbox_ordering_contract(testcase, store_factory)
_assert_diagnostics_contract(testcase, store_factory)
def reference_store_records(store: UserEngineStore) -> dict[str, Any]:
"""Write and return a representative record for every store record type."""
return _write_reference_records(store)
def _assert_readiness_contract(testcase: TestCase, store_factory: StoreFactory) -> None:
store = store_factory()
if store.schema_version is None:
testcase.assertFalse(store.ready)
store.migrate()
testcase.assertTrue(store.ready)
testcase.assertEqual(store.schema_version, LATEST_SCHEMA_VERSION)
store.migrate()
testcase.assertTrue(store.ready)
testcase.assertEqual(store.schema_version, LATEST_SCHEMA_VERSION)
def _assert_save_read_and_query_contract(
testcase: TestCase,
store_factory: StoreFactory,
) -> None:
store = _migrated(store_factory)
records = _write_reference_records(store)
user = records["user"]
account = records["account"]
identity = records["identity"]
tenant_account = records["tenant_account"]
membership = records["membership"]
application = records["application"]
binding = records["binding"]
catalog = records["catalog"]
invitation = records["invitation"]
registration = records["registration"]
factor = records["factor"]
prepared_account = records["prepared_account"]
access_profile = records["access_profile"]
access_context = records["access_context"]
welcome_protocol = records["welcome_protocol"]
onboarding_journey = records["onboarding_journey"]
profile_value = records["profile_value"]
testcase.assertEqual(store.user(USER_ID), user)
testcase.assertEqual(store.user_account(USER_ID), account)
testcase.assertEqual(store.find_identity(identity.issuer, identity.subject), identity)
testcase.assertEqual(store.identities_for_user(USER_ID), (identity,))
testcase.assertEqual(store.tenant_account(TENANT, USER_ID), tenant_account)
testcase.assertEqual(store.memberships_for_user(USER_ID), (membership,))
testcase.assertEqual(store.memberships_for_user(USER_ID, tenant=TENANT), (membership,))
testcase.assertEqual(store.memberships_for_tenant(TENANT), (membership,))
testcase.assertEqual(store.application(application.application_id), application)
testcase.assertEqual(store.binding(binding.application_id), binding)
testcase.assertEqual(store.catalog(catalog.catalog_id), catalog)
testcase.assertEqual(store.all_catalogs(), (catalog,))
testcase.assertEqual(store.family_invitation(invitation.invitation_id), invitation)
testcase.assertEqual(store.family_invitations_for_user(USER_ID), (invitation,))
testcase.assertEqual(store.registration_session(registration.registration_id), registration)
testcase.assertEqual(store.all_registration_sessions(), (registration,))
testcase.assertEqual(store.identity_factor(factor.factor_id), factor)
testcase.assertEqual(store.factors_for_registration(registration.registration_id), (factor,))
testcase.assertEqual(store.factors_for_user(USER_ID), (factor,))
testcase.assertEqual(
store.prepared_account(prepared_account.prepared_account_id),
prepared_account,
)
testcase.assertEqual(store.prepared_accounts_for_tenant(TENANT), (prepared_account,))
testcase.assertEqual(store.access_profile(access_profile.access_profile_id), access_profile)
testcase.assertEqual(store.access_profiles_for_tenant(TENANT), (access_profile,))
testcase.assertEqual(store.active_access_context(USER_ID, TENANT), access_context)
testcase.assertEqual(store.active_access_contexts_for_tenant(TENANT), (access_context,))
testcase.assertEqual(store.welcome_protocol(welcome_protocol.protocol_id), welcome_protocol)
testcase.assertEqual(store.welcome_protocols_for_tenant(TENANT), (welcome_protocol,))
testcase.assertEqual(
store.onboarding_journey(onboarding_journey.journey_id),
onboarding_journey,
)
testcase.assertEqual(store.onboarding_journeys_for_user(USER_ID), (onboarding_journey,))
testcase.assertEqual(
store.onboarding_journeys_for_user(USER_ID, tenant=TENANT),
(onboarding_journey,),
)
testcase.assertEqual(store.onboarding_journeys_for_tenant(TENANT), (onboarding_journey,))
testcase.assertEqual(store.values_for_user(USER_ID), (profile_value,))
replacement = User(
user_id=USER_ID,
display_name="Replacement User",
primary_email=RAW_FACTOR_VALUE,
created_at=user.created_at,
)
store.save_user(replacement)
testcase.assertEqual(store.user(USER_ID), replacement)
def _assert_transaction_rollback_contract(
testcase: TestCase,
store_factory: StoreFactory,
) -> None:
store = _migrated(store_factory)
actor = _actor()
with testcase.assertRaises(RuntimeError):
with store.transaction():
store.save_user(User(user_id="usr_rollback", display_name="Rollback"))
store.append_audit(
AuditRecord(
audit_id="aud_rollback",
actor=actor,
action="store.write",
subject="usr_rollback",
tenant=TENANT,
correlation_id="corr-rollback",
summary="rolled back audit",
)
)
store.append_outbox(
OutboxEvent(
event_id="evt_rollback",
event_type="store.rollback",
aggregate_id="usr_rollback",
payload={"result": "rollback"},
tenant=TENANT,
correlation_id="corr-rollback",
)
)
raise RuntimeError("force rollback")
testcase.assertIsNone(store.user("usr_rollback"))
testcase.assertEqual(store.audit_log(), ())
testcase.assertEqual(store.pending_outbox(), ())
def _assert_outbox_ordering_contract(
testcase: TestCase,
store_factory: StoreFactory,
) -> None:
store = _migrated(store_factory)
events = (
OutboxEvent(
event_id="evt_order_1",
event_type="store.first",
aggregate_id=USER_ID,
payload={"sequence": 1},
tenant=TENANT,
correlation_id="corr-order",
),
OutboxEvent(
event_id="evt_order_2",
event_type="store.second",
aggregate_id=USER_ID,
payload={"sequence": 2},
tenant=TENANT,
correlation_id="corr-order",
),
)
for event in events:
store.append_outbox(event)
testcase.assertEqual(store.pending_outbox(), events)
def _assert_diagnostics_contract(
testcase: TestCase,
store_factory: StoreFactory,
) -> None:
store = _migrated(store_factory)
_write_reference_records(store)
counts = dict(store.record_counts())
testcase.assertEqual(set(counts), set(USER_ENGINE_RECORD_COUNT_KEYS))
testcase.assertTrue(all(isinstance(value, int) for value in counts.values()))
testcase.assertEqual(counts["bindings"], 1)
testcase.assertEqual(counts["pending_outbox_events"], 1)
diagnostics_text = repr(counts)
testcase.assertNotIn(RAW_FACTOR_VALUE, diagnostics_text)
testcase.assertNotIn(PROFILE_SECRET_VALUE, diagnostics_text)
def _migrated(store_factory: StoreFactory) -> UserEngineStore:
store = store_factory()
store.migrate()
return store
def _write_reference_records(store: UserEngineStore) -> dict[str, Any]:
actor = _actor()
user = User(
user_id=USER_ID,
display_name="Store Conformance User",
primary_email=RAW_FACTOR_VALUE,
)
account = Account(
account_id="acct_store_conformance",
user_id=USER_ID,
status=AccountStatus.ACTIVE,
)
identity = ExternalIdentity(
identity_id="eid_store_conformance",
user_id=USER_ID,
issuer="https://issuer.example.test",
subject="store-conformance",
provider="fixture",
)
tenant_account = TenantAccount(user_id=USER_ID, tenant=TENANT)
membership = Membership(
membership_id="mbr_store_conformance",
user_id=USER_ID,
tenant=TENANT,
scope_type="team",
scope_id="team:store-conformance",
kind="member",
)
application = Application(
application_id="app.store-conformance",
display_name="Store Conformance",
owner="team:store-conformance",
allowed_profile_scopes=(ProfileScope.GLOBAL, ProfileScope.APPLICATION),
allowed_projection_types=(ProjectionType.APPLICATION_RUNTIME,),
)
binding = ApplicationBinding(
application_id=application.application_id,
oidc_client_id="store-conformance-client",
protected_system_id="store-conformance.service",
catalog_namespaces=("store",),
event_source="store-conformance",
deployment_ref="local",
)
catalog = Catalog(
catalog_id="cat_store_conformance",
namespace="store",
version="1.0.0",
owning_application_id=application.application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
AttributeDefinition(
key="store.secret",
value_type="string",
scope=ProfileScope.GLOBAL,
sensitivity=Sensitivity.SECRET,
visibility=(Visibility.USER,),
mutability=(Mutability.USER,),
),
),
)
invitation = FamilyInvitation(
invitation_id="finv_store_conformance",
tenant=TENANT,
family_scope_id="family:store-conformance",
application_id=application.application_id,
user_id=USER_ID,
primary_email=RAW_FACTOR_VALUE,
role="adult",
status=InvitationStatus.PENDING,
invited_by=actor.subject,
)
registration = RegistrationSession(
tenant=TENANT,
registration_id="reg_store_conformance",
status=RegistrationStatus.FACTOR_VERIFIED,
required_factor_types=(IdentityFactorType.EMAIL,),
verified_factor_ids=("fac_store_conformance",),
user_id=USER_ID,
netkingdom_id="nk-store-conformance",
started_by_subject=actor.subject,
correlation_id="corr-store",
)
factor = IdentityFactor(
factor_id="fac_store_conformance",
factor_type=IdentityFactorType.EMAIL,
normalized_value=RAW_FACTOR_VALUE,
registration_id=registration.registration_id,
user_id=USER_ID,
display_value="s***@example.test",
)
prepared_account = PreparedAccount(
tenant=TENANT,
prepared_account_id="pacct_store_conformance",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=RAW_FACTOR_VALUE,
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant=TENANT,
scope_type="team",
scope_id="team:store-conformance",
role="member",
),
),
display_name="Prepared Store User",
primary_email=RAW_FACTOR_VALUE,
prepared_by_subject=actor.subject,
correlation_id="corr-store",
)
access_profile = AccessProfile(
tenant=TENANT,
display_name="Store Member",
hat="member",
access_profile_id="apf_store_conformance",
scope_type=AccessScopeType.TENANT,
scope_id=TENANT,
membership_requirements=(
AccessMembershipRequirement(
scope_type="team",
scope_id="team:store-conformance",
kind="member",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
profile_defaults={"store.secret": PROFILE_SECRET_VALUE},
claims={"role": "member"},
)
access_context = ActiveAccessContext(
active_context_id="actx_store_conformance",
user_id=USER_ID,
tenant=TENANT,
access_profile_id=access_profile.access_profile_id,
hat=access_profile.hat,
scope_type=AccessScopeType.TENANT,
scope_id=TENANT,
membership_ids=(membership.membership_id,),
factor_ids=(factor.factor_id,),
selected_by_subject=actor.subject,
)
welcome_protocol = WelcomeProtocol(
protocol_id="wpro_store_conformance",
tenant=TENANT,
name="Store Welcome",
trigger_type=OnboardingTriggerType.REGISTRATION,
steps=(
WelcomeProtocolStep(
step_key="profile",
title="Complete profile",
subsystem="user-account",
),
),
)
onboarding_journey = OnboardingJourney(
journey_id="ojrn_store_conformance",
tenant=TENANT,
user_id=USER_ID,
protocol_id=welcome_protocol.protocol_id,
trigger_type=OnboardingTriggerType.REGISTRATION,
steps=(
OnboardingStep(
step_key="profile",
title="Complete profile",
subsystem="user-account",
),
),
source_id=registration.registration_id,
source_event_type="registration.completed",
correlation_id="corr-store",
)
profile_value = ProfileValue(
user_id=USER_ID,
attribute_key="store.secret",
value=PROFILE_SECRET_VALUE,
scope=ProfileScope.GLOBAL,
)
audit_record = AuditRecord(
audit_id="aud_store_conformance",
actor=actor,
action="store.write",
subject=USER_ID,
tenant=TENANT,
correlation_id="corr-store",
summary="store conformance",
)
outbox_event = OutboxEvent(
event_id="evt_store_conformance",
event_type="store.changed",
aggregate_id=USER_ID,
payload={"kind": "store-conformance"},
tenant=TENANT,
correlation_id="corr-store",
)
store.save_user(user)
store.save_account(account)
store.save_identity(identity)
store.save_tenant_account(tenant_account)
store.save_membership(membership)
store.save_application(application)
store.save_binding(binding)
store.save_catalog(catalog)
store.save_family_invitation(invitation)
store.save_registration_session(registration)
store.save_identity_factor(factor)
store.save_prepared_account(prepared_account)
store.save_access_profile(access_profile)
store.save_active_access_context(access_context)
store.save_welcome_protocol(welcome_protocol)
store.save_onboarding_journey(onboarding_journey)
store.save_profile_value(profile_value)
store.append_audit(audit_record)
store.append_outbox(outbox_event)
return {
"user": user,
"account": account,
"identity": identity,
"tenant_account": tenant_account,
"membership": membership,
"application": application,
"binding": binding,
"catalog": catalog,
"invitation": invitation,
"registration": registration,
"factor": factor,
"prepared_account": prepared_account,
"access_profile": access_profile,
"access_context": access_context,
"welcome_protocol": welcome_protocol,
"onboarding_journey": onboarding_journey,
"profile_value": profile_value,
"audit_record": audit_record,
"outbox_event": outbox_event,
}
def _actor() -> Actor:
return Actor(
issuer="https://issuer.example.test",
subject="store-conformance",
tenant=TENANT,
principal_type=PrincipalType.HUMAN,
audience=("user-engine",),
roles=("user",),
scopes=("profile",),
)

803
src/user_engine/ui.py Normal file
View File

@@ -0,0 +1,803 @@
"""UI-facing contracts for registration and access management surfaces.
The module is deliberately transport-neutral. Web, desktop, or CLI adapters can
serve these view models without making browser state authoritative over
user-engine records.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from html import escape
from typing import Any, Mapping
from user_engine.domain import (
AccessProfile,
Actor,
FactorVerification,
IdentityFactorType,
OnboardingJourneyStatus,
PreparedAccount,
PreparedAccountStatus,
ProjectionType,
RegistrationSession,
)
from user_engine.errors import NotFoundError, ValidationError
from user_engine.service import (
PreparedAccountClaim,
RegistrationCompletion,
UserEngineService,
)
class UiViewport(StrEnum):
MOBILE = "mobile"
DESKTOP = "desktop"
class UiTone(StrEnum):
DEFAULT = "default"
SUCCESS = "success"
WARNING = "warning"
DANGER = "danger"
INFO = "info"
@dataclass(frozen=True)
class UiAction:
action_id: str
label: str
route_id: str
method: str = "POST"
icon: str | None = None
disabled: bool = False
description: str | None = None
@dataclass(frozen=True)
class UiField:
key: str
label: str
value: Any
kind: str = "text"
tone: UiTone = UiTone.DEFAULT
redacted: bool = False
required: bool = False
@dataclass(frozen=True)
class UiSection:
section_id: str
title: str
fields: tuple[UiField, ...] = ()
actions: tuple[UiAction, ...] = ()
summary: str | None = None
tone: UiTone = UiTone.DEFAULT
@dataclass(frozen=True)
class UiScreen:
screen_id: str
route_id: str
title: str
viewport: UiViewport
sections: tuple[UiSection, ...]
actions: tuple[UiAction, ...] = ()
alerts: tuple[str, ...] = ()
landmarks: tuple[str, ...] = ("banner", "navigation", "main")
layout: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class UiRoute:
route_id: str
path: str
method: str
operation: str
persona: str
redaction: tuple[str, ...] = ()
@dataclass(frozen=True)
class UiInformationArchitecture:
primary_navigation: tuple[str, ...]
views: tuple[str, ...]
personas: tuple[str, ...]
breakpoints: Mapping[str, Mapping[str, Any]]
@dataclass(frozen=True)
class UiApiContract:
routes: tuple[UiRoute, ...]
adapter_boundaries: tuple[str, ...]
@dataclass(frozen=True)
class UiRegistrationFlow:
screen: UiScreen
session: RegistrationSession | None = None
completion: RegistrationCompletion | None = None
claim: PreparedAccountClaim | None = None
class RegistrationAccessManagementUi:
"""Thin UI facade over user-engine service operations."""
def __init__(self, service: UserEngineService) -> None:
self.service = service
def information_architecture(self) -> UiInformationArchitecture:
return UiInformationArchitecture(
primary_navigation=(
"registration",
"prepared-rights",
"active-hat",
"profile",
"onboarding",
"admin",
),
views=(
"registration.start",
"registration.factor_status",
"registration.complete",
"prepared_account.review",
"access_profile.select_hat",
"profile.completion",
"onboarding.status",
"admin.prepared_accounts",
"admin.access_profiles",
"admin.onboarding_diagnostics",
),
personas=("self-service", "tenant-admin", "family-owner", "operator"),
breakpoints={
UiViewport.MOBILE.value: {
"min_width": 320,
"columns": 1,
"navigation": "bottom-tabs",
},
UiViewport.DESKTOP.value: {
"min_width": 960,
"columns": 2,
"navigation": "sidebar",
},
},
)
def api_contract(self) -> UiApiContract:
return UiApiContract(
routes=(
UiRoute(
"registration.start",
"/ui/registration",
"POST",
"start_registration",
"self-service",
),
UiRoute(
"registration.factor",
"/ui/registration/{registration_id}/factors",
"POST",
"attach_registration_factor",
"self-service",
redaction=("normalized_value", "display_value"),
),
UiRoute(
"registration.complete",
"/ui/registration/{registration_id}/complete",
"POST",
"complete_registration",
"self-service",
),
UiRoute(
"prepared_account.review",
"/ui/registration/{registration_id}/prepared-rights",
"GET",
"list_prepared_accounts",
"self-service",
redaction=("normalized_factor_values",),
),
UiRoute(
"prepared_account.accept",
"/ui/registration/{registration_id}/prepared-rights/{id}",
"POST",
"claim_prepared_account",
"self-service",
),
UiRoute(
"prepared_account.deny",
"/ui/registration/{registration_id}/prepared-rights/{id}/deny",
"POST",
"prepared_claim_dismiss",
"self-service",
),
UiRoute(
"access_profile.select_hat",
"/ui/users/{user_id}/active-hat",
"POST",
"select_active_hat",
"self-service",
),
UiRoute(
"admin.dashboard",
"/ui/admin/{tenant}",
"GET",
"admin_dashboard",
"tenant-admin",
redaction=("factor_values", "profile_defaults", "claim_values"),
),
),
adapter_boundaries=(
"identity proofing",
"credential lifecycle",
"authorization decisions",
"notifications",
"service runtime consoles",
),
)
def start_registration(
self,
actor: Actor,
*,
required_factor_types: tuple[IdentityFactorType, ...] = (),
viewport: UiViewport = UiViewport.DESKTOP,
correlation_id: str | None = None,
) -> UiRegistrationFlow:
session = self.service.start_registration(
actor,
required_factor_types=required_factor_types,
correlation_id=correlation_id,
)
return UiRegistrationFlow(
screen=self.registration_screen(actor, session.registration_id, viewport),
session=session,
)
def attach_factor(
self,
actor: Actor,
registration_id: str,
verification: FactorVerification | Mapping[str, Any],
*,
viewport: UiViewport = UiViewport.DESKTOP,
correlation_id: str | None = None,
) -> UiRegistrationFlow:
session = self.service.attach_registration_factor(
actor,
registration_id,
verification,
correlation_id=correlation_id,
)
return UiRegistrationFlow(
screen=self.registration_screen(actor, session.registration_id, viewport),
session=session,
)
def complete_registration(
self,
actor: Actor,
registration_id: str,
*,
terms_accepted: bool,
viewport: UiViewport = UiViewport.DESKTOP,
correlation_id: str | None = None,
) -> UiRegistrationFlow:
if not terms_accepted:
return UiRegistrationFlow(
screen=self.registration_screen(
actor,
registration_id,
viewport,
alerts=("Terms and consent must be accepted to continue.",),
)
)
completion = self.service.complete_registration(
actor,
registration_id,
correlation_id=correlation_id,
)
return UiRegistrationFlow(
screen=self._registration_complete_screen(completion, viewport),
completion=completion,
)
def registration_screen(
self,
actor: Actor,
registration_id: str,
viewport: UiViewport = UiViewport.DESKTOP,
*,
alerts: tuple[str, ...] = (),
) -> UiScreen:
session = self._registration_session(registration_id)
factors = self.service.store.factors_for_registration(registration_id)
factor_types = tuple(sorted({factor.factor_type.value for factor in factors}))
pending_required = tuple(
factor_type.value
for factor_type in session.required_factor_types
if factor_type.value not in factor_types
)
return UiScreen(
screen_id="registration",
route_id="registration.start",
title="Registration",
viewport=viewport,
alerts=alerts,
layout=_layout(viewport),
sections=(
UiSection(
"session",
"Session",
fields=(
UiField("registration_id", "Registration ID", registration_id),
UiField("status", "Status", session.status.value),
UiField(
"netkingdom_id",
"NetKingdom ID",
session.netkingdom_id or "pending",
),
),
),
UiSection(
"factor_status",
"Factor Status",
fields=(
UiField("verified_factors", "Verified Factors", factor_types),
UiField("pending_factors", "Pending Factors", pending_required),
),
actions=(
UiAction(
"attach_factor",
"Attach Factor Evidence",
"registration.factor",
icon="shield-check",
),
),
),
UiSection(
"terms",
"Terms And Consent",
fields=(
UiField(
"terms_accepted",
"Terms Accepted",
False,
kind="checkbox",
required=True,
),
),
actions=(
UiAction(
"complete_registration",
"Complete Registration",
"registration.complete",
icon="check",
),
),
),
),
)
def prepared_rights_review(
self,
actor: Actor,
registration_id: str,
*,
viewport: UiViewport = UiViewport.DESKTOP,
) -> UiScreen:
session = self._registration_session(registration_id)
prepared_accounts = self.service.list_prepared_accounts(
actor,
tenant=session.tenant,
correlation_id="ui-prepared-review",
)
sections = tuple(
self._prepared_account_section(prepared)
for prepared in prepared_accounts
if prepared.status == PreparedAccountStatus.PENDING
)
if not sections:
sections = (
UiSection(
"prepared-empty",
"Prepared Rights",
fields=(UiField("status", "Status", "none available"),),
),
)
return UiScreen(
screen_id="prepared-rights",
route_id="prepared_account.review",
title="Prepared Rights",
viewport=viewport,
layout=_layout(viewport),
sections=sections,
)
def accept_prepared_claim(
self,
actor: Actor,
registration_id: str,
prepared_account_id: str,
*,
viewport: UiViewport = UiViewport.DESKTOP,
correlation_id: str | None = None,
) -> UiRegistrationFlow:
claim = self.service.claim_prepared_account(
actor,
registration_id,
prepared_account_id=prepared_account_id,
correlation_id=correlation_id,
)
return UiRegistrationFlow(
screen=UiScreen(
screen_id="prepared-claim-accepted",
route_id="prepared_account.accept",
title="Prepared Rights Accepted",
viewport=viewport,
layout=_layout(viewport),
sections=(
UiSection(
"claim",
"Claim",
fields=(
UiField(
"prepared_account_id",
"Prepared Account",
claim.prepared_account.prepared_account_id,
),
UiField("status", "Status", claim.prepared_account.status.value),
UiField(
"entitlement_count",
"Activated Entitlements",
len(claim.prepared_account.entitlements),
),
),
tone=UiTone.SUCCESS,
),
),
),
claim=claim,
)
def deny_prepared_claim(
self,
prepared_account_id: str,
*,
viewport: UiViewport = UiViewport.DESKTOP,
) -> UiScreen:
return UiScreen(
screen_id="prepared-claim-denied",
route_id="prepared_account.deny",
title="Prepared Rights Dismissed",
viewport=viewport,
layout=_layout(viewport),
alerts=("The prepared-rights package was dismissed in the UI.",),
sections=(
UiSection(
"decision",
"Decision",
fields=(
UiField("prepared_account_id", "Prepared Account", prepared_account_id),
UiField("decision", "Decision", "denied_by_user"),
),
tone=UiTone.WARNING,
),
),
)
def hat_selection_view(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
viewport: UiViewport = UiViewport.DESKTOP,
) -> UiScreen:
profiles = self.service.list_access_profiles(
actor,
tenant=tenant,
correlation_id="ui-hat-list",
)
context = self.service.identity_context(
actor,
user_id=user_id,
tenant=tenant,
correlation_id="ui-hat-context",
)
active = context.active_access_context
profile_sections = tuple(self._access_profile_section(profile) for profile in profiles)
return UiScreen(
screen_id="active-hat",
route_id="access_profile.select_hat",
title="Active Hat",
viewport=viewport,
layout=_layout(viewport),
sections=(
UiSection(
"current",
"Current Context",
fields=(
UiField("active_hat", "Active Hat", active.hat if active else "none"),
UiField(
"scope",
"Scope",
f"{active.scope_type.value}:{active.scope_id}" if active else "none",
),
),
),
*profile_sections,
),
)
def select_hat(
self,
actor: Actor,
user_id: str,
access_profile_id: str,
*,
viewport: UiViewport = UiViewport.DESKTOP,
correlation_id: str | None = None,
) -> UiScreen:
selection = self.service.select_active_hat(
actor,
user_id,
access_profile_id,
correlation_id=correlation_id,
)
return self.hat_selection_view(
actor,
user_id,
tenant=selection.active_context.tenant,
viewport=viewport,
)
def admin_dashboard(
self,
actor: Actor,
*,
tenant: str,
viewport: UiViewport = UiViewport.DESKTOP,
) -> UiScreen:
registration = self.service.registration_diagnostics(
actor,
tenant=tenant,
correlation_id="ui-admin-registration",
)
tenant_diag = self.service.tenant_diagnostics(
actor,
tenant=tenant,
correlation_id="ui-admin-tenant",
)
prepared_accounts = self.service.list_prepared_accounts(
actor,
tenant=tenant,
correlation_id="ui-admin-prepared",
)
access_profiles = self.service.list_access_profiles(
actor,
tenant=tenant,
correlation_id="ui-admin-access",
)
onboarding = self.service.onboarding_diagnostics(
actor,
tenant=tenant,
correlation_id="ui-admin-onboarding",
)
return UiScreen(
screen_id="admin-dashboard",
route_id="admin.dashboard",
title="Admin",
viewport=viewport,
layout=_layout(viewport),
sections=(
UiSection(
"registration",
"Registration",
fields=(
UiField("total_sessions", "Sessions", registration.total_sessions),
UiField(
"pending_sessions",
"Pending",
registration.pending_session_count,
),
),
),
UiSection(
"prepared_accounts",
"Prepared Accounts",
fields=(
UiField("prepared_count", "Prepared Accounts", len(prepared_accounts)),
UiField(
"pending_count",
"Pending",
len(
[
item
for item in prepared_accounts
if item.status == PreparedAccountStatus.PENDING
]
),
),
),
),
UiSection(
"access",
"Access",
fields=(
UiField("membership_count", "Memberships", len(tenant_diag.memberships)),
UiField("profile_count", "Access Profiles", len(access_profiles)),
),
),
UiSection(
"onboarding",
"Onboarding",
fields=(
UiField("journey_count", "Journeys", onboarding.journey_count),
UiField("blocked_steps", "Blocked Steps", onboarding.blocked_steps),
UiField("lifecycle_gaps", "Lifecycle Gaps", onboarding.lifecycle_gaps),
),
),
),
)
def render_html(self, screen: UiScreen) -> str:
nav_items = "".join(
f"<li><a href='#{escape(section.section_id)}'>{escape(section.title)}</a></li>"
for section in screen.sections
)
sections = "\n".join(_render_section(section) for section in screen.sections)
alerts = "".join(
f"<p role='alert' class='alert'>{escape(alert)}</p>" for alert in screen.alerts
)
return (
"<!doctype html><html lang='en'><head>"
"<meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
f"<title>{escape(screen.title)}</title>"
"</head>"
f"<body data-viewport='{escape(screen.viewport.value)}'>"
f"<header role='banner'><h1>{escape(screen.title)}</h1></header>"
f"<nav role='navigation' aria-label='Sections'><ul>{nav_items}</ul></nav>"
f"<main role='main' class='layout-{escape(str(screen.layout.get('columns', 1)))}'>"
f"{alerts}{sections}</main></body></html>"
)
def _registration_complete_screen(
self, completion: RegistrationCompletion, viewport: UiViewport
) -> UiScreen:
return UiScreen(
screen_id="registration-complete",
route_id="registration.complete",
title="Registration Complete",
viewport=viewport,
layout=_layout(viewport),
sections=(
UiSection(
"identity",
"Identity",
fields=(
UiField("netkingdom_id", "NetKingdom ID", completion.netkingdom_id),
UiField("account_status", "Account", completion.account.status.value),
),
tone=UiTone.SUCCESS,
),
),
)
def _prepared_account_section(self, prepared: PreparedAccount) -> UiSection:
factor_types = tuple(
sorted({item.factor_type.value for item in prepared.required_factor_matches})
)
entitlement_kinds = tuple(
sorted({item.kind.value for item in prepared.entitlements})
)
return UiSection(
f"prepared-{prepared.prepared_account_id}",
prepared.display_name or "Prepared Account",
fields=(
UiField("prepared_account_id", "Prepared Account", prepared.prepared_account_id),
UiField("factor_types", "Required Factors", factor_types, redacted=True),
UiField("entitlements", "Entitlements", entitlement_kinds),
UiField("status", "Status", prepared.status.value),
),
actions=(
UiAction("accept", "Accept", "prepared_account.accept", icon="check"),
UiAction("deny", "Deny", "prepared_account.deny", icon="x"),
),
)
def _access_profile_section(self, profile: AccessProfile) -> UiSection:
return UiSection(
f"profile-{profile.access_profile_id}",
profile.display_name,
fields=(
UiField("access_profile_id", "Access Profile", profile.access_profile_id),
UiField("hat", "Hat", profile.hat),
UiField("scope_type", "Scope Type", profile.scope_type.value),
UiField("scope_id", "Scope", profile.scope_id or profile.tenant),
UiField(
"factor_types",
"Required Factors",
tuple(item.value for item in profile.required_factor_types),
redacted=True,
),
),
actions=(
UiAction(
"select_hat",
"Select Hat",
"access_profile.select_hat",
icon="hat",
disabled=profile.requires_approval,
),
),
)
def _registration_session(self, registration_id: str) -> RegistrationSession:
session = self.service.store.registration_session(registration_id)
if session is None:
raise NotFoundError("registration session not found")
return session
def _layout(viewport: UiViewport) -> Mapping[str, Any]:
if viewport == UiViewport.MOBILE:
return {
"columns": 1,
"min_touch_target": 44,
"navigation": "bottom-tabs",
"density": "comfortable",
}
return {
"columns": 2,
"min_touch_target": 32,
"navigation": "sidebar",
"density": "workbench",
}
def _render_section(section: UiSection) -> str:
fields = "".join(_render_field(field) for field in section.fields)
actions = "".join(_render_action(action) for action in section.actions)
summary = f"<p>{escape(section.summary)}</p>" if section.summary else ""
return (
f"<section id='{escape(section.section_id)}' aria-labelledby='"
f"{escape(section.section_id)}-title' data-tone='{escape(section.tone.value)}'>"
f"<h2 id='{escape(section.section_id)}-title'>{escape(section.title)}</h2>"
f"{summary}<dl>{fields}</dl><div class='actions'>{actions}</div></section>"
)
def _render_field(field: UiField) -> str:
value = REDACTED if field.redacted and field.value not in ((), "", None) else field.value
return (
f"<dt>{escape(field.label)}</dt>"
f"<dd data-key='{escape(field.key)}' data-kind='{escape(field.kind)}' "
f"data-tone='{escape(field.tone.value)}'>{escape(_field_value(value))}</dd>"
)
def _render_action(action: UiAction) -> str:
disabled = " disabled aria-disabled='true'" if action.disabled else ""
icon = f"<span aria-hidden='true'>{escape(action.icon)}</span> " if action.icon else ""
description = escape(action.description or action.label)
return (
f"<button type='button' data-route='{escape(action.route_id)}' "
f"aria-label='{description}'{disabled}>{icon}{escape(action.label)}</button>"
)
def _field_value(value: Any) -> str:
if isinstance(value, StrEnum):
return value.value
if isinstance(value, (tuple, list, set)):
return ", ".join(_field_value(item) for item in value) or "none"
if isinstance(value, Mapping):
return ", ".join(f"{key}: {_field_value(item)}" for key, item in value.items())
if value is None:
return "none"
return str(value)
REDACTED = "<redacted>"

View File

@@ -0,0 +1,354 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
IdentityFactor,
IdentityFactorType,
ProjectionType,
)
from user_engine.errors import AuthorizationDenied, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
sample_catalog,
)
class AccessProfileTests(unittest.TestCase):
def test_select_active_hat_updates_identity_context_and_projection(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
store.save_identity_factor(_email_factor(session.user.user_id))
profile = service.register_access_profile(
session.actor,
_realm_operator_profile(),
correlation_id="corr-profile-register",
)
selection = service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-hat",
)
context = service.identity_context(
session.actor,
application_id="app.demo",
include_profile=True,
correlation_id="corr-identity-context",
)
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.demo",
tenant="tenant:coulomb",
correlation_id="corr-projection",
)
self.assertEqual(selection.active_context.hat, "operator")
self.assertEqual(selection.active_context.realm_id, "realm:citadel")
self.assertEqual(selection.active_context.service_id, "app.demo")
self.assertEqual(selection.active_context.asset_id, "asset:ledger")
self.assertEqual(selection.active_context.factor_ids[0].startswith("fac_"), True)
self.assertEqual(context.active_access_context.hat, "operator")
self.assertEqual(context.entity_refs["active_hat"].concept, "Hat")
self.assertIn(
"wears_hat",
{relationship.relationship_type for relationship in context.relationship_refs},
)
self.assertTrue(
any(fact.scope_id == "realm:citadel" for fact in context.access_control_facts)
)
self.assertEqual(projection.access_context["active_hat"], "operator")
self.assertEqual(projection.access_context["claims"]["service_role"], "operator")
self.assertNotIn(
"ada@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_cross_tenant_access_profile_denied(self):
service, _, _ = _service()
session = _bootstrap(service)
with self.assertRaises(AuthorizationDenied):
service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:faraday",
display_name="Faraday Operator",
hat="operator",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:faraday",
kind="operator",
),
),
),
correlation_id="corr-cross-tenant",
)
def test_missing_factor_assurance_fails_closed(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="High Assurance Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EID,),
),
correlation_id="corr-high-assurance",
)
with self.assertRaises(ValidationError):
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-missing-factor",
)
self.assertIsNone(
store.active_access_context(session.user.user_id, "tenant:coulomb")
)
def test_group_derived_access_exports_group_facts(self):
service, _, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="group",
scope_id="group:research",
kind="member",
correlation_id="corr-group-membership",
)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Research Service Hat",
hat="researcher",
scope_type=AccessScopeType.SERVICE,
scope_id="app.demo",
service_id="app.demo",
membership_requirements=(
AccessMembershipRequirement(
scope_type="group",
scope_id="group:research",
kind="member",
),
),
group_scope_ids=("group:research",),
),
correlation_id="corr-research-profile",
)
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-researcher",
)
export = service.export_access_control_facts(
session.actor,
tenant="tenant:coulomb",
user_id=session.user.user_id,
correlation_id="corr-export-facts",
)
self.assertIn("group", export.manifest["subject_types"])
self.assertTrue(
any(
fact.subject_type == "group"
and fact.subject_id == "group:research"
and fact.scope_id == "app.demo"
for fact in export.facts
)
)
def test_service_specific_projection_filters_other_services(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
store.save_identity_factor(_email_factor(session.user.user_id))
profile = service.register_access_profile(
session.actor,
_realm_operator_profile(),
correlation_id="corr-profile-register",
)
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-hat",
)
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.other",
tenant="tenant:coulomb",
correlation_id="corr-other-service",
)
self.assertEqual(projection.access_context, {})
def test_access_profile_diagnostics_are_redacted(self):
service, _, _ = _service()
session = _bootstrap(service)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Sensitive Defaults",
hat="operator",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
profile_defaults={"internal_hint": "secret-default"},
claims={"private_claim": "secret-claim"},
),
correlation_id="corr-sensitive-profile",
)
diagnostics = service.access_profile_diagnostics(
session.actor,
tenant="tenant:coulomb",
correlation_id="corr-access-diagnostics",
)
self.assertEqual(diagnostics.profile_count, 1)
self.assertIn(profile.access_profile_id, diagnostics.required_factor_types)
self.assertNotIn("secret-default", repr(diagnostics))
self.assertNotIn("secret-claim", repr(diagnostics))
self.assertNotIn(
"secret-default",
repr([event.payload for event in service.outbox_events()]),
)
self.assertNotIn(
"secret-claim",
repr([event.payload for event in service.outbox_events()]),
)
def _service():
store = InMemoryUserEngineStore()
authz = LocalAuthorizationCheckPort()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=authz,
)
return service, store, authz
def _bootstrap(service: UserEngineService):
session = service.me(_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",
)
return session
def _claims():
claims = human_actor_claims(subject="ada", tenant="tenant:coulomb")
claims["roles"] = ["tenant-admin"]
claims["email"] = "ada@example.test"
return claims
def _email_factor(user_id: str) -> IdentityFactor:
return IdentityFactor(
factor_type=IdentityFactorType.EMAIL,
normalized_value="ada@example.test",
user_id=user_id,
display_value="ada@example.test",
source_system="fixture-email",
)
def _realm_operator_profile() -> AccessProfile:
return AccessProfile(
tenant="tenant:coulomb",
display_name="Realm Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
service_id="app.demo",
asset_id="asset:ledger",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
profile_defaults={"workspace_mode": "ops"},
claims={"service_role": "operator"},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, SCHEMA_VERSION
from user_engine.migrations import (
LATEST_SCHEMA_VERSION,
USER_ENGINE_RECORD_COUNT_KEYS,
USER_ENGINE_STORE_RECORD_TYPES,
migration_manifest,
validate_migration_manifest,
)
from user_engine.testing.store_conformance import (
assert_user_engine_migration_contract,
assert_user_engine_store_conformance,
)
class DurableStoreConformanceTests(unittest.TestCase):
def test_migration_manifest_is_ordered_and_provider_safe(self):
assert_user_engine_migration_contract(self)
manifest = migration_manifest()
self.assertEqual(validate_migration_manifest(), ())
self.assertEqual(manifest[-1].version, LATEST_SCHEMA_VERSION)
self.assertIn("users", USER_ENGINE_STORE_RECORD_TYPES)
self.assertIn("pending_outbox_events", USER_ENGINE_RECORD_COUNT_KEYS)
def test_local_schema_version_uses_latest_manifest_version(self):
store = InMemoryUserEngineStore()
store.migrate()
self.assertEqual(SCHEMA_VERSION, LATEST_SCHEMA_VERSION)
self.assertEqual(store.schema_version, LATEST_SCHEMA_VERSION)
self.assertTrue(store.ready)
def test_in_memory_store_satisfies_durable_store_contract(self):
assert_user_engine_store_conformance(self, InMemoryUserEngineStore)
if __name__ == "__main__":
unittest.main()

View File

@@ -18,6 +18,7 @@ from user_engine.projections import ClaimsEnrichmentProjectionCache
from user_engine.service import REDACTED, UserEngineService
from user_engine.testing.fixtures import sample_application, sample_application_binding
from user_engine.testing.scenarios import (
REGISTRATION_SCENARIO_MATRIX,
SCENARIO_MATRIX,
ScenarioAuthorizationHarness,
StrictFixtureIdentityClaimsAdapter,
@@ -44,7 +45,29 @@ class IntegratedScenarioTests(unittest.TestCase):
"two_applications",
"sensitive_redaction",
"audit_event_replay",
"identity_canon_context",
"family_dataspace_onboarding",
"registration_onboarding_full",
"prepared_account_claim",
"privileged_role_requires_approval",
"eid_assurance_registration",
"tenant_admin_invite",
"group_access_hat",
"denied_cross_tenant_claim",
"ui_registration_access_flow",
},
)
self.assertEqual(
{scenario["id"] for scenario in REGISTRATION_SCENARIO_MATRIX},
{
"self_registration",
"prepared_account_claim",
"privileged_role_requires_approval",
"eid_assurance_registration",
"family_invite",
"tenant_admin_invite",
"group_access",
"denied_cross_tenant_claim",
},
)

View File

@@ -0,0 +1,361 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
FactorVerification,
IdentityFactorType,
OnboardingJourneyStatus,
OnboardingStepStatus,
OnboardingTriggerType,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
WelcomeProtocol,
WelcomeProtocolStep,
)
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
class OnboardingJourneyTests(unittest.TestCase):
def test_registration_completion_starts_matching_welcome_protocol(self):
service, store = _service()
actor = _actor()
protocol = service.register_welcome_protocol(
actor,
_registration_protocol(),
correlation_id="corr-register-protocol",
)
completion = _complete_registration(service, actor)
journeys = store.onboarding_journeys_for_user(completion.user.user_id)
context = service.identity_context(
actor,
user_id=completion.user.user_id,
tenant="tenant:coulomb",
correlation_id="corr-context",
)
self.assertEqual(len(journeys), 1)
self.assertEqual(journeys[0].protocol_id, protocol.protocol_id)
self.assertEqual(journeys[0].trigger_type, OnboardingTriggerType.REGISTRATION)
self.assertEqual(journeys[0].status, OnboardingJourneyStatus.IN_PROGRESS)
self.assertEqual(journeys[0].steps[0].status, OnboardingStepStatus.IN_PROGRESS)
self.assertEqual(context.onboarding_journeys[0].journey_id, journeys[0].journey_id)
self.assertIn(
"onboarding_journey.started",
[event.event_type for event in service.outbox_events()],
)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_prepared_account_claim_starts_prepared_welcome_protocol(self):
service, store = _service()
actor = _actor()
service.register_welcome_protocol(
actor,
_prepared_protocol(),
correlation_id="corr-prepared-protocol",
)
prepared = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
correlation_id="corr-prepare",
)
completion = _complete_registration(service, actor)
service.claim_prepared_account(
actor,
completion.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim",
)
journeys = store.onboarding_journeys_for_user(completion.user.user_id)
self.assertEqual(len(journeys), 1)
self.assertEqual(journeys[0].trigger_type, OnboardingTriggerType.PREPARED_ACCOUNT)
self.assertEqual(journeys[0].source_id, prepared.prepared_account_id)
self.assertEqual(journeys[0].journey_key, "welcome-demo")
self.assertIn(
"prepared_account.onboarding_requested",
[event.event_type for event in service.outbox_events()],
)
self.assertIn(
"onboarding_journey.started",
[event.event_type for event in service.outbox_events()],
)
def test_missing_subsystem_callback_blocks_journey_with_gap(self):
service, store = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Blocked Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="external-setup",
title="External Setup",
subsystem="ops-console",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-blocked-protocol",
)
start = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start-blocked",
)
diagnostics = service.onboarding_diagnostics(
session.actor,
tenant="tenant:coulomb",
correlation_id="corr-diagnostics",
)
self.assertEqual(start.journey.status, OnboardingJourneyStatus.BLOCKED)
self.assertEqual(start.journey.steps[0].status, OnboardingStepStatus.BLOCKED)
self.assertIn("subsystem-callback-missing", start.journey.steps[0].lifecycle_gap)
self.assertEqual(store.record_counts()["onboarding_journeys"], 1)
self.assertEqual(diagnostics.statuses[OnboardingJourneyStatus.BLOCKED.value], 1)
self.assertTrue(diagnostics.lifecycle_gaps)
def test_blocked_journey_can_resume_and_complete(self):
service, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Resume Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="callback",
title="Callback",
subsystem="crm",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-resume-protocol",
)
blocked = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start",
).journey
resumed = service.resume_onboarding_journey(
session.actor,
blocked.journey_id,
callback_refs={"callback": "crm://welcome/callback"},
correlation_id="corr-resume",
)
completed = service.complete_onboarding_step(
session.actor,
resumed.journey_id,
"callback",
correlation_id="corr-complete-step",
)
self.assertEqual(resumed.status, OnboardingJourneyStatus.IN_PROGRESS)
self.assertEqual(
resumed.steps[0].handoff.callback_ref,
"crm://welcome/callback",
)
self.assertIsNone(resumed.steps[0].lifecycle_gap)
self.assertEqual(completed.status, OnboardingJourneyStatus.COMPLETED)
self.assertEqual(completed.completed_at is not None, True)
def test_progress_skip_and_fail_operations_are_auditable(self):
service, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Two Step Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="intro",
title="Intro",
subsystem="portal",
),
WelcomeProtocolStep(
step_key="tour",
title="Tour",
subsystem="portal",
),
),
),
correlation_id="corr-two-step-protocol",
)
journey = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start-two-step",
).journey
progressed = service.progress_onboarding_step(
session.actor,
journey.journey_id,
"intro",
correlation_id="corr-progress",
)
second_active = service.complete_onboarding_step(
session.actor,
progressed.journey_id,
"intro",
correlation_id="corr-complete-intro",
)
skipped = service.skip_onboarding_step(
session.actor,
second_active.journey_id,
"tour",
correlation_id="corr-skip-tour",
)
self.assertEqual(skipped.status, OnboardingJourneyStatus.COMPLETED)
self.assertIn(
"onboarding_step.skipped",
[event.event_type for event in service.outbox_events()],
)
failed_protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Failing Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="danger",
title="Danger",
subsystem="portal",
),
),
),
correlation_id="corr-fail-protocol",
)
failed_start = service.start_onboarding_journey(
session.actor,
session.user.user_id,
failed_protocol.protocol_id,
correlation_id="corr-start-fail",
).journey
failed = service.fail_onboarding_step(
session.actor,
failed_start.journey_id,
"danger",
lifecycle_gap="portal-unavailable",
correlation_id="corr-fail-step",
)
self.assertEqual(failed.status, OnboardingJourneyStatus.FAILED)
self.assertEqual(failed.steps[0].lifecycle_gap, "portal-unavailable")
self.assertIn(
"onboarding_step.failed",
[event.event_type for event in service.outbox_events()],
)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return service, store
def _actor():
return FixtureIdentityClaimsAdapter().normalize(
human_actor_claims(subject="sample-user", tenant="tenant:coulomb")
)
def _complete_registration(service: UserEngineService, actor):
session = service.start_registration(actor, correlation_id="corr-start")
service.attach_registration_factor(
actor,
session.registration_id,
FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
),
correlation_id="corr-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
def _registration_protocol() -> WelcomeProtocol:
return WelcomeProtocol(
tenant="tenant:coulomb",
name="Registration Welcome",
trigger_type=OnboardingTriggerType.REGISTRATION,
required_factor_types=(IdentityFactorType.EMAIL,),
steps=(
WelcomeProtocolStep(
step_key="intro",
title="Intro",
subsystem="portal",
callback_ref="portal://welcome/intro",
requires_subsystem_callback=True,
),
),
)
def _prepared_protocol() -> WelcomeProtocol:
return WelcomeProtocol(
tenant="tenant:coulomb",
name="Prepared Welcome",
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
journey_key="welcome-demo",
prepared_journey="welcome-demo",
steps=(
WelcomeProtocolStep(
step_key="prepared-intro",
title="Prepared Intro",
subsystem="portal",
callback_ref="portal://welcome/prepared",
requires_subsystem_callback=True,
),
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -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,134 @@ 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",
"access_profiles",
"active_access_contexts",
"applications",
"audit_records",
"bindings",
"catalogs",
"family_invitations",
"identities",
"identity_factors",
"memberships",
"onboarding_journeys",
"outbox_events",
"prepared_accounts",
"profile_values",
"registration_sessions",
"tenant_accounts",
"users",
"welcome_protocols",
}
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()

View File

@@ -0,0 +1,107 @@
import unittest
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.domain import User
from user_engine.testing.postgres_provider import (
POSTGRES_TEST_DSN_ENV,
POSTGRES_TEST_RESET_ENV,
close_postgres_provider_connection,
connect_postgres_provider,
postgres_provider_test_config,
reset_user_engine_postgres_tables,
)
from user_engine.testing.store_conformance import (
assert_user_engine_store_conformance,
)
class ProviderPostgresConfigTests(unittest.TestCase):
def test_config_skips_without_dsn(self):
config, reason = postgres_provider_test_config({})
self.assertIsNone(config)
self.assertIn(POSTGRES_TEST_DSN_ENV, reason or "")
def test_config_requires_reset_acknowledgement(self):
config, reason = postgres_provider_test_config(
{POSTGRES_TEST_DSN_ENV: "postgresql://example.test/db"}
)
self.assertIsNone(config)
self.assertIn(POSTGRES_TEST_RESET_ENV, reason or "")
def test_config_accepts_dsn_and_reset_acknowledgement(self):
config, reason = postgres_provider_test_config(
{
POSTGRES_TEST_DSN_ENV: "postgresql://example.test/db",
POSTGRES_TEST_RESET_ENV: "1",
}
)
self.assertIsNotNone(config)
self.assertIsNone(reason)
class ProviderPostgresConformanceTests(unittest.TestCase):
def setUp(self):
self.config, reason = postgres_provider_test_config()
if reason:
self.skipTest(reason)
self.connections = []
def tearDown(self):
for connection in self.connections:
close_postgres_provider_connection(connection)
if self.config is not None:
cleanup = connect_postgres_provider(self.config.dsn)
try:
reset_user_engine_postgres_tables(cleanup)
finally:
close_postgres_provider_connection(cleanup)
def test_live_postgres_store_satisfies_store_conformance(self):
assert_user_engine_store_conformance(self, self._store_factory)
def test_live_postgres_migration_readiness(self):
store = self._store_factory()
self.assertFalse(store.ready)
store.migrate()
self.assertTrue(store.ready)
self.assertEqual(store.schema_version, "0001_initial")
def test_live_postgres_upsert_keeps_one_logical_record(self):
store = self._store_factory()
store.migrate()
user = User(user_id="usr_live_upsert", display_name="Original")
replacement = User(user_id="usr_live_upsert", display_name="Replacement")
store.save_user(user)
store.save_user(replacement)
self.assertEqual(store.user(user.user_id), replacement)
cursor = store.connection.cursor()
try:
cursor.execute(
"""
SELECT COUNT(*)
FROM user_engine_records
WHERE record_type = %s AND record_key = %s
""",
("users", user.user_id),
)
self.assertEqual(cursor.fetchone()[0], 1)
finally:
cursor.close()
def _store_factory(self) -> PostgresUserEngineStore:
assert self.config is not None
connection = connect_postgres_provider(self.config.dsn)
reset_user_engine_postgres_tables(connection)
self.connections.append(connection)
return PostgresUserEngineStore(connection)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,180 @@
import copy
import json
import unittest
from typing import Any, Iterable
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.migrations import LATEST_SCHEMA_VERSION
from user_engine.store_records import StoreRecord
from user_engine.testing.store_conformance import (
assert_user_engine_store_conformance,
)
class PostgresStoreAdapterTests(unittest.TestCase):
def test_fake_postgres_store_satisfies_store_conformance(self):
assert_user_engine_store_conformance(
self,
lambda: PostgresUserEngineStore(_FakePostgresConnection()),
)
def test_ready_is_false_before_migration(self):
store = PostgresUserEngineStore(_FakePostgresConnection())
self.assertFalse(store.ready)
self.assertIsNone(store.schema_version)
class _FakePostgresConnection:
def __init__(self) -> None:
self.schema_versions: set[str] = set()
self.records: dict[tuple[str, str], StoreRecord] = {}
self.audit_payloads: list[dict[str, Any]] = []
self.outbox_payloads: list[dict[str, Any]] = []
self._snapshot: tuple[
set[str],
dict[tuple[str, str], StoreRecord],
list[dict[str, Any]],
list[dict[str, Any]],
] | None = None
def cursor(self) -> "_FakePostgresCursor":
return _FakePostgresCursor(self)
def begin(self) -> None:
self._snapshot = (
copy.deepcopy(self.schema_versions),
copy.deepcopy(self.records),
copy.deepcopy(self.audit_payloads),
copy.deepcopy(self.outbox_payloads),
)
def commit(self) -> None:
self._snapshot = None
def rollback(self) -> None:
if self._snapshot is None:
return
(
self.schema_versions,
self.records,
self.audit_payloads,
self.outbox_payloads,
) = self._snapshot
self._snapshot = None
class _FakePostgresCursor:
def __init__(self, connection: _FakePostgresConnection) -> None:
self.connection = connection
self._rows: list[Any] = []
def execute(self, sql: str, params: Iterable[Any] | None = None) -> None:
normalized = " ".join(sql.lower().split())
values = tuple(params or ())
if "insert into user_engine_schema_versions" in normalized:
self.connection.schema_versions.add(LATEST_SCHEMA_VERSION)
self._rows = []
return
if "from user_engine_schema_versions" in normalized:
self._rows = [(1,)] if values[0] in self.connection.schema_versions else []
return
if normalized.startswith("insert into user_engine_records"):
payload = json.loads(values[7])
record = StoreRecord(
record_type=values[0],
record_key=values[1],
tenant=values[2],
user_id=values[3],
application_id=values[4],
scope_type=values[5],
scope_id=values[6],
payload=payload,
)
self.connection.records[(record.record_type, record.record_key)] = record
self._rows = []
return
if "from user_engine_records" in normalized:
self._select_records(normalized, values)
return
if normalized.startswith("insert into user_engine_audit_records"):
self.connection.audit_payloads.append(json.loads(values[8]))
self._rows = []
return
if normalized.startswith("insert into user_engine_outbox_events"):
self.connection.outbox_payloads.append(json.loads(values[5]))
self._rows = []
return
if "from user_engine_audit_records" in normalized:
if "count(*)" in normalized:
self._rows = [(len(self.connection.audit_payloads),)]
else:
self._rows = [
(json.dumps(payload),) for payload in self.connection.audit_payloads
]
return
if "from user_engine_outbox_events" in normalized:
if "count(*)" in normalized:
self._rows = [(len(self.connection.outbox_payloads),)]
else:
self._rows = [
(json.dumps(payload),) for payload in self.connection.outbox_payloads
]
return
self._rows = []
def fetchone(self) -> Any | None:
return self._rows[0] if self._rows else None
def fetchall(self) -> list[Any]:
return self._rows
def close(self) -> None:
return None
def _select_records(self, normalized: str, values: tuple[Any, ...]) -> None:
if "group by record_type" in normalized:
counts: dict[str, int] = {}
for record_type, _record_key in self.connection.records:
counts[record_type] = counts.get(record_type, 0) + 1
self._rows = sorted(counts.items())
return
record_type = values[0]
filter_columns = [
column
for column in (
"record_key",
"tenant",
"user_id",
"application_id",
"scope_type",
"scope_id",
)
if f"{column} = %s" in normalized
]
filters = dict(zip(filter_columns, values[1:]))
rows = []
for (stored_type, _key), record in self.connection.records.items():
if stored_type != record_type:
continue
if any(getattr(record, column) != value for column, value in filters.items()):
continue
rows.append(
(
record.record_type,
record.record_key,
record.tenant,
record.user_id,
record.application_id,
record.scope_type,
record.scope_id,
json.dumps(record.payload),
)
)
self._rows = sorted(rows, key=lambda row: row[1])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,420 @@
from datetime import timedelta
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccountStatus,
CanonEntityReference,
Catalog,
CatalogLifecycle,
FactorVerification,
IdentityFactorType,
PreparedAccount,
PreparedAccountStatus,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
utc_now,
)
from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
sample_catalog,
)
class PreparedAccountTests(unittest.TestCase):
def test_claim_prepared_account_activates_entitlements(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(service, preparer)
registration = _complete_registration(service, applicant)
claim = service.claim_prepared_account(
applicant,
registration.session.registration_id,
correlation_id="corr-claim",
)
self.assertEqual(claim.prepared_account.status, PreparedAccountStatus.CLAIMED)
self.assertEqual(claim.prepared_account.claimed_by_user_id, claim.user.user_id)
self.assertEqual(claim.tenant_accounts[0].status, AccountStatus.ACTIVE)
self.assertEqual(claim.memberships[0].scope_id, "team:demo")
self.assertEqual(claim.memberships[0].kind, "member")
self.assertEqual(claim.profile_values[0].attribute_key, "demo.display_density")
self.assertEqual(claim.profile_values[0].value, "compact")
self.assertEqual(claim.onboarding_journeys, ("welcome-demo",))
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.CLAIMED,
)
self.assertIn(
"prepared_account.onboarding_requested",
[event.event_type for event in service.outbox_events()],
)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_claim_requires_matching_verified_factor(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(
service,
preparer,
email="different@example.test",
)
registration = _complete_registration(service, applicant)
before_outbox = len(service.outbox_events())
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-mismatch",
)
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.PENDING,
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(len(service.outbox_events()), before_outbox)
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_claim_ignores_expired_factor_evidence(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(service, preparer)
registration = _complete_registration(
service,
applicant,
factor_expires_at=utc_now() - timedelta(days=1),
)
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-expired-factor",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_ambiguous_prepared_account_matches_fail_closed(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
requirement = _email_requirement()
entitlements = (_membership_entitlement(),)
store.save_prepared_account(
PreparedAccount(
tenant="tenant:coulomb",
required_factor_matches=(requirement,),
entitlements=entitlements,
prepared_by_subject="fixture",
)
)
store.save_prepared_account(
PreparedAccount(
tenant="tenant:coulomb",
required_factor_matches=(requirement,),
entitlements=entitlements,
prepared_by_subject="fixture",
)
)
registration = _complete_registration(service, applicant)
with self.assertRaises(ConflictError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
correlation_id="corr-ambiguous",
)
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: ambiguous match",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
def test_approval_required_entitlement_blocks_claim(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
prepared = service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(),),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="team",
scope_id="team:ops",
role="admin",
requires_approval=True,
),
),
correlation_id="corr-prepare-privileged",
)
registration = _complete_registration(service, applicant)
with self.assertRaises(AuthorizationDenied):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-privileged",
)
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.PENDING,
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: approval required",
)
def test_revoked_and_expired_prepared_accounts_cannot_be_claimed(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
revoked = _prepare_demo_account(service, preparer)
service.revoke_prepared_account(
preparer,
revoked.prepared_account_id,
correlation_id="corr-revoke",
)
expired = _prepare_demo_account(service, preparer)
service.expire_prepared_account(
preparer,
expired.prepared_account_id,
correlation_id="corr-expire",
)
registration = _complete_registration(service, applicant)
for prepared in (revoked, expired):
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id=f"corr-claim-{prepared.prepared_account_id}",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_duplicate_pending_prepared_accounts_are_rejected(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
_bootstrap_catalog(service, preparer)
_prepare_demo_account(service, preparer)
with self.assertRaises(ConflictError):
_prepare_demo_account(service, preparer)
def test_weak_factor_requirements_are_rejected(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
with self.assertRaises(ValidationError):
service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=" ",
),
),
entitlements=(_membership_entitlement(),),
correlation_id="corr-weak-factor",
)
def test_revoke_and_list_prepared_accounts(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
prepared = _prepare_demo_account(service, preparer)
listed = service.list_prepared_accounts(
preparer,
tenant="tenant:coulomb",
correlation_id="corr-list",
)
revoked = service.revoke_prepared_account(
preparer,
prepared.prepared_account_id,
correlation_id="corr-revoke",
)
self.assertEqual(listed[0].prepared_account_id, prepared.prepared_account_id)
self.assertEqual(revoked.status, PreparedAccountStatus.REVOKED)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return service, store
def _actor(
*,
subject: str = "sample-user",
roles: tuple[str, ...] = ("user",),
email: str = "sample.user@example.test",
):
claims = human_actor_claims(subject=subject, tenant="tenant:coulomb")
claims["roles"] = list(roles)
claims["email"] = email
claims["preferred_username"] = subject
return FixtureIdentityClaimsAdapter().normalize(claims)
def _bootstrap_catalog(service: UserEngineService, actor):
service.register_application(
actor,
sample_application(),
binding=sample_application_binding(),
correlation_id="corr-app",
)
service.publish_catalog(
actor,
Catalog(
catalog_id="demo-prepared-profile",
namespace=sample_catalog().namespace,
version=sample_catalog().version,
owning_application_id=sample_catalog().owning_application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=sample_catalog().attributes,
),
correlation_id="corr-catalog",
)
def _complete_registration(
service: UserEngineService,
actor,
*,
factor_expires_at=None,
):
session = service.start_registration(actor, correlation_id="corr-start")
service.attach_registration_factor(
actor,
session.registration_id,
FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
evidence_refs=(
CanonEntityReference(
concept="Evidence Source",
identifier="email-proof",
source_system="fixture-email",
),
),
expires_at=factor_expires_at,
),
correlation_id="corr-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
def _prepare_demo_account(
service: UserEngineService,
preparer,
*,
email: str = "sample.user@example.test",
):
return service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(email=email),),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.TENANT_ACCOUNT,
tenant="tenant:coulomb",
tenant_account_status=AccountStatus.ACTIVE,
),
_membership_entitlement(),
PreparedEntitlement(
kind=PreparedEntitlementKind.PROFILE_VALUE,
tenant="tenant:coulomb",
attribute_key="demo.display_density",
value="compact",
profile_scope=ProfileScope.APPLICATION,
profile_scope_id="app.demo",
),
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
display_name="Prepared User",
primary_email=email,
correlation_id="corr-prepare",
)
def _email_requirement(
*,
email: str = "sample.user@example.test",
) -> PreparedFactorRequirement:
return PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=email,
source_system="fixture-email",
)
def _membership_entitlement() -> PreparedEntitlement:
return PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="team",
scope_id="team:demo",
role="member",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,286 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
FactorVerification,
IdentityFactorType,
OnboardingTriggerType,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
WelcomeProtocol,
WelcomeProtocolStep,
)
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.ui import RegistrationAccessManagementUi, UiViewport
class RegistrationAccessUiTests(unittest.TestCase):
def test_information_architecture_and_api_contract_expose_expected_routes(self):
ui, _, _ = _ui()
ia = ui.information_architecture()
contract = ui.api_contract()
route_ids = {route.route_id for route in contract.routes}
self.assertIn("registration", ia.primary_navigation)
self.assertIn("prepared_account.review", route_ids)
self.assertIn("access_profile.select_hat", route_ids)
self.assertIn("admin.dashboard", route_ids)
self.assertIn("authorization decisions", contract.adapter_boundaries)
self.assertEqual(ia.breakpoints["mobile"]["columns"], 1)
self.assertEqual(ia.breakpoints["desktop"]["columns"], 2)
def test_self_service_registration_flow_requires_terms_and_redacts_factor_values(self):
ui, service, _ = _ui()
actor = _actor()
started = ui.start_registration(
actor,
required_factor_types=(IdentityFactorType.EMAIL,),
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-start",
)
ui.attach_factor(
actor,
started.session.registration_id,
_verified_email(),
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-factor",
)
blocked = ui.complete_registration(
actor,
started.session.registration_id,
terms_accepted=False,
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-blocked",
)
completed = ui.complete_registration(
actor,
started.session.registration_id,
terms_accepted=True,
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-complete",
)
html = ui.render_html(completed.screen)
self.assertIn("Terms and consent", blocked.screen.alerts[0])
self.assertEqual(completed.completion.netkingdom_id, completed.completion.user.user_id)
self.assertEqual(completed.screen.layout["min_touch_target"], 44)
self.assertIn("role='main'", html)
self.assertIn("data-viewport='mobile'", html)
self.assertNotIn("sample.user@example.test", html)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_prepared_rights_can_be_reviewed_accepted_or_dismissed(self):
ui, service, _ = _ui()
actor = _actor()
prepared = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
role="member",
),
),
display_name="Prepared Member",
primary_email="sample.user@example.test",
correlation_id="corr-ui-prepare",
)
registration = _complete_registration(service, actor)
review = ui.prepared_rights_review(
actor,
registration.session.registration_id,
viewport=UiViewport.DESKTOP,
)
dismissed = ui.deny_prepared_claim(
prepared.prepared_account_id,
viewport=UiViewport.DESKTOP,
)
accepted = ui.accept_prepared_claim(
actor,
registration.session.registration_id,
prepared.prepared_account_id,
viewport=UiViewport.DESKTOP,
correlation_id="corr-ui-claim",
)
html = ui.render_html(review)
self.assertIn("denied_by_user", repr(dismissed))
self.assertEqual(accepted.claim.prepared_account.claimed_by_user_id, accepted.claim.user.user_id)
self.assertIn("Prepared Member", html)
self.assertIn("&lt;redacted&gt;", html)
self.assertNotIn("sample.user@example.test", html)
def test_hat_selection_view_selects_active_context_without_policy_details(self):
ui, service, store = _ui()
actor = _actor()
registration = _complete_registration(service, actor)
service.add_membership(
actor,
registration.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-ui-membership",
)
profile = service.register_access_profile(
actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Realm Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
service_id="app.demo",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
claims={"internal_policy_hint": "do-not-render"},
),
correlation_id="corr-ui-profile",
)
before = ui.hat_selection_view(
actor,
registration.user.user_id,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
selected = ui.select_hat(
actor,
registration.user.user_id,
profile.access_profile_id,
viewport=UiViewport.DESKTOP,
correlation_id="corr-ui-select-hat",
)
active = store.active_access_context(registration.user.user_id, "tenant:coulomb")
html = ui.render_html(selected)
self.assertIn("none", ui.render_html(before))
self.assertEqual(active.hat, "operator")
self.assertIn("Realm Operator", html)
self.assertNotIn("do-not-render", html)
def test_admin_dashboard_redacts_sensitive_setup_details(self):
ui, service, _ = _ui()
actor = _actor()
session = service.me(human_actor_claims(), correlation_id="corr-ui-me")
service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Blocked Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="external",
title="External",
subsystem="crm",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-ui-protocol",
)
service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
primary_email="sample.user@example.test",
correlation_id="corr-ui-prepare",
)
dashboard = ui.admin_dashboard(
actor,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
html = ui.render_html(dashboard)
self.assertEqual(dashboard.layout["columns"], 2)
self.assertIn("Prepared Accounts", html)
self.assertIn("Onboarding", html)
self.assertNotIn("sample.user@example.test", html)
self.assertIn("role='navigation'", html)
self.assertIn("aria-label='Sections'", html)
def _ui():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return RegistrationAccessManagementUi(service), service, store
def _actor():
return FixtureIdentityClaimsAdapter().normalize(
human_actor_claims(subject="sample-user", tenant="tenant:coulomb")
)
def _verified_email() -> FactorVerification:
return FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
)
def _complete_registration(service: UserEngineService, actor):
session = service.start_registration(actor, correlation_id="corr-ui-reg-start")
service.attach_registration_factor(
actor,
session.registration_id,
_verified_email(),
correlation_id="corr-ui-reg-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-ui-reg-complete",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,175 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
CanonEntityReference,
FactorVerification,
IdentityFactorType,
RegistrationStatus,
)
from user_engine.errors import ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
class RegistrationIdentityTests(unittest.TestCase):
def test_registration_with_verified_email_creates_netkingdom_id(self):
service, store = _service()
actor = _actor()
session = service.start_registration(actor, correlation_id="corr-start")
verified = service.attach_registration_factor(
actor,
session.registration_id,
_verified_email(),
correlation_id="corr-factor",
)
completion = service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
self.assertEqual(verified.status, RegistrationStatus.FACTOR_VERIFIED)
self.assertEqual(completion.session.status, RegistrationStatus.COMPLETED)
self.assertEqual(completion.netkingdom_id, completion.user.user_id)
self.assertEqual(completion.session.netkingdom_id, completion.user.user_id)
self.assertEqual(completion.user.primary_email, "sample.user@example.test")
self.assertEqual(completion.identity_context.user.user_id, completion.user.user_id)
self.assertEqual(store.find_identity(*actor.identity_key).user_id, completion.user.user_id)
self.assertEqual(
store.factors_for_user(completion.user.user_id)[0].factor_type,
IdentityFactorType.EMAIL,
)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_registration_requires_all_required_factors_before_completion(self):
service, store = _service()
actor = _actor()
session = service.start_registration(
actor,
required_factor_types=(IdentityFactorType.EID,),
correlation_id="corr-start",
)
service.attach_registration_factor(
actor,
session.registration_id,
_verified_email(),
correlation_id="corr-factor",
)
with self.assertRaises(ValidationError):
service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
self.assertEqual(store.record_counts()["users"], 0)
self.assertIsNone(store.registration_session(session.registration_id).user_id)
def test_factor_verifier_adapter_normalizes_external_proofing_result(self):
service, store = _service(factor_verifier=_FixtureFactorVerifier())
actor = _actor()
session = service.start_registration(actor, correlation_id="corr-start")
updated = service.attach_registration_factor(
actor,
session.registration_id,
{
"type": "email",
"value": "Sample.User@Example.Test",
"secret_challenge": "do-not-store-this",
},
correlation_id="corr-factor",
)
stored = store.factors_for_registration(session.registration_id)[0]
self.assertEqual(updated.status, RegistrationStatus.FACTOR_VERIFIED)
self.assertEqual(stored.normalized_value, "sample.user@example.test")
self.assertNotIn("do-not-store-this", repr(stored))
self.assertNotIn(
"do-not-store-this",
repr([event.payload for event in service.outbox_events()]),
)
def test_abandoned_registration_cannot_resume_or_complete(self):
service, _ = _service()
actor = _actor()
session = service.start_registration(actor, correlation_id="corr-start")
abandoned = service.abandon_registration(
actor,
session.registration_id,
correlation_id="corr-abandon",
)
diagnostics = service.registration_diagnostics(
actor,
tenant="tenant:coulomb",
correlation_id="corr-diagnostics",
)
self.assertEqual(abandoned.status, RegistrationStatus.ABANDONED)
self.assertEqual(diagnostics.statuses[RegistrationStatus.ABANDONED.value], 1)
with self.assertRaises(ValidationError):
service.resume_registration(
actor,
session.registration_id,
correlation_id="corr-resume",
)
with self.assertRaises(ValidationError):
service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
def _service(*, factor_verifier=None):
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
factor_verifier=factor_verifier,
)
return service, store
def _actor():
return FixtureIdentityClaimsAdapter().normalize(human_actor_claims())
def _verified_email() -> FactorVerification:
return FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
assurance={"level": "email_verified"},
evidence_refs=(
CanonEntityReference(
concept="Evidence Source",
identifier="fixture-email-proof",
source_system="fixture-email",
),
),
)
class _FixtureFactorVerifier:
def normalize(self, proofing_result):
return FactorVerification(
factor_type=IdentityFactorType(str(proofing_result["type"])),
normalized_value=str(proofing_result["value"]).lower(),
display_value=str(proofing_result["value"]).lower(),
source_system="fixture-proofing",
assurance={"level": "email_verified"},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,576 @@
from dataclasses import replace
from datetime import timedelta
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
AccountStatus,
AttributeDefinition,
Catalog,
CatalogLifecycle,
FactorVerification,
IdentityFactorType,
Mutability,
OnboardingJourneyStatus,
OnboardingTriggerType,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
ProjectionType,
Sensitivity,
Visibility,
WelcomeProtocol,
WelcomeProtocolStep,
utc_now,
)
from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError
from user_engine.service import REDACTED, UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
)
from user_engine.testing.scenarios import (
ScenarioAuthorizationHarness,
StrictFixtureIdentityClaimsAdapter,
missing_tenant_claims,
)
from user_engine.ui import RegistrationAccessManagementUi, UiViewport
class RegistrationSecurityConformanceTests(unittest.TestCase):
def test_full_registration_claim_hat_onboarding_ui_conformance_path(self):
service, store, _ = _service()
actor = _actor("conformance-user")
_bootstrap_application(service, actor)
service.register_welcome_protocol(
actor,
_prepared_welcome_protocol(),
correlation_id="corr-conf-protocol",
)
prepared = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(),),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
role="operator",
),
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
correlation_id="corr-conf-prepare",
)
registration = _complete_registration(service, actor)
claim = service.claim_prepared_account(
actor,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-conf-claim",
)
profile = service.register_access_profile(
actor,
_operator_access_profile(),
correlation_id="corr-conf-profile",
)
selection = service.select_active_hat(
actor,
registration.user.user_id,
profile.access_profile_id,
correlation_id="corr-conf-hat",
)
projection = service.projection(
actor,
registration.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.demo",
tenant="tenant:coulomb",
correlation_id="corr-conf-projection",
)
context = service.identity_context(
actor,
user_id=registration.user.user_id,
tenant="tenant:coulomb",
application_id="app.demo",
correlation_id="corr-conf-context",
)
export = service.export_access_control_facts(
actor,
tenant="tenant:coulomb",
user_id=registration.user.user_id,
correlation_id="corr-conf-export",
)
ui_html = RegistrationAccessManagementUi(service).render_html(
RegistrationAccessManagementUi(service).admin_dashboard(
actor,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
)
self.assertEqual(claim.memberships[0].kind, "operator")
self.assertEqual(selection.active_context.hat, "operator")
self.assertEqual(projection.access_context["active_hat"], "operator")
self.assertTrue(context.onboarding_journeys)
self.assertEqual(
context.onboarding_journeys[0].status,
OnboardingJourneyStatus.IN_PROGRESS,
)
self.assertIn("user", export.manifest["subject_types"])
self.assertIn(
"onboarding_journey.started",
[event.event_type for event in service.outbox_events()],
)
self.assertNotIn("sample.user@example.test", ui_html)
self.assertEqual(store.record_counts()["onboarding_journeys"], 1)
def test_security_negative_paths_fail_closed_with_audit_evidence(self):
service, store, _ = _service()
actor = _actor("security-user")
_bootstrap_application(service, actor)
registration = _complete_registration(service, actor)
other_user = service.create_user(
actor,
display_name="Other",
primary_email="other@example.test",
correlation_id="corr-other",
)
with self.assertRaises(ConflictError):
service.link_identity(
actor,
other_user.user_id,
issuer=actor.issuer,
subject=actor.subject,
correlation_id="corr-duplicate-identity",
)
with self.assertRaises(ValidationError):
service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=" ",
),
),
entitlements=(_membership_entitlement(),),
correlation_id="corr-weak-factor",
)
hijack = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="victim@example.test",
),
),
entitlements=(_membership_entitlement(),),
correlation_id="corr-hijack-prepare",
)
with self.assertRaises(ValidationError):
service.claim_prepared_account(
actor,
registration.session.registration_id,
prepared_account_id=hijack.prepared_account_id,
correlation_id="corr-hijack-claim",
)
expired = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement("expired@example.test"),),
entitlements=(_membership_entitlement(scope_id="realm:expired"),),
expires_at=utc_now() - timedelta(days=1),
correlation_id="corr-expired-prepare",
)
with self.assertRaises(ValidationError):
service.claim_prepared_account(
actor,
registration.session.registration_id,
prepared_account_id=expired.prepared_account_id,
correlation_id="corr-expired-claim",
)
with self.assertRaises(AuthorizationDenied):
service.resolve_tenant_context(actor, "tenant:faraday")
privileged = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(),),
entitlements=(
replace(
_membership_entitlement(scope_id="realm:privileged"),
role="admin",
requires_approval=True,
),
),
correlation_id="corr-privileged-prepare",
)
with self.assertRaises(AuthorizationDenied):
service.claim_prepared_account(
actor,
registration.session.registration_id,
prepared_account_id=privileged.prepared_account_id,
correlation_id="corr-privileged-claim",
)
access_profile = service.register_access_profile(
actor,
replace(_operator_access_profile(), requires_approval=True),
correlation_id="corr-stale-approval-profile",
)
with self.assertRaises(AuthorizationDenied):
service.select_active_hat(
actor,
registration.user.user_id,
access_profile.access_profile_id,
correlation_id="corr-stale-approval",
)
audit_summaries = [record.summary for record in service.audit_records()]
self.assertIn(
"prepared account claim denied: factor mismatch or closed",
audit_summaries,
)
self.assertIn(
"prepared account claim denied: approval required",
audit_summaries,
)
self.assertEqual(store.memberships_for_user(other_user.user_id), ())
def test_redaction_and_diagnostics_conformance(self):
service, _, _ = _service()
actor = _actor("redaction-user")
_bootstrap_application(service, actor, catalog=_sensitive_catalog())
registration = _complete_registration(service, actor)
service.set_profile_value(
actor,
registration.user.user_id,
"demo.recovery_hint",
"blue envelope",
tenant="tenant:coulomb",
correlation_id="corr-sensitive-profile",
)
service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(),),
entitlements=(_membership_entitlement(),),
primary_email="sample.user@example.test",
correlation_id="corr-redaction-prepare",
)
service.register_access_profile(
actor,
replace(
_operator_access_profile(),
claims={"policy_secret": "do-not-render"},
profile_defaults={"landing_hint": "do-not-render"},
),
correlation_id="corr-redaction-profile",
)
projection = service.projection(
actor,
registration.user.user_id,
ProjectionType.APPLICATION_RUNTIME,
application_id="app.demo",
tenant="tenant:coulomb",
correlation_id="corr-sensitive-projection",
)
access_diagnostics = service.access_profile_diagnostics(
actor,
tenant="tenant:coulomb",
correlation_id="corr-access-diagnostics",
)
admin_html = RegistrationAccessManagementUi(service).render_html(
RegistrationAccessManagementUi(service).admin_dashboard(
actor,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
)
event_payloads = repr([event.payload for event in service.outbox_events()])
self.assertEqual(projection.values["demo.recovery_hint"], REDACTED)
self.assertNotIn("blue envelope", repr(access_diagnostics))
self.assertNotIn("do-not-render", repr(access_diagnostics))
self.assertNotIn("sample.user@example.test", admin_html)
self.assertNotIn("sample.user@example.test", event_payloads)
self.assertNotIn("blue envelope", event_payloads)
def test_adapter_conformance_harnesses_without_production_infrastructure(self):
store = InMemoryUserEngineStore()
authz = ScenarioAuthorizationHarness(
action_obligations={"access_control_facts.export": ("acl:sync",)}
)
service = UserEngineService(
store=store,
identity_adapter=StrictFixtureIdentityClaimsAdapter(),
authorization=authz,
factor_verifier=_FixtureFactorVerifier(),
)
actor = service.identity_adapter.normalize(human_actor_claims())
_bootstrap_application(service, actor)
registration = service.start_registration(actor, correlation_id="corr-adapter-start")
service.attach_registration_factor(
actor,
registration.registration_id,
{"type": "eid", "value": "EID-123", "secret": "strip-me"},
correlation_id="corr-adapter-factor",
)
service.attach_registration_factor(
actor,
registration.registration_id,
{"type": "email", "value": "sample.user@example.test"},
correlation_id="corr-adapter-email",
)
completed = service.complete_registration(
actor,
registration.registration_id,
correlation_id="corr-adapter-complete",
)
service.add_membership(
actor,
completed.user.user_id,
tenant="tenant:coulomb",
scope_type="group",
scope_id="group:research",
kind="member",
correlation_id="corr-adapter-group",
)
export = service.export_access_control_facts(
actor,
tenant="tenant:coulomb",
user_id=completed.user.user_id,
correlation_id="corr-adapter-export",
)
protocol = service.register_welcome_protocol(
actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Adapter Handoff",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="callback",
title="Callback",
subsystem="crm",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-adapter-protocol",
)
blocked = service.start_onboarding_journey(
actor,
completed.user.user_id,
protocol.protocol_id,
correlation_id="corr-adapter-onboarding",
).journey
resumed = service.resume_onboarding_journey(
actor,
blocked.journey_id,
callback_refs={"callback": "crm://welcome/callback"},
correlation_id="corr-adapter-resume",
)
self.assertIn("group", export.manifest["subject_types"])
self.assertTrue(any(request.action == "access_control_facts.export" for request in authz.requests))
self.assertNotIn("strip-me", repr(store.factors_for_user(completed.user.user_id)))
self.assertEqual(blocked.status, OnboardingJourneyStatus.BLOCKED)
self.assertEqual(resumed.status, OnboardingJourneyStatus.IN_PROGRESS)
self.assertTrue(service.audit_records())
self.assertTrue(service.outbox_events())
self.assertEqual(service.operability_snapshot().issues, ())
with self.assertRaises(ValidationError):
service.identity_adapter.normalize(missing_tenant_claims())
class _FixtureFactorVerifier:
def normalize(self, proofing_result):
factor_type = IdentityFactorType(str(proofing_result["type"]))
return FactorVerification(
factor_type=factor_type,
normalized_value=str(proofing_result["value"]).casefold(),
display_value=None,
source_system="fixture-proofing",
assurance={"level": "ial2" if factor_type == IdentityFactorType.EID else "ial1"},
)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return service, store, service.authorization
def _actor(subject: str):
claims = human_actor_claims(subject=subject, tenant="tenant:coulomb")
claims["roles"] = ["tenant-admin"]
return FixtureIdentityClaimsAdapter().normalize(claims)
def _bootstrap_application(
service: UserEngineService,
actor,
*,
catalog: Catalog | None = None,
) -> None:
service.register_application(
actor,
sample_application(),
binding=sample_application_binding(),
correlation_id="corr-bootstrap-app",
)
service.publish_catalog(
actor,
catalog or _simple_catalog(),
correlation_id="corr-bootstrap-catalog",
)
def _complete_registration(service: UserEngineService, actor):
session = service.start_registration(actor, correlation_id="corr-reg-start")
service.attach_registration_factor(
actor,
session.registration_id,
FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
),
correlation_id="corr-reg-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-reg-complete",
)
def _email_requirement(email: str = "sample.user@example.test"):
return PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=email,
)
def _membership_entitlement(scope_id: str = "realm:citadel"):
return PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="realm",
scope_id=scope_id,
role="operator",
)
def _operator_access_profile() -> AccessProfile:
return AccessProfile(
tenant="tenant:coulomb",
display_name="Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
service_id="app.demo",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
claims={"service_role": "operator"},
)
def _prepared_welcome_protocol() -> WelcomeProtocol:
return WelcomeProtocol(
tenant="tenant:coulomb",
name="Prepared Welcome",
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
journey_key="welcome-demo",
prepared_journey="welcome-demo",
steps=(
WelcomeProtocolStep(
step_key="intro",
title="Intro",
subsystem="portal",
callback_ref="portal://welcome",
requires_subsystem_callback=True,
),
),
)
def _simple_catalog() -> Catalog:
return Catalog(
catalog_id="demo-profile",
namespace="demo",
version="0.1.0",
owning_application_id="app.demo",
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
AttributeDefinition(
key="demo.display_density",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.USER, Visibility.APPLICATION),
mutability=(Mutability.USER,),
default="comfortable",
validation={"enum": ["compact", "comfortable"]},
),
),
)
def _sensitive_catalog() -> Catalog:
catalog = _simple_catalog()
return Catalog(
catalog_id=catalog.catalog_id,
namespace=catalog.namespace,
version=catalog.version,
owning_application_id=catalog.owning_application_id,
lifecycle=catalog.lifecycle,
attributes=(
*catalog.attributes,
AttributeDefinition(
key="demo.recovery_hint",
value_type="string",
scope=ProfileScope.GLOBAL,
sensitivity=Sensitivity.SENSITIVE,
visibility=(Visibility.USER, Visibility.APPLICATION, Visibility.ADMIN),
mutability=(Mutability.USER, Mutability.ADMIN),
),
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,93 @@
import json
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.migrations import USER_ENGINE_STORE_RECORD_TYPES
from user_engine.store_records import (
StoreRecord,
domain_record_from_store_record,
store_record_for,
validate_store_record_codecs,
)
from user_engine.testing.store_conformance import (
PROFILE_SECRET_VALUE,
RAW_FACTOR_VALUE,
TENANT,
USER_ID,
reference_store_records,
)
class StoreRecordSerializationTests(unittest.TestCase):
def test_codecs_cover_migration_manifest_record_types(self):
self.assertEqual(validate_store_record_codecs(), ())
def test_reference_records_round_trip_through_json_safe_payloads(self):
store = InMemoryUserEngineStore()
store.migrate()
records = reference_store_records(store)
expected_types = set(USER_ENGINE_STORE_RECORD_TYPES)
encoded_types = set()
for value in records.values():
record = store_record_for(value)
encoded_types.add(record.record_type)
json.dumps(record.payload)
decoded = domain_record_from_store_record(
StoreRecord(
record_type=record.record_type,
record_key=record.record_key,
payload=json.loads(json.dumps(record.payload)),
tenant=record.tenant,
user_id=record.user_id,
application_id=record.application_id,
scope_type=record.scope_type,
scope_id=record.scope_id,
)
)
self.assertEqual(decoded, value)
self.assertEqual(encoded_types, expected_types)
def test_record_metadata_supports_provider_indexes(self):
store = InMemoryUserEngineStore()
store.migrate()
records = reference_store_records(store)
tenant_account = store_record_for(records["tenant_account"])
active_context = store_record_for(records["access_context"])
profile_value = store_record_for(records["profile_value"])
factor = store_record_for(records["factor"])
self.assertEqual(tenant_account.record_key, f'["{TENANT}","{USER_ID}"]')
self.assertEqual(tenant_account.tenant, TENANT)
self.assertEqual(tenant_account.user_id, USER_ID)
self.assertEqual(active_context.tenant, TENANT)
self.assertEqual(active_context.user_id, USER_ID)
self.assertEqual(active_context.scope_type, "tenant")
self.assertEqual(profile_value.scope_type, "global")
self.assertEqual(factor.user_id, USER_ID)
def test_durable_payloads_are_raw_state_not_diagnostics(self):
store = InMemoryUserEngineStore()
store.migrate()
records = reference_store_records(store)
factor = store_record_for(records["factor"])
access_profile = store_record_for(records["access_profile"])
self.assertIn(RAW_FACTOR_VALUE, json.dumps(factor.payload))
self.assertIn(PROFILE_SECRET_VALUE, json.dumps(access_profile.payload))
def test_unknown_record_type_fails_closed(self):
with self.assertRaises(ValueError):
domain_record_from_store_record(
StoreRecord(record_type="unknown", record_key="1", payload={})
)
with self.assertRaises(TypeError):
store_record_for(object())
if __name__ == "__main__":
unittest.main()

View File

@@ -2,7 +2,7 @@
id: USER-WP-0001
type: workplan
title: "User Engine Preparation And Interface Adoption"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0002
type: workplan
title: "User Engine Isolated MVP"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex
@@ -13,6 +13,7 @@ created: "2026-05-22"
updated: "2026-05-22"
depends_on:
- USER-WP-0001
state_hub_workstream_id: "780ce3bb-9af0-43dc-85cd-a9288e3d74c7"
---
# USER-WP-0002 - User Engine Isolated MVP
@@ -29,6 +30,7 @@ profile resolution, projections, audit, outbox, and tests.
id: USER-WP-0002-T1
status: done
priority: high
state_hub_task_id: "0b43c19e-7ca4-4d32-93f4-3c083a200092"
```
Implement the domain model and local persistence migrations.
@@ -37,6 +39,7 @@ Implement the domain model and local persistence migrations.
id: USER-WP-0002-T2
status: done
priority: high
state_hub_task_id: "d6404f5c-292f-4eb5-819b-42fe8c237c60"
```
Implement IAM Profile-compatible fixture actor handling and local identity
@@ -46,6 +49,7 @@ linking by `(issuer, subject)`.
id: USER-WP-0002-T3
status: done
priority: high
state_hub_task_id: "b0b0ad70-d590-4faf-916e-41dbf25d6c5f"
```
Implement the authorization check port with a deterministic local test
@@ -55,6 +59,7 @@ adapter.
id: USER-WP-0002-T4
status: done
priority: high
state_hub_task_id: "ce310565-75e3-4fb4-9358-0aaff14a8ada"
```
Implement headless APIs for health, readiness, `me`, users, account lifecycle,
@@ -64,6 +69,7 @@ identity links, applications, catalogs, profiles, projections, and audit.
id: USER-WP-0002-T5
status: done
priority: high
state_hub_task_id: "4ebb8649-e3ff-4da8-80cd-eef8b1488129"
```
Implement catalog validation, profile value validation, defaults, global plus
@@ -73,6 +79,7 @@ application profile layers, and inspectable effective profile resolution.
id: USER-WP-0002-T6
status: done
priority: high
state_hub_task_id: "a238bbd8-95bb-499a-85f4-744acce188d4"
```
Persist audit records and outbox events atomically with mutations.
@@ -81,6 +88,7 @@ Persist audit records and outbox events atomically with mutations.
id: USER-WP-0002-T7
status: done
priority: high
state_hub_task_id: "a9826644-1fea-4ada-bc21-7c545e790ffc"
```
Add tests for lifecycle, identity linking, catalog validation, profile update

View File

@@ -2,7 +2,7 @@
id: USER-WP-0003
type: workplan
title: "User Engine Multi-Tenancy"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0004
type: workplan
title: "User Engine Multi-Application And Catalog Support"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0005
type: workplan
title: "User Engine Integrated Test Scenarios"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0006
type: workplan
title: "User Engine Implementation Assessment And Polish"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0007
type: workplan
title: "Identity Domain Canon Alignment"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex

View File

@@ -2,7 +2,7 @@
id: USER-WP-0008
type: workplan
title: "Family Dataspace Onboarding"
domain: netkingdom
domain: communication
repo: user-engine
status: finished
owner: codex
@@ -13,6 +13,7 @@ created: "2026-06-05"
updated: "2026-06-05"
depends_on:
- USER-WP-0007
state_hub_workstream_id: "29a39dfe-2693-4336-8e74-29a61530e4a3"
---
# USER-WP-0008 - Family Dataspace Onboarding
@@ -60,6 +61,7 @@ canon-facing identity context for that scope.
id: USER-WP-0008-T1
status: done
priority: high
state_hub_task_id: "0ce15e29-e1e2-4d22-be3c-8cc64e5472bf"
```
Define the family dataspace vocabulary and mapping. Cover family tenant,
@@ -72,6 +74,7 @@ IAM, tenant, policy, audit, or dataspace systems.
id: USER-WP-0008-T2
status: done
priority: high
state_hub_task_id: "bf477973-34af-4720-917c-675c4a18fecb"
```
Design and implement a headless onboarding facade that composes existing
@@ -83,6 +86,7 @@ binding, initial member descriptors, role assignments, and profile defaults.
id: USER-WP-0008-T3
status: done
priority: high
state_hub_task_id: "07dfaa2f-1df2-4a52-984e-41fb1345c854"
```
Add member invitation and acceptance support. Cover pre-created users,
@@ -94,6 +98,7 @@ identity proofing delegated to NetKingdom IAM or a dedicated invite adapter.
id: USER-WP-0008-T4
status: done
priority: high
state_hub_task_id: "4b4dccf3-926f-4d6a-8135-f5d8f46faa85"
```
Register the personal dataspace application through `register_application`,
@@ -105,6 +110,7 @@ rules.
id: USER-WP-0008-T5
status: done
priority: high
state_hub_task_id: "6032fbdb-2922-440a-9af7-041aa295528b"
```
Implement family membership templates and fact export. Support owner, adult,
@@ -115,6 +121,7 @@ boundaries and authorization-port decisions for privileged actions.
id: USER-WP-0008-T6
status: done
priority: medium
state_hub_task_id: "2ea5c3ac-5fec-49f6-9ea4-d0485756fc63"
```
Expose SSO-ready context for the personal dataspace. Use `identity_context`
@@ -126,6 +133,7 @@ to the NetKingdom SSO adapter.
id: USER-WP-0008-T7
status: done
priority: medium
state_hub_task_id: "9647ae26-1719-4836-8765-1240827c46d4"
```
Add lifecycle, audit, evidence, and outbox behavior for onboarding. Every
@@ -137,6 +145,7 @@ or explicit evidence-gap references.
id: USER-WP-0008-T8
status: done
priority: medium
state_hub_task_id: "74a12383-eb13-4a79-b1b1-1810bd5334dd"
```
Add scenario tests and examples for the complete family dataspace flow. Cover

View File

@@ -2,17 +2,18 @@
id: USER-WP-0009
type: workplan
title: "Postgres Durable Store Consumer Requirements"
domain: netkingdom
domain: communication
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"
---
# USER-WP-0009 - Postgres Durable Store Consumer Requirements
@@ -21,9 +22,11 @@ depends_on:
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
@@ -50,8 +53,9 @@ 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"
```
Inventory the current in-memory store behavior and document the durable
@@ -62,8 +66,9 @@ schema version reporting.
```task
id: USER-WP-0009-T2
status: todo
status: done
priority: high
state_hub_task_id: "19cfd23e-8a87-416d-b948-c727e8c5a11c"
```
Create a consumer-facing requirements document for a Postgres durable store.
@@ -73,8 +78,9 @@ 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"
```
Define the boundary between user-engine and the future NetKingdom Postgres
@@ -84,8 +90,9 @@ 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"
```
Identify required changes, if any, to the existing store protocol or migration
@@ -94,8 +101,9 @@ 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"
```
Define conformance scenarios and failure-mode tests the future Postgres store
@@ -105,8 +113,9 @@ 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"
```
Record open questions for the independent provider repository, including
@@ -126,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
```

View File

@@ -0,0 +1,157 @@
---
id: USER-WP-0010
type: workplan
title: "Registration Identity And Factor Model"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Defined NetKingdom ID semantics as the existing opaque `User.user_id` for
this first slice.
- Added `RegistrationStatus`, `IdentityFactorType`, `FactorVerification`,
`IdentityFactor`, and `RegistrationSession` domain models.
- Added registration and factor persistence to `UserEngineStore` and
`InMemoryUserEngineStore`.
- Added `FactorVerificationAdapter` for normalizing external proofing results
into safe factor evidence.
- Added `UserEngineService` registration facade methods:
`start_registration`, `attach_registration_factor`,
`complete_registration`, `abandon_registration`, `expire_registration`,
`resume_registration`, and `registration_diagnostics`.
- Added audit/outbox events for registration lifecycle transitions while
keeping factor values out of event payloads and diagnostics.
- Added `docs/registration-identity-and-factor-model.md` and public contract
updates.
- Added tests for successful email-backed registration, required-factor
enforcement, adapter-normalized factor evidence, and abandoned-session
behavior.
Verification:
```text
make test
Ran 46 tests in 0.162s
OK
```

View File

@@ -0,0 +1,160 @@
---
id: USER-WP-0011
type: workplan
title: "Prepared Accounts And Entitlement Claims"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Added `PreparedAccountStatus`, `PreparedEntitlementKind`,
`PreparedFactorRequirement`, `PreparedEntitlement`, and `PreparedAccount`
domain models.
- Added prepared-account persistence to `UserEngineStore` and
`InMemoryUserEngineStore`, including transaction rollback snapshots and
adapter-neutral record counts.
- Added `UserEngineService` prepared-account facade methods:
`prepare_account`, `update_prepared_account`, `list_prepared_accounts`,
`revoke_prepared_account`, `expire_prepared_account`, and
`claim_prepared_account`.
- Added factor-match claim resolution for completed registrations, explicit
claim decisions, duplicate pending package checks, expiry handling,
weak-factor rejection, ambiguous-match rejection, expired-factor rejection,
and approval-required fail-closed behavior.
- Added entitlement activation into tenant accounts, memberships, catalog
validated profile values, application bindings, and onboarding-request
outbox events.
- Added audit/outbox behavior for preparation, update, claim, onboarding
request, expiry, and revocation while keeping normalized factor values out
of event payloads.
- Added `docs/prepared-accounts-and-entitlement-claims.md`, public contract
updates, and scenario tests for successful claim, mismatch, ambiguity,
approval-required denial, list, and revoke behavior.
Verification:
```text
make test
Ran 55 tests in 0.362s
OK
```

View File

@@ -0,0 +1,153 @@
---
id: USER-WP-0012
type: workplan
title: "Hats, Realms, Services, Assets, And Access Profiles"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Added `AccessScopeType`, `AccessMembershipRequirement`, `AccessProfile`,
`ActiveAccessContext`, and `AccessControlFact` domain models.
- Added access-profile and active-context persistence to `UserEngineStore` and
`InMemoryUserEngineStore`, including transaction snapshots and record
counts.
- Added `UserEngineService` facade methods:
`register_access_profile`, `list_access_profiles`, `select_active_hat`,
`export_access_control_facts`, and `access_profile_diagnostics`.
- Added fail-closed active hat selection requiring tenant context, active
tenant account state, matching membership facts, unexpired factor evidence,
non-approval-required profile state, and authorization-port approval.
- Extended `identity_context` with active access context, access-control facts,
canon references for hats/realms/services/assets/groups, and active-hat
relationship references.
- Extended claims-enrichment projections with service-filtered access context
while keeping raw factor values out of events and diagnostics.
- Added adapter-neutral access-control fact export for direct memberships,
group-derived facts, and active-context facts.
- Added `docs/hats-realms-services-assets-access-profiles.md`, public contract
updates, and tests for active hat selection, cross-tenant denial, missing
factor assurance, group-derived access, service-specific projections, and
redacted diagnostics.
Verification:
```text
make test
Ran 61 tests in 0.515s
OK
```

View File

@@ -0,0 +1,145 @@
---
id: USER-WP-0013
type: workplan
title: "Onboarding Journeys And Welcome Protocols"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Added `OnboardingTriggerType`, `OnboardingJourneyStatus`,
`OnboardingStepStatus`, `WelcomeProtocol`, `WelcomeProtocolStep`,
`OnboardingJourney`, `OnboardingStep`, `OnboardingTask`, and
`SubsystemHandoff` domain models.
- Added welcome-protocol and onboarding-journey persistence to
`UserEngineStore` and `InMemoryUserEngineStore`, including transaction
snapshots and record counts.
- Added adapter ports for onboarding notifications, task links, support
content, subsystem welcome callbacks, and lifecycle task linking.
- Added `UserEngineService` onboarding facade methods:
`register_welcome_protocol`, `list_welcome_protocols`,
`start_onboarding_journey`, `start_onboarding_for_registration`,
`start_onboarding_for_prepared_account`, `progress_onboarding_step`,
`complete_onboarding_step`, `skip_onboarding_step`,
`fail_onboarding_step`, `resume_onboarding_journey`, and
`onboarding_diagnostics`.
- Added auto-start hooks for matching registration-completion protocols and
prepared-account claim protocols.
- Extended `identity_context` with onboarding journeys for the resolved
user/tenant.
- Added lifecycle-gap handling for missing subsystem callbacks and resumable
blocked/failed journey state.
- Added `docs/onboarding-journeys-and-welcome-protocols.md`, public contract
updates, and tests for registration-triggered, prepared-claim-triggered,
blocked, resumed, progressed, skipped, and failed onboarding.
Verification:
```text
make test
Ran 66 tests in 0.620s
OK
```

View File

@@ -0,0 +1,164 @@
---
id: USER-WP-0014
type: workplan
title: "Registration And Access Management UI"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Added `user_engine.ui` with transport-neutral UI contracts:
`UiRoute`, `UiApiContract`, `UiInformationArchitecture`, `UiScreen`,
`UiSection`, `UiField`, `UiAction`, `UiRegistrationFlow`, and
`RegistrationAccessManagementUi`.
- Defined information architecture for registration, prepared rights, active
hat, profile, onboarding, and admin views, with mobile and desktop layout
metadata.
- Added UI route contracts for registration start/factor/complete,
prepared-rights review/accept/deny, active hat selection, and admin
dashboard.
- Implemented self-service registration helpers with resume/status rendering,
adapter-supplied factor evidence, terms/consent gating, and completion
state.
- Implemented prepared-rights review and accept/dismiss screens while
redacting factor values.
- Implemented active hat selection views over access profiles and active
access context without exposing hidden policy logic.
- Implemented admin dashboard composition for registration diagnostics,
prepared accounts, tenant membership state, access profiles, and onboarding
diagnostics.
- Added accessible HTML verification rendering with semantic landmarks,
labeled section navigation, action labels, and mobile/desktop layout
metadata.
- Added `docs/registration-and-access-management-ui.md`, UI contract updates,
and tests for route contracts, self-service registration, prepared claims,
hat selection, admin redaction, accessibility, and responsive metadata.
Verification:
```text
make test
Ran 71 tests in 1.332s
OK
```

View File

@@ -0,0 +1,154 @@
---
id: USER-WP-0015
type: workplan
title: "Registration Scenario And Security Conformance"
domain: communication
repo: user-engine
status: finished
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: done
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: done
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: done
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: done
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: done
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: done
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.
## Implementation Notes
Implemented on 2026-06-15:
- Extended `SCENARIO_MATRIX` and added `REGISTRATION_SCENARIO_MATRIX` covering
self-registration, prepared account claim, privileged role approval gates,
eID-backed assurance, family invite, tenant admin invite, group access, and
denied cross-tenant claim.
- Added `tests/test_registration_security_conformance.py` for a full local
registration -> prepared claim -> active hat -> claims projection ->
identity context -> access fact export -> onboarding -> UI diagnostics path.
- Added security negative-path tests for weak factor requirements, duplicate
identity links, prepared-account hijack attempts, expired claims,
cross-tenant/missing tenant context, privileged prepared-role approval, and
stale approval through approval-required access profiles.
- Added redaction and diagnostics checks for factor values, prepared-account
email metadata, sensitive profile values, access-profile claims/defaults,
and proofing adapter secrets.
- Added adapter conformance coverage for factor verification normalization,
authorization harness capture, access fact export, onboarding handoff/resume,
audit availability, outbox replay, and local durable-store behavior.
- Extended UI workflow coverage from USER-WP-0014 through the conformance
path and documented the local conformance contract in
`docs/registration-scenario-and-security-conformance.md`.
Verification:
```text
make test
Ran 75 tests in 1.506s
OK
```

View File

@@ -0,0 +1,127 @@
---
id: USER-WP-0016
type: workplan
title: "Durable Store Migration And Conformance Harness"
domain: communication
repo: user-engine
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: medium
planning_order: 16
created: "2026-06-16"
updated: "2026-06-16"
depends_on:
- USER-WP-0009
- USER-WP-0015
state_hub_workstream_id: "d5e04359-3ac2-4993-9e75-f5a282ef9c80"
---
# USER-WP-0016 - Durable Store Migration And Conformance Harness
## Goal
Turn the durable-store follow-up from USER-WP-0009 into an executable adapter
contract: ordered migrations, a Postgres bootstrap schema, and reusable
conformance checks that future database-backed stores can run against the same
behavior as the in-memory reference adapter.
## Scope Direction
This slice should stay inside user-engine and avoid production infrastructure
dependencies. It should define what a provider-backed adapter must satisfy
without requiring a live Postgres service in the local unit test suite.
## Non-Goals
- Do not add a production Postgres driver or connection lifecycle.
- Do not own platform provisioning, credentials, backups, restores, or
provider observability.
- Do not require Docker, a cloud database, or network access for the standard
test suite.
## Tasks
```task
id: USER-WP-0016-T1
status: done
priority: high
state_hub_task_id: "fdb1bf47-17e6-4bdb-b822-1713217b81cf"
```
Define an ordered migration manifest with the latest schema version and the
logical store record types covered by user-engine.
```task
id: USER-WP-0016-T2
status: done
priority: high
state_hub_task_id: "e2cd55ee-0779-4df1-80e7-62070c134a1b"
```
Add a Postgres bootstrap SQL file that provider repositories can apply or
translate when implementing the store boundary.
```task
id: USER-WP-0016-T3
status: done
priority: high
state_hub_task_id: "da8e9530-2a30-4c79-9a65-6f00a184b69d"
```
Add reusable store conformance helpers covering readiness, idempotent
migration, core save/read methods, tenant and user queries, transaction
rollback, outbox ordering, and redacted diagnostics.
```task
id: USER-WP-0016-T4
status: done
priority: medium
state_hub_task_id: "b4bee55f-3e65-4c27-b2f0-accad0899d13"
```
Run the conformance helpers against `InMemoryUserEngineStore` as the reference
implementation.
```task
id: USER-WP-0016-T5
status: done
priority: medium
state_hub_task_id: "382ff39a-e081-49ea-9039-5dddfa03c587"
```
Document how future Postgres/provider adapters should consume the manifest,
SQL bootstrap file, and conformance harness.
## Acceptance Criteria
- `LATEST_SCHEMA_VERSION` and the local adapter schema version come from the
same manifest.
- The Postgres bootstrap file contains durable tables for schema versions,
logical records, audit records, and outbox events.
- A future adapter can import one conformance helper and run it with its own
store factory.
- Standard local tests prove the harness against the in-memory store.
- Diagnostics expose counts only and do not leak raw factor or profile values.
## Expected Outputs
- `user_engine.migrations` manifest.
- `migrations/postgres/0001_user_engine_store.sql`.
- `user_engine.testing.store_conformance` helper.
- Store conformance tests.
- Updated durable-store documentation.
## Implementation Notes
Implemented on 2026-06-16:
- Added an ordered migration manifest with logical record and diagnostic count
keys.
- Added a provider-facing Postgres bootstrap SQL file for the generic record,
audit, and outbox storage contract.
- Added reusable store conformance helpers and reference tests for the
in-memory adapter.
- Aligned local schema readiness with `LATEST_SCHEMA_VERSION`.
- Documented the harness in the durable-store consumer requirements and
contracts docs.

View File

@@ -0,0 +1,116 @@
---
id: USER-WP-0017
type: workplan
title: "Durable Store Record Serialization"
domain: communication
repo: user-engine
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: medium
planning_order: 17
created: "2026-06-16"
updated: "2026-06-16"
depends_on:
- USER-WP-0016
state_hub_workstream_id: "bc35882d-5caa-4c2a-9b63-31b0b1c81486"
---
# USER-WP-0017 - Durable Store Record Serialization
## Goal
Define a dependency-free serialization contract for the generic durable store
record shape introduced by USER-WP-0016 so a future Postgres adapter can persist
and restore domain dataclasses through JSONB without embedding ad hoc codecs.
## Scope Direction
This workplan should cover deterministic record keys, adapter metadata columns,
JSON-safe payload encoding, and round-trip decoding for every logical record
type in the migration manifest.
## Non-Goals
- Do not add a production Postgres driver.
- Do not implement connection pooling, migrations, locks, or outbox claiming.
- Do not redact durable payloads; adapters must avoid logging raw payloads.
## Tasks
```task
id: USER-WP-0017-T1
status: done
priority: high
state_hub_task_id: "198b3e06-7093-410a-8538-54628e70dfa5"
```
Add a store-record envelope matching the generic Postgres bootstrap table
columns.
```task
id: USER-WP-0017-T2
status: done
priority: high
state_hub_task_id: "d18b5db1-b4c5-4e23-aa12-335cacfa5eb2"
```
Add deterministic record-key and metadata extraction rules for all manifest
record types.
```task
id: USER-WP-0017-T3
status: done
priority: high
state_hub_task_id: "ca1757e6-52c3-44e4-9523-d01c8f1dcc6b"
```
Add JSON-safe payload encoding for dataclasses, enums, datetimes, tuples, and
mapping fields.
```task
id: USER-WP-0017-T4
status: done
priority: high
state_hub_task_id: "33f555f0-2ab3-4f93-ae5a-28b70814bb57"
```
Add payload decoding back into the original domain dataclasses.
```task
id: USER-WP-0017-T5
status: done
priority: medium
state_hub_task_id: "93c5bd34-f935-4cc9-ab6c-a0a37865592f"
```
Document how future Postgres adapters should use the serialization contract.
## Acceptance Criteria
- Every logical record type in the migration manifest has a codec.
- Encoded payloads can be passed through `json.dumps`.
- Domain records round-trip through `StoreRecord` without losing enum,
datetime, tuple, or nested dataclass structure.
- Composite keys are deterministic and collision-resistant for scoped records.
- Documentation warns that durable payloads may contain sensitive values and
must not be used as diagnostics output.
## Expected Outputs
- `user_engine.store_records` module.
- Store-record serialization tests.
- Durable-store documentation updates.
## Implementation Notes
Implemented on 2026-06-16:
- Added `StoreRecord`, `store_record_for`, `domain_record_from_store_record`,
and manifest validation helpers.
- Added JSON-safe recursive encode/decode support for all current domain
dataclasses used by `UserEngineStore`.
- Added round-trip tests using the same reference records as the conformance
harness.
- Documented the serialization layer as the provider-neutral prerequisite to a
live Postgres adapter.

View File

@@ -0,0 +1,113 @@
---
id: USER-WP-0018
type: workplan
title: "Postgres Store Adapter"
domain: communication
repo: user-engine
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: medium
planning_order: 18
created: "2026-06-16"
updated: "2026-06-16"
depends_on:
- USER-WP-0016
- USER-WP-0017
state_hub_workstream_id: "192a3a0c-81af-4004-a466-bf670fa99212"
---
# USER-WP-0018 - Postgres Store Adapter
## Goal
Add a dependency-free Postgres store adapter behind `UserEngineStore` that uses
the migration and serialization contracts from USER-WP-0016 and USER-WP-0017.
## Scope Direction
The adapter should accept a provider-supplied DB-API or psycopg-like connection
and avoid owning credentials, pooling, deployment, backups, or platform
observability.
## Non-Goals
- Do not vendor or require a Postgres driver in the core package.
- Do not add Docker or live database requirements to the unit test suite.
- Do not implement outbox claim/ack/retry or provider restore validation yet.
## Tasks
```task
id: USER-WP-0018-T1
status: done
priority: high
state_hub_task_id: "b1beac60-bc8f-4d00-9387-aecb5c721755"
```
Add a Postgres adapter that implements the `UserEngineStore` protocol using the
generic record, audit, and outbox tables.
```task
id: USER-WP-0018-T2
status: done
priority: high
state_hub_task_id: "794796a0-0851-44c1-a27a-18e037295cb7"
```
Wire the adapter to `StoreRecord` serialization and deterministic record keys.
```task
id: USER-WP-0018-T3
status: done
priority: high
state_hub_task_id: "160909a7-f626-4afc-97e2-151eedc4f255"
```
Support schema readiness, migration execution, transactions, audit reads,
pending outbox reads, and adapter-neutral record counts.
```task
id: USER-WP-0018-T4
status: done
priority: medium
state_hub_task_id: "4eeb3043-28a3-46d0-b7da-563686e9e28b"
```
Add a fake Postgres connection that runs the shared conformance harness without
requiring production infrastructure.
```task
id: USER-WP-0018-T5
status: done
priority: medium
state_hub_task_id: "58a6d45d-7f27-477e-9ddf-0d734086db21"
```
Document the provider boundary and remaining provider-backed conformance work.
## Acceptance Criteria
- The adapter has no hard runtime dependency on a specific Postgres driver.
- The adapter passes the same store conformance harness as the in-memory store.
- Migration readiness uses the shared latest schema version.
- Record counts stay redacted and adapter-neutral.
- Docs explain that provider repositories still own live-driver, lock, restore,
and outbox claiming validation.
## Expected Outputs
- `user_engine.adapters.postgres.PostgresUserEngineStore`.
- Fake Postgres adapter tests.
- Durable-store documentation updates.
## Implementation Notes
Implemented on 2026-06-16:
- Added `PostgresUserEngineStore` using provider-supplied DB-API/psycopg-like
connections.
- Reused `StoreRecord` serialization for all generic record writes and reads.
- Added transaction, migration, readiness, audit, outbox, and record count
support.
- Added fake connection tests that run the shared store conformance harness.

View File

@@ -0,0 +1,113 @@
---
id: USER-WP-0019
type: workplan
title: "Provider Backed Postgres Conformance"
domain: communication
repo: user-engine
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: medium
planning_order: 19
created: "2026-06-16"
updated: "2026-06-16"
depends_on:
- USER-WP-0018
state_hub_workstream_id: "40dbb193-1cbc-49b2-a08e-044f504c25e6"
---
# USER-WP-0019 - Provider Backed Postgres Conformance
## Goal
Add opt-in live Postgres conformance tests for `PostgresUserEngineStore` so
provider repositories can prove the adapter against a real database without
making ordinary user-engine tests require infrastructure.
## Scope Direction
The suite should be skipped unless an explicit test DSN and destructive reset
acknowledgement are supplied. It should cover migration readiness, the shared
store conformance harness, uniqueness/upsert behavior, rollback semantics, and
record-count diagnostics against a real provider connection.
## Non-Goals
- Do not add a mandatory Postgres driver dependency.
- Do not run live database tests by default.
- Do not implement outbox claim/ack/retry or restore validation yet.
## Tasks
```task
id: USER-WP-0019-T1
status: done
priority: high
state_hub_task_id: "425f0c51-4333-4f63-8397-ddc0bfe8a428"
```
Add env-gated live Postgres connection helpers that support `psycopg` or
`psycopg2` when installed.
```task
id: USER-WP-0019-T2
status: done
priority: high
state_hub_task_id: "f180d009-097d-45b9-aa1f-586f219f7916"
```
Require an explicit destructive reset acknowledgement before cleaning
`user_engine_*` provider test tables.
```task
id: USER-WP-0019-T3
status: done
priority: high
state_hub_task_id: "e2bbf3bd-04a2-4344-a252-330f2a190a0c"
```
Run the shared store conformance harness against a live provider connection
when configured.
```task
id: USER-WP-0019-T4
status: done
priority: medium
state_hub_task_id: "f3b781d0-3a46-4bab-a2c0-f4eb14d70ca9"
```
Add live checks for migration readiness and deterministic upsert uniqueness.
```task
id: USER-WP-0019-T5
status: done
priority: medium
state_hub_task_id: "33591917-3099-415f-8d9b-70c5273cf3b3"
```
Document provider setup, skip behavior, and remaining live conformance gaps.
## Acceptance Criteria
- Standard `make test` skips live Postgres tests unless env vars are present.
- Live tests fail closed if a DSN is supplied without reset acknowledgement.
- Provider cleanup only touches `user_engine_*` tables created by the bootstrap.
- The live suite can use either `psycopg` or `psycopg2` if available.
- Documentation names the required env vars and remaining follow-up work.
## Expected Outputs
- `user_engine.testing.postgres_provider` helper.
- Env-gated provider-backed tests.
- Durable-store documentation updates.
## Implementation Notes
Implemented on 2026-06-16:
- Added optional provider connection and reset helpers for live Postgres tests.
- Added env-gated tests for migration readiness, shared conformance, and
deterministic upsert uniqueness.
- Hardened adapter readiness before migration when provider tables do not yet
exist.
- Kept the default unit suite dependency-free and infrastructure-free.