generated from coulomb/repo-seed
Compare commits
23 Commits
475016b883
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c6ad74f6b | |||
| 3544a1b9d6 | |||
| d13bc3ad8a | |||
| a1692c62e3 | |||
| 1f2ac6666f | |||
| 0d50ad294d | |||
| c494511a2e | |||
| 6810d9a3aa | |||
| abb3c5bd34 | |||
| 32cf819305 | |||
| d6e23aadda | |||
| 886874d0f6 | |||
| 2ceecf6463 | |||
| aaefa48212 | |||
| 5d7685dc8d | |||
| 660ce24995 | |||
| 97cd03b551 | |||
| a36a25898e | |||
| 2c94b40fc4 | |||
| 05596146c8 | |||
| 3cbe281335 | |||
| 531c2193a4 | |||
| af6d82038e |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal 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.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal 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
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal 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`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal 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 1–3 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 -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal 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/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal 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
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal 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.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal 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)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal 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
16
.repo-classification.yaml
Normal 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
219
AGENTS.md
Normal 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
12
CLAUDE.md
Normal 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
|
||||
10
README.md
10
README.md
@@ -8,7 +8,15 @@ 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/examples.md`, `docs/scenarios.md`,
|
||||
`docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`,
|
||||
`docs/netkingdom-registration-onboarding-vision.md`,
|
||||
`docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.md`,
|
||||
`docs/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
|
||||
|
||||
14
SCOPE.md
14
SCOPE.md
@@ -42,7 +42,8 @@ application catalogs, projections, evidence references, audit, and events.
|
||||
- policy, control, access-review, exception, and organization source-of-truth
|
||||
ownership;
|
||||
- runtime secret custody;
|
||||
- UI implementation;
|
||||
- UI implementation in the current MVP; optional registration and access
|
||||
management UI work is proposed separately under `USER-WP-0014`;
|
||||
- full SCIM server or enterprise directory replacement in the initial product.
|
||||
|
||||
## Boundary Rule
|
||||
@@ -56,5 +57,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.
|
||||
|
||||
@@ -6,19 +6,144 @@
|
||||
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`
|
||||
- `register_application`, `publish_catalog`
|
||||
- `set_profile_value`, `effective_profile`, `projection`, `identity_context`
|
||||
- `onboard_family_dataspace`, `invite_family_member`,
|
||||
`resend_family_invitation`, `revoke_family_invitation`,
|
||||
`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:
|
||||
@@ -36,6 +161,24 @@ policy, control, access-review, exception, and lifecycle task references belong
|
||||
to adapter contracts and remain non-owned unless a later workplan assigns
|
||||
source-of-truth responsibility to user-engine.
|
||||
|
||||
## Family Dataspace Onboarding Contract
|
||||
|
||||
`onboard_family_dataspace` is a convenience facade for personal-family
|
||||
identity-domain setup. It composes existing user, account, tenant-account,
|
||||
membership, application, catalog, profile, audit, outbox, projection, and
|
||||
identity-context operations.
|
||||
|
||||
The facade represents a family as a NetKingdom tenant plus a `family` scope. It
|
||||
does not provision the tenant, issue SSO tokens, own credentials, or implement
|
||||
the protected dataspace runtime. Family roles are scoped membership facts such
|
||||
as `owner`, `adult`, `child`, `guest`, and `delegated-caretaker`; authorization
|
||||
systems decide how those facts affect access.
|
||||
|
||||
Invitation acceptance requires already-verified claims. user-engine stores
|
||||
local invitation lifecycle, links the verified external identity, activates
|
||||
account state, and returns both `identity_context` and a
|
||||
`CLAIMS_ENRICHMENT` projection for SSO adapters.
|
||||
|
||||
## Error Taxonomy
|
||||
|
||||
- `ValidationError`: caller supplied an invalid shape, state transition, or
|
||||
@@ -68,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -66,3 +66,40 @@ operation. Outbox consumers should treat `event_id` as the delivery id and
|
||||
for event in service.outbox_events():
|
||||
print(event.event_type, event.aggregate_id, event.correlation_id)
|
||||
```
|
||||
|
||||
## Onboard A Family Dataspace
|
||||
|
||||
```python
|
||||
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
|
||||
|
||||
owner = service.me(owner_claims, correlation_id="corr-owner")
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
owner.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
accepted = service.accept_family_invitation(
|
||||
child_claims,
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-child-accept",
|
||||
)
|
||||
```
|
||||
|
||||
`accepted.identity_context` is the canon-facing context for the SSO adapter.
|
||||
`accepted.claims_projection` is the application-visible profile projection for
|
||||
the personal dataspace.
|
||||
|
||||
120
docs/family-dataspace-onboarding.md
Normal file
120
docs/family-dataspace-onboarding.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Family Dataspace Onboarding
|
||||
|
||||
Status: implemented MVP facade
|
||||
Date: 2026-06-05
|
||||
Related workplan: USER-WP-0008
|
||||
|
||||
## Purpose
|
||||
|
||||
Family dataspace onboarding is the first concrete convenience use case for
|
||||
`user-engine` as a NetKingdom identity-domain integration layer. It lets a
|
||||
consumer represent a family as a tenant-scoped identity context, invite family
|
||||
members, bind a personal dataspace application, and produce SSO-ready identity
|
||||
context without making callers sequence low-level user, profile, membership,
|
||||
application, audit, and projection operations themselves.
|
||||
|
||||
## Model
|
||||
|
||||
| Use-case concept | user-engine representation | Source of truth |
|
||||
| --- | --- | --- |
|
||||
| Family | NetKingdom tenant plus `family` membership scope | NetKingdom tenant/organization infrastructure |
|
||||
| Family owner | `User`, `Account`, active `TenantAccount`, `family:owner` membership | user-engine for local facts |
|
||||
| Family member | invited `User`, `Account`, `TenantAccount`, `FamilyInvitation` | user-engine for local lifecycle |
|
||||
| SSO identity | linked `ExternalIdentity` from verified `(issuer, subject)` | NetKingdom IAM for authentication |
|
||||
| Family role | scoped `Membership.kind` such as `owner`, `adult`, `child`, `guest` | user-engine fact, authorization consumes it |
|
||||
| Personal dataspace | registered `Application` with `ApplicationBinding` | user-engine binding, external runtime owns app |
|
||||
| SSO claims input | `identity_context` plus `CLAIMS_ENRICHMENT` projection | user-engine read model, NetKingdom IAM consumes it |
|
||||
|
||||
## Public Flow
|
||||
|
||||
1. Resolve the owner through `me(...)` or pass an already-normalized actor.
|
||||
2. Call `onboard_family_dataspace(...)` with a `FamilyDataspaceRequest`.
|
||||
3. user-engine ensures the owner exists, registers the dataspace application,
|
||||
publishes a minimal dataspace catalog, assigns owner membership, creates
|
||||
pending member invitations, and returns identity context plus a
|
||||
claims-enrichment projection for SSO.
|
||||
4. Invited members accept through `accept_family_invitation(...)` using
|
||||
verified NetKingdom claims. user-engine links the external identity,
|
||||
activates account state, records audit/outbox events, and returns SSO-ready
|
||||
context for the member.
|
||||
5. Pending invitations can be resent or revoked through
|
||||
`resend_family_invitation(...)` and `revoke_family_invitation(...)`.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
|
||||
|
||||
owner = service.me(owner_claims, correlation_id="corr-owner")
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
owner.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
member = service.accept_family_invitation(
|
||||
member_claims,
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-member-accept",
|
||||
)
|
||||
```
|
||||
|
||||
`onboarding.identity_context` and `member.identity_context` contain the
|
||||
canon-facing actor, user, account, authenticated subject, authorization
|
||||
principal, tenant, family group, membership, grant-like, and evidence
|
||||
references. `claims_projection` contains application-visible profile values
|
||||
such as the family display name and member display name.
|
||||
|
||||
## Boundary
|
||||
|
||||
user-engine does not issue tokens, manage credentials, run MFA, provision the
|
||||
family tenant, or implement the personal dataspace runtime. Those remain
|
||||
NetKingdom IAM, tenant, security, and application responsibilities.
|
||||
|
||||
Family roles are exported as scoped membership facts. The authorization port
|
||||
decides whether those facts allow an action.
|
||||
|
||||
Invitation tokens and proofing are deliberately adapter-owned. The MVP
|
||||
invitation record tracks local lifecycle state and assumes NetKingdom IAM has
|
||||
already verified claims before acceptance.
|
||||
|
||||
## Audit And Events
|
||||
|
||||
The facade emits high-level events in addition to the lower-level events from
|
||||
the operations it composes:
|
||||
|
||||
- `family_dataspace.onboarded`
|
||||
- `family_member.invited`
|
||||
- `family_invitation.resent`
|
||||
- `family_invitation.revoked`
|
||||
- `family_invitation.accepted`
|
||||
|
||||
Lower-level events such as `user.created`, `tenant_account.status_changed`,
|
||||
`membership.added`, `identity.linked`, `application.registered`,
|
||||
`catalog.published`, and `profile.value_set` remain visible for replay and
|
||||
traceability.
|
||||
|
||||
## Current MVP Limits
|
||||
|
||||
- Invitations are stored in the current store boundary and need durable-store
|
||||
backing before production use.
|
||||
- Invitation delivery, one-time token material, and proofing are external
|
||||
adapter responsibilities.
|
||||
- Membership revocation and historical role lifecycle are not yet fully
|
||||
modeled beyond invitation revoke and account status changes.
|
||||
- The default dataspace catalog is intentionally minimal and should evolve with
|
||||
real dataspace claims requirements.
|
||||
106
docs/hats-realms-services-assets-access-profiles.md
Normal file
106
docs/hats-realms-services-assets-access-profiles.md
Normal 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.
|
||||
263
docs/netkingdom-registration-onboarding-vision.md
Normal file
263
docs/netkingdom-registration-onboarding-vision.md
Normal 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.
|
||||
91
docs/onboarding-journeys-and-welcome-protocols.md
Normal file
91
docs/onboarding-journeys-and-welcome-protocols.md
Normal 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.
|
||||
322
docs/postgres-durable-store-consumer-requirements.md
Normal file
322
docs/postgres-durable-store-consumer-requirements.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Postgres Durable Store Consumer Requirements
|
||||
|
||||
Status: requirements + store contract boundary
|
||||
Date: 2026-06-15
|
||||
Related workplan: USER-WP-0009
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines what `user-engine` needs from a durable Postgres-backed
|
||||
store, from the consumer side. It intentionally does not design or implement
|
||||
the Postgres provider. The expected direction is that an independent
|
||||
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
|
||||
facts durably while keeping NetKingdom security, IAM, secrets, network, tenant
|
||||
isolation, backup, and operational controls outside the user-engine domain
|
||||
implementation.
|
||||
|
||||
The desired experience is:
|
||||
|
||||
```text
|
||||
NetKingdom gives user-engine a scoped Postgres capability.
|
||||
user-engine applies or verifies its own schema for its own tables.
|
||||
service operations keep the same behavior as the isolated MVP.
|
||||
mutations, audit records, and outbox events commit atomically.
|
||||
tenant boundaries and security controls are enforced by both adapter logic and
|
||||
the provided database capability.
|
||||
```
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
### NetKingdom Postgres Provider Owns
|
||||
|
||||
- Database or cluster provisioning.
|
||||
- Tenant isolation primitive, such as database-per-tenant, schema-per-tenant,
|
||||
row-level security, or another accepted model.
|
||||
- Roles, credentials, certificate material, TLS requirements, secret rotation,
|
||||
and credential lease policy.
|
||||
- Network reachability, firewall rules, service identity admission, and
|
||||
runtime policy integration.
|
||||
- Backup, restore, PITR, replication, retention, and disaster recovery
|
||||
controls.
|
||||
- Platform-level metrics, logs, traces, alert routing, and operational
|
||||
runbooks for the database capability.
|
||||
- Base security posture, hardening, encryption at rest, and administrative
|
||||
access controls.
|
||||
|
||||
### user-engine Owns
|
||||
|
||||
- Domain table definitions for its own data.
|
||||
- Schema version expectations and forward migrations for user-engine tables.
|
||||
- Store adapter behavior that satisfies the public service contract.
|
||||
- Transaction boundaries for user-engine mutations.
|
||||
- Domain constraints, validation, and deterministic query behavior.
|
||||
- Local audit and outbox table semantics when those records are persisted in
|
||||
the user-engine store.
|
||||
- Store conformance tests.
|
||||
|
||||
### External Systems Continue To Own
|
||||
|
||||
- Identity provider configuration, token issuance, credentials, MFA, and
|
||||
sessions.
|
||||
- Authorization policy decisions.
|
||||
- Platform audit custody and long-term audit archive.
|
||||
- Secrets authority and secret distribution.
|
||||
- Organization or tenant authority beyond user-engine references.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### Store Parity
|
||||
|
||||
The durable store must satisfy the same behavior currently exercised against
|
||||
the isolated store:
|
||||
|
||||
- Persist users, accounts, tenant accounts, external identities, applications,
|
||||
application bindings, catalogs, profile values, memberships, audit records,
|
||||
and outbox events.
|
||||
- Return stable records by the same logical keys used by
|
||||
`UserEngineService`.
|
||||
- Preserve `schema_version`, `ready`, and migration readiness semantics.
|
||||
- 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.
|
||||
- An external identity must not be linked to two different users.
|
||||
- A user must have at most one primary account record in the current model.
|
||||
- Tenant account records must be unique by `(tenant, user_id)`.
|
||||
- Deleted or disabled account states must remain inspectable for audit and
|
||||
lifecycle decisions.
|
||||
|
||||
### Tenant Boundary Requirements
|
||||
|
||||
- Every tenant-scoped row must carry an explicit tenant identifier or be
|
||||
reachable only through an explicit tenant-scoped relationship.
|
||||
- Queries that resolve tenant-scoped data must require tenant context from the
|
||||
service layer.
|
||||
- The adapter must fail closed when tenant context is missing for tenant-bound
|
||||
operations.
|
||||
- The provider should make tenant isolation enforceable below application code,
|
||||
for example through separate databases, schemas, RLS policies, or scoped
|
||||
database roles.
|
||||
- Platform-level access must be represented as an explicit NetKingdom security
|
||||
capability, not as a default database superuser path.
|
||||
|
||||
### Application And Catalog Requirements
|
||||
|
||||
- Application ids must be unique.
|
||||
- Application bindings must be retrievable by application id.
|
||||
- Active catalog namespace ownership must not move silently between
|
||||
applications.
|
||||
- Catalog versioning must preserve the existing rule that active definitions
|
||||
cannot downgrade sensitivity or move versions backwards.
|
||||
- Attribute lookup by key must remain deterministic and efficient enough for
|
||||
projection generation.
|
||||
|
||||
### Profile And Projection Requirements
|
||||
|
||||
- Profile values must be unique by user, attribute key, scope, and scope id.
|
||||
- Effective profile resolution must remain deterministic across global,
|
||||
tenant, application, and membership scopes.
|
||||
- Sensitive and secret values must not leak through diagnostics or logs.
|
||||
- Projection reads should avoid N+1 query patterns for common application
|
||||
runtime and claims-enrichment use cases.
|
||||
|
||||
### Membership Requirements
|
||||
|
||||
- Memberships must be queryable by user and tenant.
|
||||
- Membership facts must carry scope type, scope id, kind, source system,
|
||||
owning system, and freshness version.
|
||||
- Privileged memberships should remain traceable to audit records, evidence
|
||||
references, or explicit evidence gaps.
|
||||
- The store must support future revoke/update behavior without losing the
|
||||
ability to inspect historical role changes.
|
||||
|
||||
### Audit And Outbox Requirements
|
||||
|
||||
- Mutating service operations must commit domain changes, local audit records,
|
||||
and outbox events atomically.
|
||||
- Authorization denials must be auditable without emitting outbox events.
|
||||
- Audit records should be append-only from the service perspective.
|
||||
- Outbox records must support pending reads and future claim/ack/retry
|
||||
semantics.
|
||||
- Outbox event ids must be stable delivery ids, and correlation ids must remain
|
||||
queryable for cross-system tracing.
|
||||
|
||||
### Transaction Requirements
|
||||
|
||||
- Each public mutating service operation must run in one transaction.
|
||||
- Failed validation or authorization must not partially write domain state.
|
||||
- Store implementation must handle uniqueness races deterministically and map
|
||||
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
|
||||
|
||||
- user-engine owns migrations for its own tables.
|
||||
- Migrations must be forward-only unless an explicit rollback strategy is
|
||||
accepted for a release.
|
||||
- Readiness must report the expected schema version and actual store version.
|
||||
- Startup behavior must distinguish "store unreachable", "migration required",
|
||||
"migration in progress", and "ready".
|
||||
- Destructive migrations require an explicit operator-controlled process.
|
||||
- The provider may supply the database and schema container, but should not
|
||||
need to know user-engine domain tables.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- Database credentials must come through a NetKingdom secret or identity
|
||||
mechanism, not literal config files.
|
||||
- Connections must require TLS when crossing process or host boundaries.
|
||||
- Credentials should be scoped to the minimum database, schema, tenant, and
|
||||
operations needed by user-engine.
|
||||
- Logs, errors, readiness output, and diagnostics must not expose credentials,
|
||||
connection strings, secret values, sensitive profile data, or full personal
|
||||
records.
|
||||
- The adapter must make tenant context explicit and auditable.
|
||||
- The provider should expose enough security metadata for `identity_context`
|
||||
evidence or gap references when privileged access or lifecycle work depends
|
||||
on database-side controls.
|
||||
|
||||
## Operability Requirements
|
||||
|
||||
- Health checks should report whether the adapter can reach the store.
|
||||
- Readiness checks should report schema compatibility and migration state.
|
||||
- Diagnostics should include redacted connection target, schema version, last
|
||||
migration, pending outbox count, and recent store error class.
|
||||
- Metrics should cover connection failures, transaction failures, conflicts,
|
||||
migration duration, query latency, and outbox backlog.
|
||||
- Backup/restore expectations must be testable from the consumer side through
|
||||
restore validation or equivalent provider evidence.
|
||||
- Store failures should produce actionable errors without leaking sensitive
|
||||
details.
|
||||
|
||||
## Provider Interface Expectations
|
||||
|
||||
The future provider repository should be able to give user-engine:
|
||||
|
||||
- A logical store reference for the NetKingdom environment and tenant scope.
|
||||
- A secret handle or service identity mechanism for credentials.
|
||||
- TLS or certificate requirements.
|
||||
- Tenant isolation metadata that the adapter can record in diagnostics.
|
||||
- Migration permission policy for user-engine-owned tables.
|
||||
- Backup and restore evidence or status references.
|
||||
- Operational contact/runbook references.
|
||||
|
||||
`user-engine` should not require:
|
||||
|
||||
- Cluster administrator credentials.
|
||||
- Knowledge of physical cluster topology.
|
||||
- Direct control over backups, replication, firewall rules, or secret
|
||||
rotation.
|
||||
- Provider-specific SQL outside an adapter layer.
|
||||
|
||||
## Acceptance Tests For A Future Adapter
|
||||
|
||||
A future Postgres adapter should pass conformance tests for:
|
||||
|
||||
- Creating a user from verified identity claims and reading it through `me`.
|
||||
- Preventing duplicate `(issuer, subject)` links across users.
|
||||
- Creating tenant accounts and denying cross-tenant reads through the service
|
||||
layer.
|
||||
- Adding memberships and returning them in `identity_context`.
|
||||
- Registering an application, publishing a catalog, writing profile values,
|
||||
and producing application runtime and claims-enrichment projections.
|
||||
- Redacting sensitive values in non-eligible projections.
|
||||
- Rolling back all writes when a mutation fails after validation or
|
||||
authorization.
|
||||
- Persisting audit records and outbox events atomically with mutations.
|
||||
- Reporting not-ready state when schema version is missing or incompatible.
|
||||
- Recovering from restart without losing users, memberships, profiles, audit,
|
||||
or outbox records.
|
||||
|
||||
## Open Questions For The Provider Repository
|
||||
|
||||
- Should NetKingdom use database-per-tenant, schema-per-tenant, RLS, or a
|
||||
hybrid model for user-engine data?
|
||||
- Who runs user-engine migrations in production: user-engine startup, a
|
||||
deployment job, or a provider-controlled migration runner?
|
||||
- How are credential leases issued, renewed, revoked, and audited?
|
||||
- What backup unit maps to a family or organization dataspace: cluster,
|
||||
database, schema, tenant row set, or application scope?
|
||||
- What evidence references can the provider expose for backup status, restore
|
||||
tests, encryption posture, and access reviews?
|
||||
- How should local development emulate the provider without weakening the
|
||||
production contract?
|
||||
|
||||
## First Implementation Follow-Ups
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
118
docs/prepared-accounts-and-entitlement-claims.md
Normal file
118
docs/prepared-accounts-and-entitlement-claims.md
Normal 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.
|
||||
128
docs/registration-and-access-management-ui.md
Normal file
128
docs/registration-and-access-management-ui.md
Normal 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.
|
||||
120
docs/registration-identity-and-factor-model.md
Normal file
120
docs/registration-identity-and-factor-model.md
Normal 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.
|
||||
108
docs/registration-scenario-and-security-conformance.md
Normal file
108
docs/registration-scenario-and-security-conformance.md
Normal 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.
|
||||
@@ -15,6 +15,28 @@ projection, audit, and event behavior testable without a UI.
|
||||
| sensitive_redaction | Sensitive values are redacted in runtime and claims-enrichment projections. |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
78
migrations/postgres/0001_user_engine_store.sql
Normal file
78
migrations/postgres/0001_user_engine_store.sql
Normal 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
12
registry/README.md
Normal 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`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
updated: '2026-06-16'
|
||||
domain: helix_forge
|
||||
capabilities: []
|
||||
@@ -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__",
|
||||
]
|
||||
|
||||
@@ -4,8 +4,10 @@ from user_engine.adapters.local import (
|
||||
InMemoryUserEngineStore,
|
||||
LocalAuthorizationCheckPort,
|
||||
)
|
||||
from user_engine.adapters.postgres import PostgresUserEngineStore
|
||||
|
||||
__all__ = [
|
||||
"InMemoryUserEngineStore",
|
||||
"LocalAuthorizationCheckPort",
|
||||
"PostgresUserEngineStore",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
@@ -15,15 +19,22 @@ from user_engine.domain import (
|
||||
AuthorizationRequest,
|
||||
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
|
||||
@@ -44,11 +55,27 @@ class InMemoryUserEngineStore:
|
||||
applications: dict[str, Application] = field(default_factory=dict)
|
||||
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."""
|
||||
@@ -58,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
|
||||
|
||||
@@ -76,12 +130,154 @@ 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
|
||||
|
||||
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
|
||||
return self.family_invitations.get(invitation_id)
|
||||
|
||||
def family_invitations_for_user(self, user_id: str) -> tuple[FamilyInvitation, ...]:
|
||||
return tuple(
|
||||
invitation
|
||||
for invitation in self.family_invitations.values()
|
||||
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)
|
||||
@@ -121,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.
|
||||
|
||||
638
src/user_engine/adapters/postgres.py
Normal file
638
src/user_engine/adapters/postgres.py
Normal 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()
|
||||
)
|
||||
@@ -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,25 +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",
|
||||
@@ -48,18 +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",
|
||||
]
|
||||
|
||||
@@ -45,6 +45,88 @@ class ManagementMode(StrEnum):
|
||||
SERVICE_MANAGED = "service_managed"
|
||||
|
||||
|
||||
class FamilyRole(StrEnum):
|
||||
OWNER = "owner"
|
||||
ADULT = "adult"
|
||||
CHILD = "child"
|
||||
GUEST = "guest"
|
||||
DELEGATED_CARETAKER = "delegated-caretaker"
|
||||
|
||||
|
||||
class InvitationStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
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"
|
||||
@@ -252,6 +334,300 @@ 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
|
||||
role: FamilyRole | str = FamilyRole.ADULT
|
||||
display_name: str | None = None
|
||||
issuer: str | None = None
|
||||
subject: str | None = None
|
||||
provider: str | None = None
|
||||
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyDataspaceRequest:
|
||||
tenant: str
|
||||
family_scope_id: str
|
||||
family_display_name: str
|
||||
application_id: str = "app.personal-dataspace"
|
||||
application_display_name: str = "Personal Dataspace"
|
||||
oidc_client_id: str | None = None
|
||||
protected_system_id: str | None = None
|
||||
catalog_namespace: str = "dataspace"
|
||||
event_source: str | None = None
|
||||
deployment_ref: str | None = None
|
||||
member_specs: tuple[FamilyMemberSpec, ...] = ()
|
||||
owner_profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyInvitation:
|
||||
invitation_id: str
|
||||
tenant: str
|
||||
family_scope_id: str
|
||||
application_id: str
|
||||
user_id: str
|
||||
primary_email: str
|
||||
role: str
|
||||
status: InvitationStatus = InvitationStatus.PENDING
|
||||
invited_by: str | None = None
|
||||
correlation_id: str | None = None
|
||||
resend_count: int = 0
|
||||
last_sent_correlation_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
accepted_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FactorVerification:
|
||||
factor_type: IdentityFactorType
|
||||
normalized_value: str
|
||||
verification_id: str = field(default_factory=lambda: new_id("fvr"))
|
||||
display_value: str | None = None
|
||||
source_system: str = "external-proofing"
|
||||
assurance: Mapping[str, Any] = field(default_factory=dict)
|
||||
evidence_refs: tuple[CanonEntityReference, ...] = ()
|
||||
verified_at: datetime = field(default_factory=utc_now)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityFactor:
|
||||
factor_type: IdentityFactorType
|
||||
normalized_value: str
|
||||
factor_id: str = field(default_factory=lambda: new_id("fac"))
|
||||
registration_id: str | None = None
|
||||
user_id: str | None = None
|
||||
display_value: str | None = None
|
||||
source_system: str = "external-proofing"
|
||||
assurance: Mapping[str, Any] = field(default_factory=dict)
|
||||
evidence_refs: tuple[CanonEntityReference, ...] = ()
|
||||
verified_at: datetime = field(default_factory=utc_now)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationSession:
|
||||
tenant: str
|
||||
registration_id: str = field(default_factory=lambda: new_id("reg"))
|
||||
status: RegistrationStatus = RegistrationStatus.STARTED
|
||||
required_factor_types: tuple[IdentityFactorType, ...] = ()
|
||||
verified_factor_ids: tuple[str, ...] = ()
|
||||
user_id: str | None = None
|
||||
netkingdom_id: str | None = None
|
||||
started_by_subject: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
completed_at: datetime | None = None
|
||||
abandoned_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
rejected_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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
|
||||
|
||||
124
src/user_engine/migrations.py
Normal file
124
src/user_engine/migrations.py
Normal 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)
|
||||
@@ -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
357
src/user_engine/store_records.py
Normal file
357
src/user_engine/store_records.py
Normal 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}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
82
src/user_engine/testing/postgres_provider.py
Normal file
82
src/user_engine/testing/postgres_provider.py
Normal 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()
|
||||
@@ -26,6 +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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
527
src/user_engine/testing/store_conformance.py
Normal file
527
src/user_engine/testing/store_conformance.py
Normal 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
803
src/user_engine/ui.py
Normal 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>"
|
||||
354
tests/test_access_profiles.py
Normal file
354
tests/test_access_profiles.py
Normal 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()
|
||||
42
tests/test_durable_store_conformance.py
Normal file
42
tests/test_durable_store_conformance.py
Normal 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()
|
||||
196
tests/test_family_dataspace_onboarding.py
Normal file
196
tests/test_family_dataspace_onboarding.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import unittest
|
||||
|
||||
from user_engine.adapters.local import InMemoryUserEngineStore
|
||||
from user_engine.domain import (
|
||||
AccountStatus,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
InvitationStatus,
|
||||
)
|
||||
from user_engine.errors import AuthorizationDenied, ValidationError
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.testing.fixtures import human_actor_claims
|
||||
from user_engine.testing.scenarios import (
|
||||
ScenarioAuthorizationHarness,
|
||||
StrictFixtureIdentityClaimsAdapter,
|
||||
)
|
||||
|
||||
|
||||
class FamilyDataspaceOnboardingTests(unittest.TestCase):
|
||||
def test_onboarding_creates_family_scope_dataspace_app_and_invitation(self):
|
||||
service, store, authz = _service()
|
||||
session = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
session.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
self.assertEqual(onboarding.tenant, "tenant:worsch-family")
|
||||
self.assertEqual(onboarding.binding.oidc_client_id, "personal-dataspace-client")
|
||||
self.assertEqual(onboarding.catalog.namespace, "dataspace")
|
||||
self.assertEqual(onboarding.owner_membership.kind, FamilyRole.OWNER.value)
|
||||
self.assertEqual(onboarding.invitations[0].invitation.status, InvitationStatus.PENDING)
|
||||
self.assertEqual(onboarding.invitations[0].tenant_account.status, AccountStatus.INVITED)
|
||||
self.assertEqual(onboarding.identity_context.entity_refs["family:family:worsch"].concept, "Group")
|
||||
self.assertEqual(
|
||||
onboarding.claims_projection.values["dataspace.family_display_name"],
|
||||
"Worsch Family",
|
||||
)
|
||||
self.assertIn("family_dataspace.onboarded", _event_types(service))
|
||||
self.assertIn("family_member.invited", _event_types(service))
|
||||
self.assertIn("family_dataspace.onboard", [request.action for request in authz.requests])
|
||||
self.assertEqual(len(store.family_invitations), 1)
|
||||
|
||||
def test_member_acceptance_links_sso_identity_and_returns_dataspace_context(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
invitation = onboarding.invitations[0].invitation
|
||||
|
||||
acceptance = service.accept_family_invitation(
|
||||
_member_claims(subject="child-sso"),
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-accept",
|
||||
)
|
||||
|
||||
self.assertEqual(acceptance.invitation.status, InvitationStatus.ACCEPTED)
|
||||
self.assertEqual(acceptance.session.user.user_id, invitation.user_id)
|
||||
self.assertEqual(acceptance.session.account.status, AccountStatus.ACTIVE)
|
||||
self.assertEqual(
|
||||
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
|
||||
AccountStatus.ACTIVE,
|
||||
)
|
||||
self.assertEqual(
|
||||
service.store.find_identity(
|
||||
"https://issuer.example.test",
|
||||
"child-sso",
|
||||
).user_id,
|
||||
invitation.user_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
acceptance.claims_projection.values["dataspace.member_display_name"],
|
||||
"Child Member",
|
||||
)
|
||||
self.assertEqual(acceptance.identity_context.memberships[0].kind, FamilyRole.CHILD.value)
|
||||
self.assertIn("family_invitation.accepted", _event_types(service))
|
||||
|
||||
def test_revoked_invitation_cannot_be_accepted(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
invitation = onboarding.invitations[0].invitation
|
||||
|
||||
resent = service.resend_family_invitation(
|
||||
owner.actor,
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-resend",
|
||||
)
|
||||
revoked = service.revoke_family_invitation(
|
||||
owner.actor,
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-revoke",
|
||||
)
|
||||
|
||||
self.assertEqual(resent.resend_count, 1)
|
||||
self.assertEqual(revoked.status, InvitationStatus.REVOKED)
|
||||
self.assertEqual(
|
||||
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
|
||||
AccountStatus.DISABLED,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.accept_family_invitation(
|
||||
_member_claims(subject="revoked-child"),
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-revoked-accept",
|
||||
)
|
||||
|
||||
def test_cross_tenant_invitation_acceptance_is_denied(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
service.accept_family_invitation(
|
||||
_member_claims(subject="wrong-tenant", tenant="tenant:other-family"),
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-wrong-tenant",
|
||||
)
|
||||
|
||||
|
||||
def _service():
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=StrictFixtureIdentityClaimsAdapter(),
|
||||
authorization=ScenarioAuthorizationHarness(),
|
||||
)
|
||||
return service, store, service.authorization
|
||||
|
||||
|
||||
def _onboard_family(service: UserEngineService, actor):
|
||||
return service.onboard_family_dataspace(
|
||||
actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
|
||||
def _owner_claims() -> dict[str, object]:
|
||||
claims = human_actor_claims(
|
||||
subject="family-owner",
|
||||
tenant="tenant:worsch-family",
|
||||
)
|
||||
claims["roles"] = ["tenant-admin"]
|
||||
claims["preferred_username"] = "family.owner"
|
||||
claims["email"] = "owner@example.test"
|
||||
return claims
|
||||
|
||||
|
||||
def _member_claims(
|
||||
*,
|
||||
subject: str,
|
||||
tenant: str = "tenant:worsch-family",
|
||||
) -> dict[str, object]:
|
||||
claims = human_actor_claims(subject=subject, tenant=tenant)
|
||||
claims["preferred_username"] = subject
|
||||
claims["email"] = f"{subject}@example.test"
|
||||
return claims
|
||||
|
||||
|
||||
def _event_types(service: UserEngineService) -> list[str]:
|
||||
return [event.event_type for event in service.outbox_events()]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,6 +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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
361
tests/test_onboarding_journeys.py
Normal file
361
tests/test_onboarding_journeys.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
107
tests/test_postgres_provider_conformance.py
Normal file
107
tests/test_postgres_provider_conformance.py
Normal 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()
|
||||
180
tests/test_postgres_store_adapter.py
Normal file
180
tests/test_postgres_store_adapter.py
Normal 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()
|
||||
420
tests/test_prepared_accounts.py
Normal file
420
tests/test_prepared_accounts.py
Normal 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()
|
||||
286
tests/test_registration_access_ui.py
Normal file
286
tests/test_registration_access_ui.py
Normal 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("<redacted>", 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()
|
||||
175
tests/test_registration_identity.py
Normal file
175
tests/test_registration_identity.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import unittest
|
||||
|
||||
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
|
||||
from user_engine.domain import (
|
||||
CanonEntityReference,
|
||||
FactorVerification,
|
||||
IdentityFactorType,
|
||||
RegistrationStatus,
|
||||
)
|
||||
from user_engine.errors import ValidationError
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
||||
|
||||
|
||||
class RegistrationIdentityTests(unittest.TestCase):
|
||||
def test_registration_with_verified_email_creates_netkingdom_id(self):
|
||||
service, store = _service()
|
||||
actor = _actor()
|
||||
|
||||
session = service.start_registration(actor, correlation_id="corr-start")
|
||||
verified = service.attach_registration_factor(
|
||||
actor,
|
||||
session.registration_id,
|
||||
_verified_email(),
|
||||
correlation_id="corr-factor",
|
||||
)
|
||||
completion = service.complete_registration(
|
||||
actor,
|
||||
session.registration_id,
|
||||
correlation_id="corr-complete",
|
||||
)
|
||||
|
||||
self.assertEqual(verified.status, RegistrationStatus.FACTOR_VERIFIED)
|
||||
self.assertEqual(completion.session.status, RegistrationStatus.COMPLETED)
|
||||
self.assertEqual(completion.netkingdom_id, completion.user.user_id)
|
||||
self.assertEqual(completion.session.netkingdom_id, completion.user.user_id)
|
||||
self.assertEqual(completion.user.primary_email, "sample.user@example.test")
|
||||
self.assertEqual(completion.identity_context.user.user_id, completion.user.user_id)
|
||||
self.assertEqual(store.find_identity(*actor.identity_key).user_id, completion.user.user_id)
|
||||
self.assertEqual(
|
||||
store.factors_for_user(completion.user.user_id)[0].factor_type,
|
||||
IdentityFactorType.EMAIL,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"sample.user@example.test",
|
||||
repr([event.payload for event in service.outbox_events()]),
|
||||
)
|
||||
|
||||
def test_registration_requires_all_required_factors_before_completion(self):
|
||||
service, store = _service()
|
||||
actor = _actor()
|
||||
session = service.start_registration(
|
||||
actor,
|
||||
required_factor_types=(IdentityFactorType.EID,),
|
||||
correlation_id="corr-start",
|
||||
)
|
||||
service.attach_registration_factor(
|
||||
actor,
|
||||
session.registration_id,
|
||||
_verified_email(),
|
||||
correlation_id="corr-factor",
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
service.complete_registration(
|
||||
actor,
|
||||
session.registration_id,
|
||||
correlation_id="corr-complete",
|
||||
)
|
||||
|
||||
self.assertEqual(store.record_counts()["users"], 0)
|
||||
self.assertIsNone(store.registration_session(session.registration_id).user_id)
|
||||
|
||||
def test_factor_verifier_adapter_normalizes_external_proofing_result(self):
|
||||
service, store = _service(factor_verifier=_FixtureFactorVerifier())
|
||||
actor = _actor()
|
||||
session = service.start_registration(actor, correlation_id="corr-start")
|
||||
|
||||
updated = service.attach_registration_factor(
|
||||
actor,
|
||||
session.registration_id,
|
||||
{
|
||||
"type": "email",
|
||||
"value": "Sample.User@Example.Test",
|
||||
"secret_challenge": "do-not-store-this",
|
||||
},
|
||||
correlation_id="corr-factor",
|
||||
)
|
||||
stored = store.factors_for_registration(session.registration_id)[0]
|
||||
|
||||
self.assertEqual(updated.status, RegistrationStatus.FACTOR_VERIFIED)
|
||||
self.assertEqual(stored.normalized_value, "sample.user@example.test")
|
||||
self.assertNotIn("do-not-store-this", repr(stored))
|
||||
self.assertNotIn(
|
||||
"do-not-store-this",
|
||||
repr([event.payload for event in service.outbox_events()]),
|
||||
)
|
||||
|
||||
def test_abandoned_registration_cannot_resume_or_complete(self):
|
||||
service, _ = _service()
|
||||
actor = _actor()
|
||||
session = service.start_registration(actor, correlation_id="corr-start")
|
||||
|
||||
abandoned = service.abandon_registration(
|
||||
actor,
|
||||
session.registration_id,
|
||||
correlation_id="corr-abandon",
|
||||
)
|
||||
diagnostics = service.registration_diagnostics(
|
||||
actor,
|
||||
tenant="tenant:coulomb",
|
||||
correlation_id="corr-diagnostics",
|
||||
)
|
||||
|
||||
self.assertEqual(abandoned.status, RegistrationStatus.ABANDONED)
|
||||
self.assertEqual(diagnostics.statuses[RegistrationStatus.ABANDONED.value], 1)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.resume_registration(
|
||||
actor,
|
||||
session.registration_id,
|
||||
correlation_id="corr-resume",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.complete_registration(
|
||||
actor,
|
||||
session.registration_id,
|
||||
correlation_id="corr-complete",
|
||||
)
|
||||
|
||||
|
||||
def _service(*, factor_verifier=None):
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||
authorization=LocalAuthorizationCheckPort(),
|
||||
factor_verifier=factor_verifier,
|
||||
)
|
||||
return service, store
|
||||
|
||||
|
||||
def _actor():
|
||||
return FixtureIdentityClaimsAdapter().normalize(human_actor_claims())
|
||||
|
||||
|
||||
def _verified_email() -> FactorVerification:
|
||||
return FactorVerification(
|
||||
factor_type=IdentityFactorType.EMAIL,
|
||||
normalized_value="sample.user@example.test",
|
||||
display_value="sample.user@example.test",
|
||||
source_system="fixture-email",
|
||||
assurance={"level": "email_verified"},
|
||||
evidence_refs=(
|
||||
CanonEntityReference(
|
||||
concept="Evidence Source",
|
||||
identifier="fixture-email-proof",
|
||||
source_system="fixture-email",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FixtureFactorVerifier:
|
||||
def normalize(self, proofing_result):
|
||||
return FactorVerification(
|
||||
factor_type=IdentityFactorType(str(proofing_result["type"])),
|
||||
normalized_value=str(proofing_result["value"]).lower(),
|
||||
display_value=str(proofing_result["value"]).lower(),
|
||||
source_system="fixture-proofing",
|
||||
assurance={"level": "email_verified"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
576
tests/test_registration_security_conformance.py
Normal file
576
tests/test_registration_security_conformance.py
Normal 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()
|
||||
93
tests/test_store_records.py
Normal file
93
tests/test_store_records.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
217
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal file
217
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
id: USER-WP-0008
|
||||
type: workplan
|
||||
title: "Family Dataspace Onboarding"
|
||||
domain: communication
|
||||
repo: user-engine
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
planning_order: 8
|
||||
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
|
||||
|
||||
## Goal
|
||||
|
||||
Make `user-engine` convenient for a personal-family use case: represent a
|
||||
family as a NetKingdom identity-domain scope, onboard family members into that
|
||||
scope, register a personal dataspace as a protected application, and provide
|
||||
SSO-ready identity context and profile projections without exposing consumers
|
||||
to IAM, authorization, profile, catalog, audit, or evidence implementation
|
||||
details.
|
||||
|
||||
The intended consumer experience is:
|
||||
|
||||
```text
|
||||
Create a family space, invite members, assign family roles, bind a personal
|
||||
dataspace application, and let NetKingdom SSO receive the claims/profile
|
||||
context it needs.
|
||||
```
|
||||
|
||||
## Scope Direction
|
||||
|
||||
`user-engine` should orchestrate domain-facing setup and read models. It should
|
||||
not provision the NetKingdom tenant, issue credentials, own the identity
|
||||
provider, or become the protected dataspace runtime. The family scope is a
|
||||
tenant or tenant-backed organization reference owned by NetKingdom
|
||||
infrastructure; user-engine manages local users, accounts, identity links,
|
||||
memberships, profile values, application bindings, projections, audit, and
|
||||
canon-facing identity context for that scope.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not issue SSO tokens, sessions, passwords, passkeys, or MFA challenges.
|
||||
- Do not provision the underlying NetKingdom tenant or organization authority.
|
||||
- Do not become the personal dataspace storage/runtime implementation.
|
||||
- Do not implement a production UI as part of the first onboarding slice.
|
||||
- Do not hard-code family relationship policy into authorization decisions;
|
||||
export facts and consume NetKingdom authorization outcomes.
|
||||
- Do not implement the durable Postgres store in this workplan.
|
||||
|
||||
## Tasks
|
||||
|
||||
```task
|
||||
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,
|
||||
family/member scopes, owner, adult, child, guest, delegated caretaker, personal
|
||||
dataspace application, SSO claims enrichment, and identity-canon references.
|
||||
Mark which facts are owned by user-engine and which remain owned by NetKingdom
|
||||
IAM, tenant, policy, audit, or dataspace systems.
|
||||
|
||||
```task
|
||||
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
|
||||
service operations into a convenient use-case API. The facade should accept a
|
||||
NetKingdom-provided family tenant reference, owner actor, dataspace application
|
||||
binding, initial member descriptors, role assignments, and profile defaults.
|
||||
|
||||
```task
|
||||
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,
|
||||
tenant-account lifecycle, invitation status, identity-link acceptance,
|
||||
resend/revoke behavior, and audit/event records. Keep invitation tokens and
|
||||
identity proofing delegated to NetKingdom IAM or a dedicated invite adapter.
|
||||
|
||||
```task
|
||||
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`,
|
||||
bind it to external SSO/protected-system identifiers, and publish a minimal
|
||||
profile catalog for dataspace-specific claims, preferences, and visibility
|
||||
rules.
|
||||
|
||||
```task
|
||||
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,
|
||||
child, guest, and delegated roles as scoped memberships while preserving tenant
|
||||
boundaries and authorization-port decisions for privileged actions.
|
||||
|
||||
```task
|
||||
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`
|
||||
and claims-enrichment projections to provide subject, principal, account,
|
||||
family tenant, role/membership, profile, evidence, and explicit gap references
|
||||
to the NetKingdom SSO adapter.
|
||||
|
||||
```task
|
||||
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
|
||||
family/member/application/profile mutation should produce correlated audit and
|
||||
outbox records, and privileged role grants should be traceable through evidence
|
||||
or explicit evidence-gap references.
|
||||
|
||||
```task
|
||||
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
|
||||
owner setup, member invitation, accepted SSO identity link, child/guest
|
||||
membership, dataspace claims enrichment, tenant isolation, and denied
|
||||
cross-family access.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A consumer can onboard a family dataspace through one headless facade or a
|
||||
small number of purpose-built commands instead of manually sequencing low
|
||||
level service calls.
|
||||
- The family scope is represented as a NetKingdom tenant or tenant-backed
|
||||
organization reference, not as a user-engine-owned organization authority.
|
||||
- Family members have distinct users, accounts, tenant accounts, external
|
||||
identities, and scoped memberships.
|
||||
- The personal dataspace is registered as an application with a binding to
|
||||
SSO/protected-system identifiers and a minimal catalog for dataspace profile
|
||||
values.
|
||||
- NetKingdom SSO can consume claims-enrichment projection or identity-context
|
||||
output without knowing user-engine persistence details.
|
||||
- Owner/adult/child/guest behavior is represented as membership facts and
|
||||
authorization context, not embedded as final policy decisions in user-engine.
|
||||
- Audit, outbox, evidence references, and lifecycle gaps exist for onboarding
|
||||
and role changes.
|
||||
- Scenario tests prove happy-path onboarding, SSO context generation, and
|
||||
tenant isolation.
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
- Family dataspace vocabulary and mapping notes.
|
||||
- Headless onboarding facade or command contract.
|
||||
- Invitation and member lifecycle model.
|
||||
- Personal dataspace application/catalog example.
|
||||
- Family membership templates and fact export behavior.
|
||||
- Claims-enrichment and `identity_context` examples for SSO adapters.
|
||||
- Scenario tests and documentation for the end-to-end use case.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented on 2026-06-05:
|
||||
|
||||
- Added family-domain roles, invitation status, member specs, onboarding
|
||||
request, and invitation records.
|
||||
- Added local invitation persistence to the isolated store boundary.
|
||||
- Added `UserEngineService.onboard_family_dataspace(...)` as the headless
|
||||
onboarding facade.
|
||||
- Added `invite_family_member`, `resend_family_invitation`,
|
||||
`revoke_family_invitation`, and `accept_family_invitation`.
|
||||
- Registered the personal dataspace as an application with an SSO/protected
|
||||
system binding and a minimal dataspace profile catalog.
|
||||
- Represented family roles as scoped memberships while preserving
|
||||
authorization-port decisions.
|
||||
- Returned `identity_context` and `CLAIMS_ENRICHMENT` projection outputs for
|
||||
SSO adapters.
|
||||
- Added audit/outbox events for high-level family onboarding and invitation
|
||||
lifecycle actions.
|
||||
- Added `docs/family-dataspace-onboarding.md`, examples, contract updates, and
|
||||
scenario documentation.
|
||||
- Added scenario tests for owner onboarding, member acceptance, resend/revoke,
|
||||
SSO identity linking, claims projection, and cross-family denial.
|
||||
|
||||
Verification:
|
||||
|
||||
```text
|
||||
make test
|
||||
Ran 39 tests in 0.119s
|
||||
OK
|
||||
```
|
||||
180
workplans/USER-WP-0009-postgres-durable-store-requirements.md
Normal file
180
workplans/USER-WP-0009-postgres-durable-store-requirements.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
id: USER-WP-0009
|
||||
type: workplan
|
||||
title: "Postgres Durable Store Consumer Requirements"
|
||||
domain: communication
|
||||
repo: user-engine
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
planning_order: 9
|
||||
created: "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
|
||||
|
||||
## Goal
|
||||
|
||||
Define, from the `user-engine` consumer perspective, what a durable
|
||||
Postgres-backed store must provide before user-engine depends on it in
|
||||
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
|
||||
|
||||
`user-engine` should be able to consume a NetKingdom-provided, tenant-aware,
|
||||
security-integrated Postgres capability through an adapter boundary. The
|
||||
future Postgres/provider repository should own provisioning, credentials,
|
||||
network policy, tenant isolation primitives, backup/restore, platform
|
||||
observability, and operational security. `user-engine` should own its domain
|
||||
schema, migrations for its own tables, store semantics, and conformance tests.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not implement a Postgres store adapter in this workplan.
|
||||
- Do not add database dependencies to the package in this workplan.
|
||||
- Do not provision Postgres, schemas, roles, credentials, certificates, or
|
||||
network access from this repository.
|
||||
- Do not decide the final independent infrastructure repository layout.
|
||||
- Do not move audit-platform, IAM, secrets, or authorization ownership into
|
||||
user-engine.
|
||||
- Do not change the public service surface unless the requirements reveal a
|
||||
missing durable-store contract.
|
||||
|
||||
## Tasks
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T1
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "64c578e1-e2a1-48d4-8da9-659d4f881ef3"
|
||||
```
|
||||
|
||||
Inventory the current in-memory store behavior and document the durable
|
||||
persistence semantics user-engine consumers already rely on: users, accounts,
|
||||
tenant accounts, external identities, applications, bindings, catalogs,
|
||||
profile values, memberships, audit records, outbox events, readiness, and
|
||||
schema version reporting.
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T2
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "19cfd23e-8a87-416d-b948-c727e8c5a11c"
|
||||
```
|
||||
|
||||
Create a consumer-facing requirements document for a Postgres durable store.
|
||||
Cover connection handoff, tenant context, schema ownership, migrations,
|
||||
transactions, isolation, constraints, query behavior, audit/outbox durability,
|
||||
security, observability, backup/restore expectations, and acceptance tests.
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T3
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d3b388de-bb79-41d5-805e-d2def88ac926"
|
||||
```
|
||||
|
||||
Define the boundary between user-engine and the future NetKingdom Postgres
|
||||
provider repository. Specify which responsibilities belong to the provider,
|
||||
which belong to the user-engine adapter, and which must remain external IAM,
|
||||
secrets, authorization, or audit-platform concerns.
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T4
|
||||
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
|
||||
contract so durable implementations can satisfy the same service behavior as
|
||||
the isolated MVP without leaking Postgres concepts into domain code.
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T5
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "3c428960-be5b-411e-bd9b-7cba833abba8"
|
||||
```
|
||||
|
||||
Define conformance scenarios and failure-mode tests the future Postgres store
|
||||
must pass. Include transaction rollback, duplicate identity prevention,
|
||||
tenant-boundary enforcement, outbox exactly-once handoff semantics, migration
|
||||
readiness, and redacted diagnostics.
|
||||
|
||||
```task
|
||||
id: USER-WP-0009-T6
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d606094a-254c-46d5-9bb8-a3449ce61c2c"
|
||||
```
|
||||
|
||||
Record open questions for the independent provider repository, including
|
||||
tenant isolation model, credential lease model, schema-per-service or
|
||||
database-per-tenant strategy, migration runner ownership, backup unit, PITR
|
||||
expectations, encryption, and operational runbooks.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `docs/postgres-durable-store-consumer-requirements.md` exists and is clear
|
||||
enough for an independent NetKingdom Postgres provider repo to implement
|
||||
against.
|
||||
- The document describes user-engine as a consumer of a secure Postgres
|
||||
capability, not as the owner of Postgres provisioning or platform security.
|
||||
- Requirements cover domain persistence, transactions, migrations, tenant
|
||||
isolation, security, audit/outbox durability, operability, and acceptance
|
||||
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
|
||||
```
|
||||
157
workplans/USER-WP-0010-registration-identity-and-factor-model.md
Normal file
157
workplans/USER-WP-0010-registration-identity-and-factor-model.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
164
workplans/USER-WP-0014-registration-and-access-management-ui.md
Normal file
164
workplans/USER-WP-0014-registration-and-access-management-ui.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
116
workplans/USER-WP-0017-durable-store-record-serialization.md
Normal file
116
workplans/USER-WP-0017-durable-store-record-serialization.md
Normal 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.
|
||||
113
workplans/USER-WP-0018-postgres-store-adapter.md
Normal file
113
workplans/USER-WP-0018-postgres-store-adapter.md
Normal 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.
|
||||
113
workplans/USER-WP-0019-provider-backed-postgres-conformance.md
Normal file
113
workplans/USER-WP-0019-provider-backed-postgres-conformance.md
Normal 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.
|
||||
Reference in New Issue
Block a user