From 62e10cddeda3cd6d49a3c78795a420b7c58331fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:15:42 +0700 Subject: [PATCH 01/32] docs(plans): orchestrate Luna Max delivery batches --- docs/plans/000-platform-program.md | 2 + .../002-complete-execution-orchestration.md | 15 +- docs/plans/003-luna-handoff-runbook.md | 16 +- docs/plans/004-luna-max-execution-plan.md | 159 +++++++++++ docs/plans/README.md | 9 +- docs/plans/execution-orchestration.json | 254 +++++++++++++++++- .../src/check-execution-orchestration.mjs | 182 ++++++++++++- .../test/execution-orchestration.test.mjs | 75 +++++- 8 files changed, 681 insertions(+), 31 deletions(-) create mode 100644 docs/plans/004-luna-max-execution-plan.md diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index b86015c7..f9d652b6 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -43,6 +43,8 @@ Child plans are written and approved before their product slice begins. Each nam docs/plans/requirement-traceability.json records all 611 IDs with requested trace fields, one primary plan/task, release status, and verified paths only after evidence exists. +`docs/plans/004-luna-max-execution-plan.md` packages the unfinished orchestration tasks into 15 dependency-safe delivery batches. Normal batches contain 30–99 atomic commits, target about 70, keep promotion diffs at or below 260 changed files, and use the `feat/*` or `fix/*` → `dev` → reviewed `main` flow below. + ## Branch, commit, and review policy - `main` contains stable releases. `dev` is the integration branch. diff --git a/docs/plans/002-complete-execution-orchestration.md b/docs/plans/002-complete-execution-orchestration.md index fd7cc544..40b8dc2f 100644 --- a/docs/plans/002-complete-execution-orchestration.md +++ b/docs/plans/002-complete-execution-orchestration.md @@ -4,7 +4,7 @@ **Goal:** Finish all 611 DataBreeze requirements through dependency-ordered, independently reversible tasks and preserve enough verified state that a new model can resume without guessing. -**Architecture:** The numbered child plans remain the requirement owners. This document adds the execution DAG, atomic task boundaries, parallel-work rules, cross-plan gates, failure handling, and handoff contract that bind those child plans into one program. `execution-orchestration.json` is the machine-readable control record; `requirement-traceability.json` remains the requirement-to-code-and-evidence authority. +**Architecture:** The numbered child plans remain the requirement owners. This document adds the execution DAG, atomic task boundaries, parallel-work rules, cross-plan gates, failure handling, and handoff contract that bind those child plans into one program. `004-luna-max-execution-plan.md` packages unfinished tasks into reviewable delivery batches, `execution-orchestration.json` is the machine-readable control record, and `requirement-traceability.json` remains the requirement-to-code-and-evidence authority. **Tech Stack:** pnpm/Turborepo and strict TypeScript, NestJS/Fastify, PostgreSQL 17 with Prisma, Redis 7.4, S3-compatible storage, Electron, Kotlin/Compose, Python 3.13, OpenAPI/JSON Schema, OpenTofu/AWS Singapore, GitHub Actions, and CodeRabbit. @@ -33,8 +33,9 @@ Read sources in this order when they disagree: 3. Normative requirements under `docs/specs/` and `docs/specs/requirement-index.json`. 4. Product and architecture documents under `docs/product/` and `docs/architecture/`. 5. The requirement-owning child plan under `docs/plans/`. -6. This orchestration plan and `execution-orchestration.json`. -7. Existing code and historical implementation reports. +6. This orchestration plan. +7. `004-luna-max-execution-plan.md` and `execution-orchestration.json`. +8. Existing code and historical implementation reports. Code is evidence of work, not evidence of full requirement completion. Use these states consistently: @@ -57,11 +58,15 @@ The ledger also uses these plan/task states; they are not requirement statuses a Never infer `verified` from a merged PR, a green unit test, file existence, or a previous model's prose. +The numbered child plans own requirement scope and release obligations. Their early generated `Paths` examples are not repository path authority. Section 4 of this document supersedes generic aggregate names such as `identity-audit-entitlements`, `production-readiness`, or hyphenated Python processor directories; use the module-owned roots and deterministic platform keys below. + ## 2. Recorded checkpoint -This plan was prepared on 2026-08-02 from remote `dev` at `b21642b` and remote `main` at `8ac8bca`; promotion PR 8 had merged and no PR was open. The requirement manifest contained 611 records: 444 P0, 154 P1, 13 P2; 608 were `planned` and 3 were `partial`. +This plan was reconciled on 2026-08-02 from remote `dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7` and remote `main` at `3ed3d77d0281ef239d0509c81ded447d8fffd213`; promotion PR 20 had merged and no PR was open. The requirement manifest contained 611 records: 444 P0, 154 P1, 13 P2; 608 were `planned` and 3 were `partial`. + +Merged PRs 1–23 establish substantial engineering, IAM/AUD/BUA, IAE/DSM, JRA, and DSO code. PR 19 delivered the normal 73-commit foundation batch to `dev`; PR 20 promoted it to `main`; PRs 21–23 carried validated promotion-review fixes back through `dev`. Plans 010–050 must therefore start with evidence reconciliation, not blind reimplementation. Plans 060–500 remain unverified and must be treated as planned until their gates pass. -Merged PRs 1–8 establish substantial engineering, IAM/AUD/BUA, IAE/DSM, JRA, and DSO code. Plans 010–050 must therefore start with evidence reconciliation, not blind reimplementation. Plans 060–500 remain unverified and must be treated as planned until their gates pass. +The active execution packet is `B01` in `004-luna-max-execution-plan.md`, starting with `FND-003` on `feat/foundation-identity-completion`. The packet preserves the requested 30–99 commit rule, targets about 70 commits, and carries the implementation forward without opening a documentation-only PR. The hashes above are an audit anchor, not a branch lock. Every session must fetch and recompute live state; update the ledger checkpoint only as part of a committed task/PR handoff so session-local observations do not create meaningless dirty files. diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index 8d112214..80fdefce 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -5,7 +5,7 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- ## Start-of-session algorithm 1. Locate the canonical `databreeze-platform` repository; do not implement in the legacy `Databreeze` directory. -2. Read repository `AGENTS.md` files, `docs/plans/README.md`, `002-complete-execution-orchestration.md`, `execution-orchestration.json`, the selected child plan, and the requirement records it owns. +2. Read repository `AGENTS.md` files, `docs/plans/README.md`, `002-complete-execution-orchestration.md`, `004-luna-max-execution-plan.md`, `execution-orchestration.json`, the selected child plan, and the requirement records it owns. 3. Fetch before trusting any recorded hash: ```powershell @@ -38,8 +38,9 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- ``` If the offline Python cache is unavailable, use the documented online locked sync; do not alter the lock merely to make bootstrap pass. -8. Select `nextTaskId` only if every dependency is verified and no open PR or dirty worktree already owns it. Otherwise follow the table below and record the corrected next task. -9. State the selected task, assumptions, entry gate, expected files, tests, and stop conditions before mutation. If delegation is explicitly authorized, assign only disjoint paths with explicit integration ownership. +8. Select `activeBatchId` and `nextTaskId` only if every dependency is verified and no open PR or dirty worktree already owns them. Verify that the active batch contains the next task and that its current commit/file budgets remain safe. Otherwise follow the table below and record the corrected batch and task. +9. State the selected batch/task, assumptions, entry gate, expected files, tests, commit/file budget, and stop conditions before mutation. If delegation is explicitly authorized, assign only disjoint paths with explicit integration ownership. +10. Run package-manager operations sequentially within a worktree. Never start concurrent `pnpm install`, check, test, or build commands that share the same `node_modules` tree. ## Resume-state decision table @@ -57,6 +58,7 @@ Use this runbook to resume DataBreeze after a model, machine, branch, or hosted- | Dependency is only `implemented`, not `verified` | Finish its tests/evidence/review gate before the dependent task | Treating merged code as a verified dependency | | Baseline fails before task edits | Diagnose and document whether environment or repository caused it; repair in a `fix/*` unit or stop if unsafe | Attributing the failure to the new task or weakening the gate | | Migration/schema or generated-contract drift exists | Reconcile canonical sources, regeneration, migrations, and compatibility before feature work | Editing generated clients or database state manually | +| Package installation/check reports `EBUSY` or `EEXIST` in `node_modules/.pnpm` | Another package-manager process used the same worktree or a previous one was interrupted | Stop concurrent package-manager processes, preserve tracked files, rerun one frozen install sequentially, then rerun the failed gate; never delete tracked files or alter the lock to bypass it | | Required production/signing/business credential is unavailable | Complete all credential-independent code/tests/runbooks and stop at the explicit external gate | Using personal/untracked credentials or claiming release readiness | ## Atomic task execution loop @@ -89,7 +91,8 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: 10. Inspect generated/runtime debris before commit. Do not commit `.venv`, `node_modules`, Gradle state, build output, logs, caches, secrets, local databases, Terraform state, or test reports unless the repository explicitly tracks a sanitized fixture. 11. Commit one independently reversible outcome with a semantic message. Do not combine contracts, an unrelated fix, and a different feature just to increase commit count. -12. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. +12. Recount the active batch against its base. Do not open a normal PR below 30 commits; target about 70, stop accepting new tasks at 90, and never exceed 99. Split before the promotion diff reaches 280 changed files; the packet target is 260. +13. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. ## Pull-request and CodeRabbit protocol @@ -145,6 +148,7 @@ Remote dev / main: Open feature PR / promotion PR: CodeRabbit invocation count, invocation timestamp (UTC), and review URL: Active plan / task ID: +Active delivery batch / commit count / changed-file count: Requirement IDs and statuses changed: Completed commits (hash — outcome): Checks run and exact results: @@ -165,11 +169,11 @@ The record supplements Git; it cannot claim `verified` without traceable test/re Copy this into the first Luna session and replace only the bracketed values discovered from live Git: ```text -You are resuming DataBreeze in the canonical databreeze-platform repository. Do not trust chat checkpoints until you fetch and verify Git/PR state. Read every applicable AGENTS.md plus docs/plans/README.md, docs/plans/002-complete-execution-orchestration.md, docs/plans/003-luna-handoff-runbook.md, docs/plans/execution-orchestration.json, the selected child plan, and its requirement-traceability records. +You are resuming DataBreeze in the canonical databreeze-platform repository. Do not trust chat checkpoints until you fetch and verify Git/PR state. Read every applicable AGENTS.md plus docs/plans/README.md, docs/plans/002-complete-execution-orchestration.md, docs/plans/003-luna-handoff-runbook.md, docs/plans/004-luna-max-execution-plan.md, docs/plans/execution-orchestration.json, the selected child plan, and its requirement-traceability records. Live verified checkpoint: branch [BRANCH], HEAD [HEAD], origin/dev [DEV], origin/main [MAIN], open feature PR [FEATURE_PR_OR_NONE], open dev→main promotion PR [PROMOTION_PR_OR_NONE]. Run the orchestration checker and the documented clean baseline before edits. Preserve all user changes and use an ignored worktree if isolation is needed. -Resume task [TASK_ID] only after proving its dependency/entry gate. Follow test-first atomic delivery: canonical contracts when the interface changes, failing domain/state tests, PostgreSQL migration/tenant/transaction/outbox tests when durable state changes, implementation through ports, vertical client/adapter coverage when the task involves client behavior, safe telemetry/recovery, traceability evidence, scoped checks, repo:check, repo:build, diff review, and one reversible commit. For documentation-only or other non-durable/non-client tasks, record why those conditional tests do not apply. Do not mark merged code verified without all evidence. +Resume batch [BATCH_ID] and task [TASK_ID] only after proving their dependency/entry gates, branch ownership, and commit/file budgets. Follow test-first atomic delivery: canonical contracts when the interface changes, failing domain/state tests, PostgreSQL migration/tenant/transaction/outbox tests when durable state changes, implementation through ports, vertical client/adapter coverage when the task involves client behavior, safe telemetry/recovery, traceability evidence, scoped checks, repo:check, repo:build, diff review, and one reversible commit. For documentation-only or other non-durable/non-client tasks, record why those conditional tests do not apply. Do not mark merged code verified without all evidence. Run pnpm installation/check/test/build commands sequentially within one worktree. Git flow is fixed: feat/* or fix/* → PR to dev with hosted checks and no CodeRabbit; merge preserving atomic commits; immediately open dev→main; request exactly one CodeRabbit full review there; reproduce every comment, fix only valid findings, document rejected ones, never request a second review on that PR. Prefer 30–70 commits, hard cap 99, and do not invoke the promotion review over 280 changed files. diff --git a/docs/plans/004-luna-max-execution-plan.md b/docs/plans/004-luna-max-execution-plan.md new file mode 100644 index 00000000..4b4f54bd --- /dev/null +++ b/docs/plans/004-luna-max-execution-plan.md @@ -0,0 +1,159 @@ +# Luna Max Complete Implementation Execution Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` when delegation is explicitly authorized, or `superpowers:executing-plans` for inline delivery. Execute one orchestration task at a time and preserve the checkbox and handoff state in the authoritative records. + +**Goal:** Give Luna Max a deterministic, resumable route from the current foundation checkpoint through all 611 DataBreeze requirements, grouped into reviewable promotion batches that preserve atomic rollback commits. + +**Architecture:** `002-complete-execution-orchestration.md` owns the 155-task execution catalog and dependency graph; the numbered child plans own requirement scope; `requirement-traceability.json` owns requirement status and evidence. This plan packages every unfinished task into dependency-safe delivery batches, assigns shared-file ownership, fixes the Git/CodeRabbit flow, and provides the exact first-session bootstrap. + +**Tech Stack:** pnpm/Turborepo, strict TypeScript, NestJS/Fastify, Prisma/PostgreSQL 17, Redis 7.4, S3-compatible storage, Electron, Kotlin/Compose, Python 3.13, OpenAPI/JSON Schema, OpenTofu/AWS Singapore, GitHub Actions, and CodeRabbit. + +## Global Constraints + +- Work only in the canonical `databreeze-platform` repository. The legacy repositories are reference-only. +- Preserve the DataBreeze name and checksum-pinned logo sources without redrawing, recoloring, or adding duplicate wordmarks. +- PostgreSQL is authoritative; Redis is disposable. Local, Hybrid, and Cloud data-mode rules fail closed. +- Every durable operation is tenant-scoped, revision-aware, idempotent, auditable, and recoverable. Immutable records are corrected with successor versions or compensating entries. +- Workers, Desktop, and Android accept signed typed actions and scoped handles only; they never receive arbitrary commands, unrestricted paths, or database credentials. +- Vietnamese is the complete default locale and English is complete for every delivered client slice. +- Requirement status is evidence-based: merged code is not automatically `verified` or `released`. +- Normal feature PRs contain at least 30 commits, target about 70, and remain below 100. Empty, padding, or artificially split commits are forbidden. +- Feature/fix PRs target `dev` without CodeRabbit. The corresponding `dev` to `main` promotion receives exactly one full CodeRabbit review after hosted checks are otherwise ready. +- Keep the promotion diff at or below 260 changed files, leaving safety margin under the 280-file review stop gate. +- Never run package-manager commands concurrently in the same worktree. `pnpm install`, checks, tests, and builds share `node_modules` and execute sequentially there. + +--- + +## 1. Verified starting checkpoint + +This checkpoint was reconciled on 2026-08-02 after the latest promotion: + +| Item | Verified value | +|---|---| +| Integration branch | `origin/dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7` | +| Stable branch | `origin/main` at `3ed3d77d0281ef239d0509c81ded447d8fffd213` | +| Last normal feature PR | PR #19, 73 commits, `feat/fnd003-local-infra-batch` to `dev` | +| Last promotion PR | PR #20, `dev` to `main` | +| Promotion review fixes | PRs #21, #22, and #23 back to `dev` | +| Open PRs observed | None | +| Requirement ledger | 611 total: 608 `planned`, 3 `partial`, 0 `verified` | +| Next orchestration task | `FND-003` | +| Active delivery batch | `B01` | + +`FND-001` and `FND-002` have verified task evidence. `FND-003` through `FND-007` contain substantial merged implementation, but live Docker, OpenTofu, protected-environment, and final clean-checkout evidence remain conservative gates. Plans 020 through 050 also contain merged code that must be reconciled before any missing behavior is implemented. Do not recreate those foundations blindly. + +## 2. Authority and state ownership + +Read and apply these files in this order after `AGENTS.md`, accepted ADRs, and specifications: + +1. `docs/plans/README.md` +2. The selected numbered child plan for requirement ownership and release obligations +3. `docs/plans/002-complete-execution-orchestration.md` for task and repository-path authority +4. This plan for delivery-batch ownership +5. `docs/plans/execution-orchestration.json` for live machine state +6. `docs/plans/003-luna-handoff-runbook.md` for resume and Git/PR procedure +7. The selected requirement records in `docs/plans/requirement-traceability.json` + +The Markdown files explain intent; the fetched Git graph and PR state decide what exists. If the machine ledger and Git disagree, stop feature mutation, reconcile the ledger in a focused commit, run the orchestration checker, and then resume. + +Some early child plans contain generic aggregate `Paths` examples. Do not create those directories. The module-owned API/Prisma/client/engine paths and deterministic Android/Python keys in Section 4 of `002-complete-execution-orchestration.md` supersede those examples. + +## 3. Delivery-batch map + +Each batch is one normal integration PR and one promotion PR unless the changed-file safety gate forces a split. Commit ranges are planning budgets, not quotas. If a coherent batch finishes below 30 commits, keep the branch open and continue the next compatible task; do not open a small PR merely to reset the counter. + +| Batch | Branch | Tasks | Dependencies | Commit budget | Exit gate | +|---|---|---|---|---|---| +| `B01` | `feat/foundation-identity-completion` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 50–85, target 70 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | +| `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 40–75, target 65 | Immutable artifact/evidence/dataset foundations verified | +| `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 45–80, target 70 | Signed typed jobs execute locally/cloud with approvals and durable recovery | +| `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 45–80, target 70 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | +| `B05` | `feat/collaboration-integrations` | All Plan 060 tasks | `B04` | 45–80, target 70 | Notifications, collaboration, public API, connectors, and webhooks pass | +| `B06` | `feat/dogfood-autopilot-core` | `DOG-001..007`, `FA-001..003` | `B05` | 45–75, target 65 | Ten-condition dogfood record accepted; safe Autopilot intake/routing exists | +| `B07` | `feat/autopilot-spreadsheet-auditor` | `FA-004..007`, `SA-001..007` | `B06` | 50–85, target 70 | Folder Autopilot and Spreadsheet Auditor P0/P1 gates pass | +| `B08` | `feat/quote-invoice-intelligence` | `QI-001..007`, then `ILD-001..007` | `B06` | 60–90, target 75 | Quote Intelligence and Invoice Leak Detector P0/P1 gates pass | +| `B09` | `feat/operations-capture` | `OC-001..008` | `B06` | 40–75, target 65 | Offline native capture, immutable submission, supervision, and reconciliation pass | +| `B10` | `feat/client-report-factory` | `CRF-001..007` | `B07`, `B08` | 40–75, target 65 | Evidence-linked multi-format reports and revocable sharing pass | +| `B11` | `feat/private-data-analyst` | `PDA-001..008` | `B09`, `B10` | 45–80, target 70 | Deterministic governed analysis and optional-AI boundaries pass | +| `B12` | `feat/migration-quality-suite` | `MR-001..007`, then `DQG-001..008` | `B08`, `B11` | 65–95, target 80 | Migration Ready and Data Quality Guard P0/P1 gates pass | +| `B13` | `feat/embedded-importer` | `EI-001..007` | `B05` | 35–70, target 60 | Hosted importer and outbound-only local gateway pass hostile tests | +| `B14` | `feat/production-readiness` | `GA-001..012` | `B12`, `B13` | 60–90, target 75 | Every P0/P1 requirement is verified and coordinated GA is released | +| `B15` | `feat/post-ga-extensions` | `P2-001..004` | `B14` | 35–70, target 60 | All 13 P2 requirements are opt-in, revocable, and verified | + +The machine-readable `deliveryBatches` array is authoritative for exact task membership. Its checker rejects missing or duplicate task ownership, dependency cycles, a batch below the 30-commit minimum, a maximum of 100 or more, and an active batch that does not contain `nextTaskId`. + +## 4. Parallel execution and integration ownership + +The foundation spine `B01` through `B06` is serial. After `B06`, `B07`, `B08`, and `B09` may run in separate worktrees. `B13` may start after `B05` and proceed alongside `B06` through `B12`. All other dependencies in the table remain hard gates. + +Before parallel work begins, the integration owner records: + +- the exact `origin/dev` base for every branch; +- one migration timestamp range per branch; +- canonical schema namespaces owned by that branch; +- feature-directory ownership for API, Web, Desktop, Android, and engine; +- the merge order and the person/model responsible for generated aggregate conflicts. + +Only the integration owner edits shared aggregators during a parallel merge: `services/api/src/app.module.ts`, generated OpenAPI aggregates, Prisma aggregate configuration, package export maps, root Web route registration, root localization catalogs, `requirement-traceability.json`, and `execution-orchestration.json`. Feature workers modify module-owned canonical sources and tests. After each merge, the integration owner regenerates contracts and reruns drift checks before the next branch merges. + +Recommended merge queue after the parallel wave is `B13`, `B07`, `B08`, `B09`, `B10`, `B11`, `B12`. Completion time may differ; dependency and shared-file safety decide merge order, not which worker finishes first. + +## 5. Atomic task recipe + +For every `#### TASK-ID —` entry in `002-complete-execution-orchestration.md`, Luna performs this exact cycle: + +- [ ] Read the owning requirements, accepted ADRs, current implementation, and existing tests. Record which obligations already exist and which remain. +- [ ] Reserve canonical schema names and migration ordering before editing shared interfaces. +- [ ] Write the failing contract, domain, policy, state-machine, or repository test that proves the missing behavior. Run it and confirm the expected failure. +- [ ] Add the smallest domain/application implementation needed for that test. Re-run the narrow test. +- [ ] When durable state changes, add the ordered migration and real PostgreSQL tests for tenant scope, transactions, concurrency, idempotency, and compensating rollback. +- [ ] Add adapters and only the platform surfaces owned by the task. Use generated contracts at every client/worker boundary. +- [ ] Add negative privacy/security tests, bounded failure behavior, content-safe telemetry, recovery, and rollback notes. +- [ ] Run the owning package tests, contract drift, `corepack pnpm repo:check`, `corepack pnpm repo:build`, and `git diff --check` sequentially. +- [ ] Update only the trace records supported by exact code, test, and evidence paths. Update task and batch state using immutable commit hashes. +- [ ] Commit one independently reversible outcome and push at the stable task boundary. + +Typical reversible commits inside a task are: canonical contract, domain behavior, migration/repository, adapter/API, client vertical slice, and verification/evidence. Omit a category that genuinely does not apply and record why; never create padding commits. + +## 6. PR and promotion algorithm + +1. Count commits and changed files against the batch base before opening anything. +2. Do not open the normal PR below 30 commits. At 60–75 commits, finish the current atomic task and prepare the PR. At 90 commits, stop accepting new tasks. At 99 commits, the branch is at the hard boundary and must not receive another commit before scope is split or promoted. +3. If the branch exceeds 260 changed files, split at a completed task boundary before review. Do not split a migration from its code/tests or a canonical schema from generated consumers. +4. Open `feat/*` or `fix/*` to `dev`. Run hosted checks and merge with a merge commit that preserves atomic commits. Do not invoke CodeRabbit. +5. Immediately open `dev` to `main`. When otherwise ready, request one full CodeRabbit review and record the invocation. +6. Reproduce every comment against the exact reviewed commit. Fix valid findings through focused commits merged back to `dev`; document rejected findings with tests or authoritative references. Do not request another review. +7. Merge the promotion only after required checks are green and all valid findings are resolved. Fetch both branches, record merge hashes, and activate the next dependency-ready batch. + +Focused promotion-gate fixes may use a smaller PR to `dev` because they close an already-reviewed promotion. They do not reset or weaken the next normal batch’s 30-commit minimum. + +## 7. First Luna Max session + +The active branch is `feat/foundation-identity-completion`, based on `origin/dev` at `783a4710c0aa2a2808d78ad7f0643e6731150bd7`. Its first commit is this orchestration update; continue on the same branch until `B01` reaches a coherent 50–85 commit boundary. + +Run these commands sequentially: + +```powershell +git fetch --all --prune +git status --short --branch +git rev-parse HEAD +git rev-parse origin/dev +git rev-parse origin/main +gh pr list --state open --limit 100 --json number,title,headRefName,baseRefName,isDraft,statusCheckRollup,url +corepack pnpm install --frozen-lockfile +corepack pnpm orchestration:check +corepack pnpm requirements:check +``` + +Then resume `FND-003`: + +1. Run the Docker-capable checks in `docs/operations/foundation-local-infrastructure-2026-08-02.md` when Docker Desktop/Compose v2 is available. +2. If Docker remains unavailable, preserve `FND-003` as incomplete, finish only credential-independent `FND-004..007` evidence, and record the external gate. Do not claim foundation verification. +3. Reconcile Plans 020–050 against merged code before implementing any missing behavior. For `B01`, complete Plan 020 only after the remaining foundation boundaries are explicit. +4. End every session with the handoff record from `003-luna-handoff-runbook.md`, including exact branch/HEAD, open PRs, checks, task/batch status, rollback points, and safest next command. + +## 8. Completion and stop rules + +The program is complete only when `GA-012` is released and all P0/P1 requirements are `released`; `B15` completes the separately authorized P2 scope. A plan file, UI mock, green unit test, or merged PR is not product completion. + +Stop without widening scope when specs conflict, customer/user changes overlap, a migration risks unrecoverable data, a security/privacy boundary cannot fail closed, signing or production authority is missing, or a required test remains nondeterministic after diagnosis. Preserve the branch, commits, evidence, and exact smallest decision needed. Never reset, delete, force-push, or silently downgrade a gate to keep the schedule moving. diff --git a/docs/plans/README.md b/docs/plans/README.md index de83740b..dfa45462 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -14,11 +14,12 @@ Each plan must: The planning authorities are: 1. `000-platform-program.md` — stable program policy and release gates. -2. `001-engineering-foundation.md` — historical foundation planning record. +2. `001-plan-package.md` — historical record for creating the child-plan and traceability package. 3. `002-complete-execution-orchestration.md` — implementation DAG, atomic task catalog, cross-plan gates, and edge cases. 4. `003-luna-handoff-runbook.md` — deterministic session resume, Git/PR, CodeRabbit, recovery, and handoff protocol. -5. `execution-orchestration.json` — machine-readable plan/task/dependency ledger and next-task pointer. -6. `requirement-traceability.json` — 611 unique requirement assignments (P0 444, P1 154, P2 13) and their implementation/test/release evidence. +5. `004-luna-max-execution-plan.md` — live checkpoint, 15 delivery batches, commit/file budgets, parallel lanes, and the exact Luna Max starting packet. +6. `execution-orchestration.json` — machine-readable plan/task/batch dependency ledger and active batch/next-task pointer. +7. `requirement-traceability.json` — 611 unique requirement assignments (P0 444, P1 154, P2 13) and their implementation/test/release evidence. The dependency-ordered child plans are: @@ -42,6 +43,6 @@ The dependency-ordered child plans are: 18. `400-production-readiness.md` 19. `500-post-ga-extensions.md` -Run `corepack pnpm orchestration:check` before selecting or handing off an implementation task. A child plan owns requirement scope; the orchestration plan owns execution order and task boundaries; the traceability manifest owns evidence status. Git and fetched pull-request state override historical checkpoint hashes. +Run `corepack pnpm orchestration:check` before selecting or handing off an implementation task. A child plan owns requirement scope; the orchestration plan owns task order; the Luna Max execution plan owns delivery batching; the traceability manifest owns evidence status. Git and fetched pull-request state override historical checkpoint hashes. A plan does not change product scope. Any conflict returns to the specification review process. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 84f853b2..37be5e90 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -1,10 +1,11 @@ { - "version": 1, + "version": 2, "program": "DataBreeze complete platform delivery", "authority": { "requirements": "docs/plans/requirement-traceability.json", "execution": "docs/plans/002-complete-execution-orchestration.md", - "resumeProtocol": "docs/plans/003-luna-handoff-runbook.md" + "resumeProtocol": "docs/plans/003-luna-handoff-runbook.md", + "deliveryBatches": "docs/plans/004-luna-max-execution-plan.md" }, "requirementTotals": { "total": 611, @@ -15,13 +16,14 @@ } }, "checkpoint": { - "observedAt": "2026-08-02T12:00:00Z", - "remoteDev": "92b3e9a4d581f3a6947b7a2bf58c8334f4ae0c18", - "remoteMain": "a2fcba34037c1ffd77816be16be75453abfb16fa", - "lastFeaturePullRequest": 18, - "lastPromotionPullRequest": 14, + "observedAt": "2026-08-02T13:38:28Z", + "remoteDev": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", + "remoteMain": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "lastFeaturePullRequest": 19, + "lastPromotionPullRequest": 20, + "lastPromotionFixPullRequest": 23, "openPullRequestsObserved": 0, - "note": "Historical observation only; every session must fetch and recompute current state." + "note": "PR 19 merged a 73-commit foundation batch to dev; PR 20 promoted dev to main after CodeRabbit review and PRs 21-23 delivered validated review fixes. Historical observation only; every session must fetch and recompute current state." }, "reviewPolicy": { "featurePullRequest": { @@ -53,6 +55,7 @@ "blocked" ], "nextTaskId": "FND-003", + "activeBatchId": "B01", "taskState": { "FND-001": { "status": "verified", @@ -80,7 +83,7 @@ }, "FND-003": { "status": "in-progress", - "commit": "d9598a2", + "commit": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", "evidence": [ "infrastructure/local/compose.yml", "infrastructure/local/README.md", @@ -89,16 +92,245 @@ "tools/repo-cli/test/local-infrastructure.test.mjs", "docs/operations/foundation-local-infrastructure-2026-08-02.md" ], - "note": "Static Compose/bootstrap/lifecycle checks, daemon-free config/preflight, bounded local diagnostics/log retention, AWS safety checks, telemetry redaction tests, and infrastructure path-aware CI pass. Live Docker health, port-collision, disk-pressure, and restart-persistence evidence remains pending because the Docker daemon is unavailable in this environment." + "note": "Static Compose/bootstrap/lifecycle checks, daemon-free config/preflight, bounded local diagnostics/log retention, AWS safety checks, telemetry redaction tests, and infrastructure path-aware CI are integrated and promoted. Live Docker health, port-collision, disk-pressure, and restart-persistence evidence remains pending because the Docker daemon was unavailable in the implementation environment." + }, + "FND-004": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "infrastructure/aws/README.md", + "infrastructure/aws/environments/alpha/main.tf", + "tools/repo-cli/src/check-aws-infrastructure.mjs", + "tools/repo-cli/test/aws-infrastructure.test.mjs" + ], + "note": "Portable AWS modules and static safety checks are promoted. Pinned OpenTofu fmt/init/validate and any reviewed plan/apply evidence remain external gates." + }, + "FND-005": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/operations/telemetry-safety.md", + "packages/telemetry/test/telemetry-v1.test.mjs", + "services/engine/tests/test_telemetry.py" + ], + "note": "TypeScript and Python telemetry boundaries, hostile-input normalization, and redaction tests are promoted; final clean-checkout reconciliation remains in B01." + }, + "FND-006": { + "status": "implemented", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/operations/foundation-ci-supply-chain-2026-08-02.md", + "tools/repo-cli/src/check-ci-policy.mjs", + "tools/repo-cli/test/ci-policy.test.mjs", + "tools/repo-cli/test/provenance.test.mjs", + "tools/repo-cli/test/sbom.test.mjs" + ], + "note": "Hosted checks, supply-chain policy, SBOM, and provenance safeguards are promoted. GitHub release-environment protection remains an administrator-recorded external gate." + }, + "FND-007": { + "status": "partial-needs-reconciliation", + "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "evidence": [ + "docs/development/README.md", + "docs/operations/engineering-foundation-verification.md", + "docs/operations/deployment-and-rollback.md", + "docs/operations/secret-rotation.md", + "docs/operations/provider-adapters.md" + ], + "note": "Runbooks and static evidence exist; B01 must reconcile live external gates and publish the final foundation handoff before advancing plan status." } }, + "deliveryBatches": [ + { + "batchId": "B01", + "name": "Foundation verification and identity completion", + "branch": "feat/foundation-identity-completion", + "dependencies": [], + "status": "in-progress", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "maximumChangedFiles": 260, + "taskIds": [ + "FND-003", + "FND-004", + "FND-005", + "FND-006", + "FND-007", + "IAM-001", + "IAM-002", + "IAM-003", + "IAM-004", + "IAM-005", + "AUD-001", + "AUD-002", + "BUA-001", + "BUA-002", + "IAM-006", + "IAM-007" + ], + "exitGate": "Foundation external gates are explicit and IAM, AUD, and BUA requirements are reconciled, completed, tested, and evidenced." + }, + { + "batchId": "B02", + "name": "Artifacts, evidence, datasets, and definitions", + "branch": "feat/artifacts-datasets-completion", + "dependencies": ["B01"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["IAE-001", "IAE-002", "IAE-003", "IAE-004", "IAE-005", "DSM-001", "DSM-002", "DSM-003", "IAE-006", "IAE-007"], + "exitGate": "Local, Hybrid, and Cloud artifact, evidence, dataset, definition, retention, and deletion gates pass." + }, + { + "batchId": "B03", + "name": "Jobs, processing, findings, reviews, and approvals", + "branch": "feat/jobs-processing-completion", + "dependencies": ["B02"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["JRA-001", "JRA-002", "JRA-003", "JRA-004", "JRA-005", "JRA-006", "JRA-007", "JRA-008", "JRA-009", "JRA-010", "JRA-011"], + "exitGate": "The same signed typed action executes locally or in cloud with durable admission, evidence, approval, recovery, usage, and audit outcomes." + }, + { + "batchId": "B04", + "name": "Devices, synchronization, and offline operation", + "branch": "feat/devices-sync-completion", + "dependencies": ["B03"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["DSO-001", "DSO-002", "DSO-003", "DSO-004", "DSO-005", "DSO-006", "DSO-007", "DSO-008", "DSO-009", "DSO-010"], + "exitGate": "Desktop and Android operate offline, resume idempotently, expose conflicts, preserve data modes, and fail closed after revocation." + }, + { + "batchId": "B05", + "name": "Collaboration, notifications, public API, and integrations", + "branch": "feat/collaboration-integrations", + "dependencies": ["B04"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["NCO-001", "NCO-002", "NCO-003", "NCO-004", "INT-001", "INT-002", "INT-003", "INT-004", "NCO-005", "INT-005"], + "exitGate": "Collaboration and external access use shared contracts and replaceable adapters without restricted or undocumented APIs." + }, + { + "batchId": "B06", + "name": "Three-platform dogfood and Autopilot core", + "branch": "feat/dogfood-autopilot-core", + "dependencies": ["B05"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["DOG-001", "DOG-002", "DOG-003", "DOG-004", "DOG-005", "DOG-006", "DOG-007", "FA-001", "FA-002", "FA-003"], + "exitGate": "All ten dogfood conditions pass and Autopilot has governed bindings, routing, watchers, and reconciliation." + }, + { + "batchId": "B07", + "name": "Folder Autopilot and Spreadsheet Auditor", + "branch": "feat/autopilot-spreadsheet-auditor", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 85 }, + "maximumChangedFiles": 260, + "taskIds": ["FA-004", "FA-005", "FA-006", "FA-007", "SA-001", "SA-002", "SA-003", "SA-004", "SA-005", "SA-006", "SA-007"], + "exitGate": "Folder Autopilot and Spreadsheet Auditor P0/P1 requirements are verified without mutating originals." + }, + { + "batchId": "B08", + "name": "Quote and invoice intelligence", + "branch": "feat/quote-invoice-intelligence", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "maximumChangedFiles": 260, + "taskIds": ["QI-001", "QI-002", "QI-003", "QI-004", "QI-005", "QI-006", "QI-007", "ILD-001", "ILD-002", "ILD-003", "ILD-004", "ILD-005", "ILD-006", "ILD-007"], + "exitGate": "Quote Intelligence and Invoice Leak Detector P0/P1 requirements are verified with exact source evidence and no vendor API dependency." + }, + { + "batchId": "B09", + "name": "Operations Capture", + "branch": "feat/operations-capture", + "dependencies": ["B06"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["OC-001", "OC-002", "OC-003", "OC-004", "OC-005", "OC-006", "OC-007", "OC-008"], + "exitGate": "Offline native capture, immutable submissions, correction, supervision, and Desktop reconciliation pass P0/P1 gates." + }, + { + "batchId": "B10", + "name": "Client Report Factory", + "branch": "feat/client-report-factory", + "dependencies": ["B07", "B08"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 65, "maximum": 75 }, + "maximumChangedFiles": 260, + "taskIds": ["CRF-001", "CRF-002", "CRF-003", "CRF-004", "CRF-005", "CRF-006", "CRF-007"], + "exitGate": "Evidence-linked multi-format reports, review, release, scheduling, and revocable sharing pass P0/P1 gates." + }, + { + "batchId": "B11", + "name": "Private Data Analyst", + "branch": "feat/private-data-analyst", + "dependencies": ["B09", "B10"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 70, "maximum": 80 }, + "maximumChangedFiles": 260, + "taskIds": ["PDA-001", "PDA-002", "PDA-003", "PDA-004", "PDA-005", "PDA-006", "PDA-007", "PDA-008"], + "exitGate": "Governed analysis is deterministic and reproducible; optional AI can propose but never supply numeric truth." + }, + { + "batchId": "B12", + "name": "Migration Ready and Data Quality Guard", + "branch": "feat/migration-quality-suite", + "dependencies": ["B08", "B11"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 80, "maximum": 95 }, + "maximumChangedFiles": 260, + "taskIds": ["MR-001", "MR-002", "MR-003", "MR-004", "MR-005", "MR-006", "MR-007", "DQG-001", "DQG-002", "DQG-003", "DQG-004", "DQG-005", "DQG-006", "DQG-007", "DQG-008"], + "exitGate": "Migration Ready and Data Quality Guard P0/P1 requirements are verified with export-first, immutable, evidence-backed behavior." + }, + { + "batchId": "B13", + "name": "Embedded Importer", + "branch": "feat/embedded-importer", + "dependencies": ["B05"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "maximumChangedFiles": 260, + "taskIds": ["EI-001", "EI-002", "EI-003", "EI-004", "EI-005", "EI-006", "EI-007"], + "exitGate": "Hosted importer and outbound-only Desktop gateway pass tenant, origin, upload, replay, accessibility, and local/cloud parity gates." + }, + { + "batchId": "B14", + "name": "Production readiness and coordinated GA", + "branch": "feat/production-readiness", + "dependencies": ["B12", "B13"], + "status": "planned", + "commitBudget": { "minimum": 30, "target": 75, "maximum": 90 }, + "maximumChangedFiles": 260, + "taskIds": ["GA-001", "GA-002", "GA-003", "GA-004", "GA-005", "GA-006", "GA-007", "GA-008", "GA-009", "GA-010", "GA-011", "GA-012"], + "exitGate": "Every P0/P1 requirement is verified, artifacts are signed and recoverable, and the coordinated GA release is observed and recorded." + }, + { + "batchId": "B15", + "name": "Post-GA opt-in extensions", + "branch": "feat/post-ga-extensions", + "dependencies": ["B14"], + "status": "post-ga-planned", + "commitBudget": { "minimum": 30, "target": 60, "maximum": 70 }, + "maximumChangedFiles": 260, + "taskIds": ["P2-001", "P2-002", "P2-003", "P2-004"], + "exitGate": "All 13 P2 requirements are opt-in, disabled by default, provider-exitable, revocable, and verified." + } + ], "plans": [ { "planId": "010", "file": "010-engineering-foundation.md", "requirementCount": 0, "dependencies": [], - "status": "partial-needs-reconciliation", + "status": "in-progress", "taskIds": ["FND-001", "FND-002", "FND-003", "FND-004", "FND-005", "FND-006", "FND-007"] }, { diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index 5229f4e7..6ee2c595 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -44,6 +44,16 @@ const requiredRunbookHeadings = [ '## End-of-session handoff record', '## Luna bootstrap prompt', ]; +const requiredExecutionPlanHeadings = [ + '## 1. Verified starting checkpoint', + '## 3. Delivery-batch map', + '## 4. Parallel execution and integration ownership', + '## 5. Atomic task recipe', + '## 6. PR and promotion algorithm', + '## 7. First Luna Max session', + '## 8. Completion and stop rules', +]; +const batchChangedFileMaximum = 260; const traceStatuses = new Set(['planned', 'partial', 'implemented', 'verified', 'released']); function parseOptions(argumentsList) { @@ -121,6 +131,145 @@ function validateDag(plans, diagnostics) { for (const planId of byId.keys()) visit(planId); } +function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnostics }) { + const batches = Array.isArray(ledger.deliveryBatches) ? ledger.deliveryBatches : []; + const byId = new Map(); + const batchByTask = new Map(); + + for (const batch of batches) { + if (typeof batch.batchId !== 'string' || batch.batchId.trim() === '') { + diagnostics.push('delivery batch has no batchId'); + continue; + } + if (byId.has(batch.batchId)) diagnostics.push(`duplicate delivery batch ${batch.batchId}`); + byId.set(batch.batchId, batch); + if (!/^feat\/[a-z0-9-]+$|^fix\/[a-z0-9-]+$/u.test(batch.branch ?? '')) { + diagnostics.push(`batch ${batch.batchId} has invalid branch ${batch.branch}`); + } + if (!ledger.statusVocabulary?.includes(batch.status)) { + diagnostics.push(`batch ${batch.batchId} has unsupported status ${batch.status}`); + } + const budget = batch.commitBudget ?? {}; + if (!Number.isInteger(budget.minimum) || budget.minimum < 30) { + diagnostics.push(`batch ${batch.batchId} commit minimum must be at least 30`); + } + if ( + !Number.isInteger(budget.target) || + budget.target < budget.minimum || + budget.target > budget.maximum + ) { + diagnostics.push(`batch ${batch.batchId} commit target is outside its budget`); + } + if (!Number.isInteger(budget.maximum) || budget.maximum >= 100) { + diagnostics.push(`batch ${batch.batchId} commit maximum must remain below 100`); + } + if ( + !Number.isInteger(batch.maximumChangedFiles) || + batch.maximumChangedFiles < 1 || + batch.maximumChangedFiles > batchChangedFileMaximum + ) { + diagnostics.push( + `batch ${batch.batchId} changed-file maximum must be between 1 and ${batchChangedFileMaximum}`, + ); + } + if (!Array.isArray(batch.taskIds) || batch.taskIds.length === 0) { + diagnostics.push(`batch ${batch.batchId} has no tasks`); + continue; + } + for (const taskId of batch.taskIds) { + if (!taskIds.has(taskId)) + diagnostics.push(`batch ${batch.batchId} has unknown task ${taskId}`); + if (batchByTask.has(taskId)) { + diagnostics.push( + `task ${taskId} is assigned to both ${batchByTask.get(taskId)} and ${batch.batchId}`, + ); + } + batchByTask.set(taskId, batch.batchId); + } + } + + for (const batch of batches) { + for (const dependency of batch.dependencies ?? []) { + if (!byId.has(dependency)) { + diagnostics.push(`batch ${batch.batchId} has unknown dependency ${dependency}`); + } + } + } + + const active = new Set(); + const complete = new Set(); + function visit(batchId) { + if (complete.has(batchId)) return; + if (active.has(batchId)) { + diagnostics.push(`delivery batch dependency cycle includes ${batchId}`); + return; + } + active.add(batchId); + for (const dependency of byId.get(batchId)?.dependencies ?? []) visit(dependency); + active.delete(batchId); + complete.add(batchId); + } + for (const batchId of byId.keys()) visit(batchId); + + const verifiedTasks = new Set( + Object.entries(ledger.taskState ?? {}) + .filter(([, state]) => ['verified', 'released'].includes(state?.status)) + .map(([taskId]) => taskId), + ); + for (const taskId of taskIds) { + if (verifiedTasks.has(taskId)) { + if (batchByTask.has(taskId)) diagnostics.push(`verified task ${taskId} remains batched`); + } else if (!batchByTask.has(taskId)) { + diagnostics.push(`unfinished task ${taskId} has no delivery batch`); + } + } + + if (!byId.has(ledger.activeBatchId)) { + diagnostics.push(`activeBatchId ${ledger.activeBatchId} is not a delivery batch`); + } else if (!byId.get(ledger.activeBatchId).taskIds.includes(ledger.nextTaskId)) { + diagnostics.push( + `active batch ${ledger.activeBatchId} does not contain nextTaskId ${ledger.nextTaskId}`, + ); + } + + function dependsOn(batchId, expectedDependency, seen = new Set()) { + if (batchId === expectedDependency) return true; + if (seen.has(batchId)) return false; + seen.add(batchId); + return (byId.get(batchId)?.dependencies ?? []).some((dependency) => + dependsOn(dependency, expectedDependency, seen), + ); + } + + const planById = new Map(plans.map((plan) => [plan.planId, plan])); + for (const [taskId, planId] of taskToPlan) { + const consumerBatchId = batchByTask.get(taskId); + if (consumerBatchId === undefined) continue; + const consumerBatch = byId.get(consumerBatchId); + for (const dependencyPlanId of planById.get(planId)?.dependencies ?? []) { + const dependencyTaskIds = planById.get(dependencyPlanId)?.taskIds ?? []; + for (const dependencyTaskId of dependencyTaskIds) { + if (verifiedTasks.has(dependencyTaskId)) continue; + const producerBatchId = batchByTask.get(dependencyTaskId); + if (producerBatchId === undefined) continue; + if (producerBatchId === consumerBatchId) { + if ( + consumerBatch.taskIds.indexOf(dependencyTaskId) > consumerBatch.taskIds.indexOf(taskId) + ) { + diagnostics.push( + `batch ${consumerBatchId} orders dependent task ${taskId} before ${dependencyTaskId}`, + ); + } + } else if (!dependsOn(consumerBatchId, producerBatchId)) { + diagnostics.push( + `batch ${consumerBatchId} containing ${taskId} does not depend on ${producerBatchId} containing ${dependencyTaskId}`, + ); + } + } + } + } +} + function run(argumentsList) { const { root } = parseOptions(argumentsList); const plansDirectory = path.join(root, 'docs', 'plans'); @@ -128,7 +277,14 @@ function run(argumentsList) { const traceabilityPath = path.join(plansDirectory, 'requirement-traceability.json'); const orchestrationPath = path.join(plansDirectory, '002-complete-execution-orchestration.md'); const runbookPath = path.join(plansDirectory, '003-luna-handoff-runbook.md'); - const requiredFiles = [ledgerPath, traceabilityPath, orchestrationPath, runbookPath]; + const executionPlanPath = path.join(plansDirectory, '004-luna-max-execution-plan.md'); + const requiredFiles = [ + ledgerPath, + traceabilityPath, + orchestrationPath, + runbookPath, + executionPlanPath, + ]; const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)); if (missingFiles.length > 0) { throw new Error(`Missing orchestration files:\n${missingFiles.join('\n')}`); @@ -138,9 +294,13 @@ function run(argumentsList) { const traceability = readJson(traceabilityPath); const orchestration = readFileSync(orchestrationPath, 'utf8'); const runbook = readFileSync(runbookPath, 'utf8'); + const executionPlan = readFileSync(executionPlanPath, 'utf8'); const diagnostics = []; - if (ledger.version !== 1) diagnostics.push(`unsupported ledger version ${ledger.version}`); + if (ledger.version !== 2) diagnostics.push(`unsupported ledger version ${ledger.version}`); + if (ledger.authority?.deliveryBatches !== 'docs/plans/004-luna-max-execution-plan.md') { + diagnostics.push('delivery-batch authority must point to the Luna Max execution plan'); + } if (!sameJson(ledger.reviewPolicy, expectedReviewPolicy)) { diagnostics.push('reviewPolicy does not preserve the approved dev/main/CodeRabbit flow'); } @@ -210,6 +370,7 @@ function run(argumentsList) { const planIds = new Set(); const planFiles = new Set(); const taskIds = new Set(); + const taskToPlan = new Map(); for (const plan of plans) { if (planIds.has(plan.planId)) diagnostics.push(`duplicate plan ${plan.planId}`); planIds.add(plan.planId); @@ -246,6 +407,7 @@ function run(argumentsList) { for (const taskId of plan.taskIds) { if (taskIds.has(taskId)) diagnostics.push(`duplicate orchestration task ${taskId}`); taskIds.add(taskId); + taskToPlan.set(taskId, plan.planId); const heading = new RegExp(`^#### ${escapeRegExp(taskId)} —`, 'mu'); if (!heading.test(orchestration)) { diagnostics.push(`orchestration heading is missing for task ${taskId}`); @@ -285,10 +447,24 @@ function run(argumentsList) { } } } + validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnostics }); for (const heading of requiredRunbookHeadings) { if (!runbook.split(/\r?\n/u).includes(heading)) diagnostics.push(`runbook heading missing: ${heading}`); } + for (const heading of requiredExecutionPlanHeadings) { + if (!executionPlan.split(/\r?\n/u).includes(heading)) { + diagnostics.push(`Luna Max execution plan heading missing: ${heading}`); + } + } + for (const batch of ledger.deliveryBatches ?? []) { + const documentedRow = `| \`${batch.batchId}\` | \`${batch.branch}\` |`; + if (!executionPlan.includes(documentedRow)) { + diagnostics.push( + `Luna Max execution plan does not document ${batch.batchId} on ${batch.branch}`, + ); + } + } if (diagnostics.length > 0) { process.stderr.write(`${diagnostics.sort().join('\n')}\n`); @@ -298,6 +474,8 @@ function run(argumentsList) { process.stdout.write( `${JSON.stringify({ nextTaskId: ledger.nextTaskId, + activeBatchId: ledger.activeBatchId, + batchCount: ledger.deliveryBatches.length, planCount: plans.length, requirementCount: requirements.length, taskCount: taskIds.size, diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 5497c2e3..ab338bd8 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -95,7 +95,7 @@ test('execution ledger covers the complete dependency-ordered implementation pro 'utf8', ); - assert.equal(ledger.version, 1); + assert.equal(ledger.version, 2); assert.deepEqual( ledger.plans.map((plan) => plan.planId), [...expectedPlans.keys()], @@ -147,6 +147,35 @@ test('handoff policy preserves the requested dev and main review flow', () => { }); }); +test('delivery batches cover every unfinished task once within review budgets', () => { + const ledger = readJson('docs/plans/execution-orchestration.json'); + const allTasks = new Set(ledger.plans.flatMap((plan) => plan.taskIds)); + const verifiedTasks = new Set( + Object.entries(ledger.taskState) + .filter(([, state]) => ['verified', 'released'].includes(state.status)) + .map(([taskId]) => taskId), + ); + const batchedTasks = ledger.deliveryBatches.flatMap((batch) => batch.taskIds); + + assert.equal(ledger.deliveryBatches.length, 15); + assert.equal(new Set(batchedTasks).size, batchedTasks.length); + assert.deepEqual( + new Set(batchedTasks), + new Set([...allTasks].filter((taskId) => !verifiedTasks.has(taskId))), + ); + for (const batch of ledger.deliveryBatches) { + assert.ok(batch.commitBudget.minimum >= 30); + assert.ok(batch.commitBudget.target >= batch.commitBudget.minimum); + assert.ok(batch.commitBudget.target <= batch.commitBudget.maximum); + assert.ok(batch.commitBudget.maximum < 100); + assert.ok(batch.maximumChangedFiles <= 260); + } + const activeBatch = ledger.deliveryBatches.find( + (batch) => batch.batchId === ledger.activeBatchId, + ); + assert.ok(activeBatch.taskIds.includes(ledger.nextTaskId)); +}); + test('the handoff runbook contains deterministic resume and failure protocols', () => { const runbook = readFileSync( path.join(repositoryRoot, 'docs', 'plans', '003-luna-handoff-runbook.md'), @@ -177,8 +206,12 @@ test('repository checker validates the committed orchestration package', () => { test('ledger records verified task evidence before advancing the next task', () => { const ledger = readJson('docs/plans/execution-orchestration.json'); assert.equal(ledger.nextTaskId, 'FND-003'); - assert.equal(ledger.checkpoint.lastFeaturePullRequest, 18); - assert.equal(ledger.checkpoint.lastPromotionPullRequest, 14); + assert.equal(ledger.activeBatchId, 'B01'); + assert.equal(ledger.checkpoint.remoteDev, '783a4710c0aa2a2808d78ad7f0643e6731150bd7'); + assert.equal(ledger.checkpoint.remoteMain, '3ed3d77d0281ef239d0509c81ded447d8fffd213'); + assert.equal(ledger.checkpoint.lastFeaturePullRequest, 19); + assert.equal(ledger.checkpoint.lastPromotionPullRequest, 20); + assert.equal(ledger.checkpoint.lastPromotionFixPullRequest, 23); assert.deepEqual(ledger.taskState?.['FND-001']?.status, 'verified'); assert.match(ledger.taskState?.['FND-001']?.commit ?? '', /^[0-9a-f]{40}$/u); assert.ok( @@ -250,3 +283,39 @@ test('repository checker rejects task evidence paths that escape the repository }, ); }); + +test('repository checker rejects duplicate delivery-task ownership', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[1].taskIds.push(ledger.deliveryBatches[0].taskIds[0]); + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /is assigned to both/u); + }, + ); +}); + +test('repository checker rejects undersized normal delivery batches', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[0].commitBudget.minimum = 29; + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /commit minimum must be at least 30/u); + }, + ); +}); + +test('repository checker rejects delivery-batch drift from the Luna plan', () => { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[0].branch = 'feat/undocumented-batch'; + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Luna Max execution plan does not document B01/u); + }, + ); +}); From eeb08539152d063b6fe3abfed6a82ac5fae69895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:17:17 +0700 Subject: [PATCH 02/32] docs(operations): record Luna Max handoff --- .../operations/luna-max-handoff-2026-08-02.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/operations/luna-max-handoff-2026-08-02.md diff --git a/docs/operations/luna-max-handoff-2026-08-02.md b/docs/operations/luna-max-handoff-2026-08-02.md new file mode 100644 index 00000000..01b28ba6 --- /dev/null +++ b/docs/operations/luna-max-handoff-2026-08-02.md @@ -0,0 +1,85 @@ +# Luna Max implementation handoff + +Observed at (UTC): `2026-08-02T14:16:09Z` + +## Repository state + +- Canonical repository/worktree: `databreeze-platform/.worktrees/luna-max-orchestration` +- Branch: `feat/foundation-identity-completion` +- Upstream: `origin/feat/foundation-identity-completion` +- Branch base / remote `dev`: `783a4710c0aa2a2808d78ad7f0643e6731150bd7` +- Remote `main`: `3ed3d77d0281ef239d0509c81ded447d8fffd213` +- Completed orchestration commit: `62e10cddeda3cd6d49a3c78795a420b7c58331fc` +- Open feature PR: none +- Open promotion PR: none +- CodeRabbit invocation for this batch: `0` +- Normal batch count before this handoff record: `1` commit and `8` changed files + +## Active execution state + +- Active batch: `B01 — Foundation verification and identity completion` +- Next task: `FND-003 — Close local infrastructure gaps` +- Batch target: 70 atomic commits; allowed range 30–85; repository hard maximum 99 +- Promotion changed-file target: at most 260; review stop gate: 280 +- Requirement ledger remains conservative: 608 `planned`, 3 `partial`, 0 `verified` +- Verified foundation task evidence: `FND-001`, `FND-002` +- Integrated but incompletely evidenced foundation work: `FND-003..007` + +## Delivered planning outcome + +- Added `docs/plans/004-luna-max-execution-plan.md` with 15 dependency-safe delivery batches covering all 153 unfinished orchestration tasks exactly once. +- Updated the execution ledger to version 2 with the current promotion checkpoint, `activeBatchId`, exact batch dependencies, branch names, commit budgets, changed-file limits, exit gates, and conservative foundation task states. +- Extended the orchestration checker to reject missing/duplicate task ownership, batch dependency cycles, undersized normal batches, 100-or-more commit maxima, changed-file limits above 260, undocumented batch drift, and an active batch that does not contain the next task. +- Updated the resume runbook with batch-state recovery, sequential package-manager operation, `EBUSY`/`EEXIST` recovery, and the exact Luna Max bootstrap contract. +- Corrected the stale plan-package filename in `docs/plans/README.md`. + +## Verification evidence + +Passed from the isolated worktree after sequential bootstrap: + +- `corepack pnpm install --frozen-lockfile` +- `uv sync --locked --offline` in `services/engine` +- `corepack pnpm repo:check` +- `corepack pnpm repo:build` +- `git diff --check` +- Orchestration checker: 19 plans, 155 tasks, 15 batches, 611 requirements, next `FND-003`, active `B01` +- Repository CLI tests: 74 passed +- Orchestration tests: 13 passed +- Python engine tests: 92 passed +- Turborepo tests: 21 successful tasks +- Turborepo builds: 12 successful tasks +- TypeScript/Python/Kotlin contract parity: 28 cases + +Environment gates intentionally remain open: + +- OpenTofu is unavailable on this workstation; static AWS checks pass, but pinned `fmt/init/validate` and reviewed plan evidence remain required. +- Docker Desktop/Compose v2 was unavailable during the foundation implementation; live health, collision, disk-pressure, Redis persistence, and restart checks remain required for `FND-003`. +- GitHub protected release-environment reviewers and branch restrictions require administrator evidence. +- Android instrumentation/emulator and signed packaging remain later release gates. + +## Resume instructions + +Run sequentially: + +```powershell +git fetch --all --prune +git switch feat/foundation-identity-completion +git pull --ff-only origin feat/foundation-identity-completion +corepack pnpm install --frozen-lockfile +Push-Location services/engine +uv sync --locked --offline +Pop-Location +corepack pnpm orchestration:check +corepack pnpm requirements:check +``` + +Then read `AGENTS.md`, `docs/plans/002-complete-execution-orchestration.md`, `docs/plans/003-luna-handoff-runbook.md`, `docs/plans/004-luna-max-execution-plan.md`, the Plan 010/020 documents, and the selected trace records. Resume `FND-003` from its live-environment gate; reconcile existing merged behavior before writing replacement code. + +Do not open a PR yet. Continue atomic `B01` work on this branch until it reaches at least 30 commits, targeting about 70. The feature PR goes to `dev` without CodeRabbit; the later `dev` to `main` promotion receives the single full CodeRabbit review. + +## Rollback and preserved state + +- Revert the handoff-record commit to remove only this status record. +- Revert `62e10cddeda3cd6d49a3c78795a420b7c58331fc` to remove the Luna batch plan, ledger version 2, and its validation changes as one independent unit. +- No infrastructure was applied, no database was migrated, no customer data was read, and no feature flag changed. +- Ignored `node_modules`, `.venv`, `dist`, and tool caches are disposable worktree products; tracked files are clean after the containing handoff commit. From 1ebdc1e6405b207f16d653165ca1d177a2ffca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:30:00 +0700 Subject: [PATCH 03/32] feat(api): compose audit and entitlement foundations --- services/api/src/app.module.ts | 8 ++++- .../adapter/sha256-audit-digest.adapter.ts | 10 ++++++ services/api/src/features/aud/aud.module.ts | 31 +++++++++++++++++++ services/api/src/features/bua/bua.module.ts | 30 ++++++++++++++++++ .../foundation-module-composition.test.ts | 21 +++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts create mode 100644 services/api/src/features/aud/aud.module.ts create mode 100644 services/api/src/features/bua/bua.module.ts create mode 100644 services/api/test/features/foundation-module-composition.test.ts diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 994350d6..5781d5ad 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -5,12 +5,16 @@ import { SystemModule, type SystemModuleOptions } from './features/system/system import { IaeModule, type IaeModuleOptions } from './features/iae/iae.module.js'; import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; +import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; +import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & IaeModuleOptions & DsmModuleOptions & - DsoModuleOptions; + DsoModuleOptions & + AudModuleOptions & + BuaModuleOptions; @Module({}) export class AppModule { @@ -23,6 +27,8 @@ export class AppModule { IaeModule.register(options), DsmModule.register(options), DsoModule.register(options), + AudModule.register(options), + BuaModule.register(options), ], }; } diff --git a/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts b/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts new file mode 100644 index 00000000..ae5e2731 --- /dev/null +++ b/services/api/src/features/aud/adapter/sha256-audit-digest.adapter.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto'; + +import type { AuditDigestPortV1 } from '@databreeze/domain/audit/v1'; + +/** Deterministic digest implementation for the control-plane audit chain. */ +export class Sha256AuditDigestAdapter implements AuditDigestPortV1 { + public digest(canonicalRecord: string): string { + return createHash('sha256').update(canonicalRecord, 'utf8').digest('base64url'); + } +} diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts new file mode 100644 index 00000000..8b1da86a --- /dev/null +++ b/services/api/src/features/aud/aud.module.ts @@ -0,0 +1,31 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { AuditLedgerService } from './application/audit-ledger.service.js'; +import { + AUDIT_REPOSITORY_PORT, + type AuditRepositoryPortV1, +} from './application/audit-repository.port.js'; +import { InMemoryAuditRepositoryAdapter } from './adapter/in-memory-audit-repository.adapter.js'; +import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; + +export const AUDIT_LEDGER_SERVICE = Symbol('AUDIT_LEDGER_SERVICE'); + +export interface AudModuleOptions { + readonly auditRepository?: AuditRepositoryPortV1; +} + +@Module({}) +export class AudModule { + public static register(options: AudModuleOptions = {}): DynamicModule { + const repository = options.auditRepository ?? new InMemoryAuditRepositoryAdapter(); + const service = new AuditLedgerService(repository, new Sha256AuditDigestAdapter()); + return { + module: AudModule, + providers: [ + { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, + { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + ], + exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], + }; + } +} diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts new file mode 100644 index 00000000..1f1d896e --- /dev/null +++ b/services/api/src/features/bua/bua.module.ts @@ -0,0 +1,30 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { InMemoryEntitlementRepositoryAdapter } from './adapter/in-memory-entitlement-repository.adapter.js'; +import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; +import { + ENTITLEMENT_REPOSITORY_PORT, + type EntitlementRepositoryPortV1, +} from './application/entitlement-repository.port.js'; + +export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVICE'); + +export interface BuaModuleOptions { + readonly entitlementRepository?: EntitlementRepositoryPortV1; +} + +@Module({}) +export class BuaModule { + public static register(options: BuaModuleOptions = {}): DynamicModule { + const repository = options.entitlementRepository ?? new InMemoryEntitlementRepositoryAdapter(); + const service = new EntitlementAdmissionService(repository); + return { + module: BuaModule, + providers: [ + { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, + { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + ], + exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], + }; + } +} diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts new file mode 100644 index 00000000..a0c07bb4 --- /dev/null +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AppModule } from '../../src/app.module.js'; +import { AudModule } from '../../src/features/aud/aud.module.js'; +import { BuaModule } from '../../src/features/bua/bua.module.js'; + +function moduleTypes(): readonly unknown[] { + const registered = AppModule.register(); + return (registered.imports ?? []).map((entry) => + typeof entry === 'object' && entry !== null && 'module' in entry + ? (entry as { readonly module: unknown }).module + : entry, + ); +} + +void test('[IAM-001, AUD-001, BUA-001] application composition includes identity, audit, and entitlements modules', () => { + const types = moduleTypes(); + assert.ok(types.includes(AudModule)); + assert.ok(types.includes(BuaModule)); +}); From db436ce75936643835148a24004819873b95fff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:36:00 +0700 Subject: [PATCH 04/32] feat(aud): add tenant-scoped Prisma audit persistence --- .../prisma-audit-repository.adapter.ts | 382 ++++++++++++++++++ services/api/src/features/aud/aud.module.ts | 15 +- .../aud/prisma-audit-repository.test.ts | 168 ++++++++ .../foundation-module-composition.test.ts | 17 + 4 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts create mode 100644 services/api/test/features/aud/prisma-audit-repository.test.ts diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts new file mode 100644 index 00000000..37b23f33 --- /dev/null +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -0,0 +1,382 @@ +import { + AUDIT_ACTIONS_V1, + sanitizeAuditSummaryV1, + verifyAuditChainV1, + type AuditActorTypeV1, + type AuditEventV1, + type AuditSealV1, + type AuditSummaryV1, + type AuditDigestPortV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { randomUUID } from 'node:crypto'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditRepositoryPortV1, + AuditTransactionPortV1, +} from '../application/audit-repository.port.js'; + +export interface AuditEventDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly action: string; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly actorType: string; + readonly actorId: string; + readonly entityType: string; + readonly entityId: string; + readonly entityRevision: number; + readonly sequence: number; + readonly occurredAt: Date; + readonly correlationId: string; + readonly idempotencyKey: string; + readonly summary: unknown; + readonly previousDigest: string | null; + readonly digest: string; + readonly createdAt: Date; +} + +export interface AuditSealDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: Date; + readonly createdAt: Date; +} + +interface AuditEventCreateDataV1 extends Omit { + readonly createdAt: Date; +} + +interface AuditSealCreateDataV1 extends Omit { + readonly createdAt: Date; +} + +interface AuditEventDelegateV1 { + create(input: { readonly data: AuditEventCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly sequence: 'asc' | 'desc' }; + }): Promise; +} + +interface AuditSealDelegateV1 { + create(input: { readonly data: AuditSealCreateDataV1 }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: { readonly lastSequence: 'asc' | 'desc' }; + }): Promise; +} + +export interface AuditDatabaseClientV1 { + readonly auditEventRecord: AuditEventDelegateV1; + readonly auditSealRecord: AuditSealDelegateV1; + $transaction( + work: (transaction: AuditDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function scopeKey(scope: TenantScopeV1): string { + if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; + if (scope.scopeType === 'workspace') + return `workspace:${scope.organizationId}:${scope.workspaceId}`; + return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; +} + +function persistedScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('AUD_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function persistedEvent(row: AuditEventDatabaseRowV1): AuditEventV1 { + const eventId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const projectId = row.projectId === null ? undefined : parseStableIdentifierV1(row.projectId); + const actorId = parseStableIdentifierV1(row.actorId); + const entityId = parseStableIdentifierV1(row.entityId); + const correlationId = parseStableIdentifierV1(row.correlationId); + const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); + const tenantScope = persistedScope(row); + const summary = sanitizeAuditSummaryV1(row.summary); + if ( + row.schemaVersion !== 1 || + !eventId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + (row.projectId !== null && !projectId?.accepted) || + !actorId.accepted || + !entityId.accepted || + !correlationId.accepted || + !occurredAt.accepted || + !summary.accepted || + !AUDIT_ACTIONS_V1.includes(row.action as (typeof AUDIT_ACTIONS_V1)[number]) || + !['USER', 'SERVICE_ACCOUNT', 'DEVICE', 'SYSTEM'].includes(row.actorType) || + !text(row.entityType, 80) || + !positiveInteger(row.entityRevision) || + !positiveInteger(row.sequence) || + !text(row.idempotencyKey, 200) || + !text(row.digest, 512) || + (row.previousDigest !== null && !text(row.previousDigest, 512)) + ) { + throw new Error('AUD_PERSISTED_EVENT_INVALID'); + } + return Object.freeze({ + schemaVersion: 1, + eventId: eventId.value, + action: row.action as AuditEventV1['action'], + tenantScope, + actor: Object.freeze({ + actorType: row.actorType as AuditActorTypeV1, + actorId: actorId.value, + }), + entityType: row.entityType, + entityId: entityId.value, + entityRevision: row.entityRevision, + sequence: row.sequence, + occurredAt: occurredAt.value, + correlationId: correlationId.value, + idempotencyKey: row.idempotencyKey, + summary: summary.value, + previousDigest: row.previousDigest, + digest: row.digest, + }); +} + +function persistedSeal(row: AuditSealDatabaseRowV1): AuditSealV1 { + const scope = persistedScope(row); + if ( + row.schemaVersion !== 1 || + !positiveInteger(row.firstSequence) || + !positiveInteger(row.lastSequence) || + row.lastSequence < row.firstSequence || + !positiveInteger(row.eventCount) || + !text(row.rootDigest, 512) || + !parseStrictUtcTimestampV1(row.sealedAt.toISOString()).accepted + ) { + throw new Error('AUD_PERSISTED_SEAL_INVALID'); + } + const sealedAt = parseStrictUtcTimestampV1(row.sealedAt.toISOString()); + if (!sealedAt.accepted) throw new Error('AUD_PERSISTED_SEAL_INVALID'); + return Object.freeze({ + schemaVersion: 1, + tenantScope: scope, + firstSequence: row.firstSequence, + lastSequence: row.lastSequence, + eventCount: row.eventCount, + rootDigest: row.rootDigest, + sealedAt: sealedAt.value, + }); +} + +function eventCreateData(event: AuditEventV1): AuditEventCreateDataV1 { + return { + ...databaseScope(event.tenantScope), + id: event.eventId, + schemaVersion: event.schemaVersion, + action: event.action, + scopeKey: scopeKey(event.tenantScope), + actorType: event.actor.actorType, + actorId: event.actor.actorId, + entityType: event.entityType, + entityId: event.entityId, + entityRevision: event.entityRevision, + sequence: event.sequence, + occurredAt: new Date(event.occurredAt), + correlationId: event.correlationId, + idempotencyKey: event.idempotencyKey, + summary: event.summary, + previousDigest: event.previousDigest, + digest: event.digest, + createdAt: new Date(), + }; +} + +function sealCreateData(seal: AuditSealV1): AuditSealCreateDataV1 { + return { + ...databaseScope(seal.tenantScope), + id: randomUUID(), + schemaVersion: seal.schemaVersion, + scopeKey: scopeKey(seal.tenantScope), + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + eventCount: seal.eventCount, + rootDigest: seal.rootDigest, + sealedAt: new Date(seal.sealedAt), + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { + public constructor( + private readonly client: AuditDatabaseClientV1, + private readonly digestPort: AuditDigestPortV1, + ) {} + + public async appendEvent( + context: IamTenantContextV1, + event: AuditEventV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, event.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.auditEventRecord.findUnique({ + where: { id: event.eventId }, + }); + if (existing !== null) { + const current = persistedEvent(existing); + if (JSON.stringify(current) !== JSON.stringify(event)) throw new Error('AUD_IMMUTABLE_EVENT'); + return current; + } + const siblings = await this.client.auditEventRecord.findMany({ + where: { scopeKey: scopeKey(event.tenantScope) }, + orderBy: { sequence: 'desc' }, + }); + const duplicate = siblings.find((row) => row.idempotencyKey === event.idempotencyKey); + if (duplicate !== undefined) throw new Error('AUD_IDEMPOTENCY_CONFLICT'); + const latest = siblings[0]; + if ( + latest !== undefined && + (event.sequence !== latest.sequence + 1 || event.previousDigest !== latest.digest) + ) { + throw new Error('AUD_SEQUENCE_CONFLICT'); + } + const created = await this.client.auditEventRecord.create({ data: eventCreateData(event) }); + return persistedEvent(created); + } + + public async listEvents(context: IamTenantContextV1): Promise { + const rows = await this.client.auditEventRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { sequence: 'asc' }, + }); + const events = rows + .filter((row) => visible(context.tenantScope, persistedScope(row))) + .map(persistedEvent); + const verified = verifyAuditChainV1(events, this.digestPort); + if (!verified.accepted) throw new Error('AUD_CHAIN_INVALID'); + return events; + } + + public async saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, seal.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.auditSealRecord.findFirst({ + where: { + scopeKey: scopeKey(seal.tenantScope), + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + }, + }); + if (existing !== null) { + if (JSON.stringify(persistedSeal(existing)) !== JSON.stringify(seal)) + throw new Error('AUD_IMMUTABLE_SEAL'); + return; + } + await this.client.auditSealRecord.create({ data: sealCreateData(seal) }); + } + + public async listSeals(context: IamTenantContextV1): Promise { + const rows = await this.client.auditSealRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { lastSequence: 'asc' }, + }); + return rows + .filter((row) => visible(context.tenantScope, persistedScope(row))) + .map(persistedSeal); + } +} + +export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { + public constructor( + private readonly client: AuditDatabaseClientV1, + private readonly digestPort: AuditDigestPortV1, + ) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaAuditTransactionAdapter(transaction, this.digestPort)), + ); + } + + public appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).appendEvent( + context, + event, + ); + } + + public listEvents(context: IamTenantContextV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context); + } + + public saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).saveSeal(context, seal); + } + + public listSeals(context: IamTenantContextV1): Promise { + return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listSeals(context); + } +} diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 8b1da86a..cb4ee6d6 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -6,19 +6,30 @@ import { type AuditRepositoryPortV1, } from './application/audit-repository.port.js'; import { InMemoryAuditRepositoryAdapter } from './adapter/in-memory-audit-repository.adapter.js'; +import { + PrismaAuditRepositoryAdapter, + type AuditDatabaseClientV1, +} from './adapter/prisma-audit-repository.adapter.js'; import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; export const AUDIT_LEDGER_SERVICE = Symbol('AUDIT_LEDGER_SERVICE'); export interface AudModuleOptions { readonly auditRepository?: AuditRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly auditDatabase?: AuditDatabaseClientV1; } @Module({}) export class AudModule { public static register(options: AudModuleOptions = {}): DynamicModule { - const repository = options.auditRepository ?? new InMemoryAuditRepositoryAdapter(); - const service = new AuditLedgerService(repository, new Sha256AuditDigestAdapter()); + const digest = new Sha256AuditDigestAdapter(); + const repository = + options.auditRepository ?? + (options.auditDatabase === undefined + ? new InMemoryAuditRepositoryAdapter() + : new PrismaAuditRepositoryAdapter(options.auditDatabase, digest)); + const service = new AuditLedgerService(repository, digest); return { module: AudModule, providers: [ diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts new file mode 100644 index 00000000..dd5e3d83 --- /dev/null +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { AuditLedgerService } from '../../../src/features/aud/application/audit-ledger.service.js'; +import { appendAuditEventV1 } from '@databreeze/domain/audit/v1'; +import { + PrismaAuditRepositoryAdapter, + type AuditDatabaseClientV1, +} from '../../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000101'; +const workspaceId = '00000000-0000-4000-8000-000000000102'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000103'; +const actorId = '00000000-0000-4000-8000-000000000110'; +const correlationId = '00000000-0000-4000-8000-000000000111'; + +function context(workspace = workspaceId, idempotencyKey = 'audit') { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid audit context'); + return result.value; +} + +function delegate>(rows: TRow[]) { + return { + create({ data }: { readonly data: TRow }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }: { readonly where: { readonly id: string } }) { + return Promise.resolve(rows.find((row) => row['id'] === where.id) ?? null); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value)) ?? + null, + ); + }, + findMany({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }) { + const filtered = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + const [field, direction] = Object.entries(orderBy)[0] ?? []; + return Promise.resolve( + [...filtered].sort((left, right) => { + if (!field) return 0; + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) return 0; + const comparison = leftValue! < rightValue! ? -1 : 1; + return direction === 'desc' ? -comparison : comparison; + }), + ); + }, + }; +} + +function client(): AuditDatabaseClientV1 { + const eventRows: Record[] = []; + const sealRows: Record[] = []; + const database = { + auditEventRecord: delegate(eventRows), + auditSealRecord: delegate(sealRows), + async $transaction( + work: (transaction: AuditDatabaseClientV1) => Promise, + ): Promise { + const eventCount = eventRows.length; + const sealCount = sealRows.length; + try { + return await work(database as unknown as AuditDatabaseClientV1); + } catch (error) { + eventRows.splice(eventCount); + sealRows.splice(sealCount); + throw error; + } + }, + }; + return database as unknown as AuditDatabaseClientV1; +} + +const digest = { + digest: (value: string) => createHash('sha256').update(value, 'utf8').digest('base64url'), +}; + +function input(eventId: string, action: 'job.started' | 'job.completed') { + return { + eventId, + actorType: 'USER' as const, + action, + entityType: 'job', + entityId: '00000000-0000-4000-8000-000000000120', + entityRevision: action === 'job.started' ? 1 : 2, + occurredAt: action === 'job.started' ? '2026-01-01T00:00:00.000Z' : '2026-01-01T00:00:01.000Z', + summary: { outcome: 'accepted' }, + }; +} + +void test('[AUD-001, AUD-003, AUD-008, IAM-009] Prisma audit adapter persists an immutable scoped chain', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + const service = new AuditLedgerService(repository, digest); + const first = await service.append( + context(workspaceId, 'event-1'), + input('00000000-0000-4000-8000-000000000121', 'job.started'), + ); + assert.equal(first.accepted, true); + const second = await service.append( + context(workspaceId, 'event-2'), + input('00000000-0000-4000-8000-000000000122', 'job.completed'), + ); + assert.equal(second.accepted, true); + assert.equal((await repository.listEvents(context(workspaceId, 'read'))).length, 2); + assert.equal((await repository.listEvents(context(siblingWorkspaceId, 'sibling'))).length, 0); + assert.equal((await repository.listEvents(context(organizationId, 'organization'))).length, 0); +}); + +void test('[AUD-015, AUD-018] Prisma audit adapter persists and reads immutable seals', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + const service = new AuditLedgerService(repository, digest); + await service.append( + context(workspaceId, 'event-3'), + input('00000000-0000-4000-8000-000000000123', 'job.started'), + ); + const sealed = await service.seal(context(workspaceId, 'seal-1'), '2026-01-01T00:01:00.000Z'); + assert.equal(sealed.accepted, true); + assert.equal((await repository.listSeals(context(workspaceId, 'read'))).length, 1); + assert.equal((await repository.listSeals(context(siblingWorkspaceId, 'sibling'))).length, 0); +}); + +void test('[AUD-002] Prisma audit transactions do not retain an event when the unit of work fails', async () => { + const repository = new PrismaAuditRepositoryAdapter(client(), digest); + await assert.rejects( + repository.withTransaction(context(workspaceId, 'rollback'), async (transaction) => { + const created = appendAuditEventV1( + { events: [] }, + { + ...input('00000000-0000-4000-8000-000000000124', 'job.started'), + tenantScope: context(workspaceId, 'nested').tenantScope, + actor: { actorType: 'USER', actorId }, + correlationId, + idempotencyKey: 'nested', + }, + digest, + ); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await transaction.appendEvent(context(workspaceId, 'nested'), created.value.event); + await transaction.listEvents(context(workspaceId, 'inspect')); + throw new Error('rollback-me'); + }), + /rollback-me/u, + ); + assert.equal((await repository.listEvents(context(workspaceId, 'after'))).length, 0); +}); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index a0c07bb4..ca00ad80 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -3,6 +3,8 @@ import test from 'node:test'; import { AppModule } from '../../src/app.module.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; +import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; +import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; import { BuaModule } from '../../src/features/bua/bua.module.js'; function moduleTypes(): readonly unknown[] { @@ -19,3 +21,18 @@ void test('[IAM-001, AUD-001, BUA-001] application composition includes identity assert.ok(types.includes(AudModule)); assert.ok(types.includes(BuaModule)); }); + +void test('[AUD-001] configured audit persistence uses the Prisma adapter instead of the local fallback', () => { + const database = {} as never; + const registered = AudModule.register({ auditDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === AUDIT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaAuditRepositoryAdapter); +}); From ff6972490dc442396e81d15ff38f7c9439e61fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:41:33 +0700 Subject: [PATCH 05/32] feat(bua): add durable entitlement and usage persistence --- .../prisma-entitlement-repository.adapter.ts | 587 ++++++++++++++++++ services/api/src/features/bua/bua.module.ts | 12 +- .../bua/prisma-entitlement-repository.test.ts | 207 ++++++ .../foundation-module-composition.test.ts | 17 + 4 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts create mode 100644 services/api/test/features/bua/prisma-entitlement-repository.test.ts diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts new file mode 100644 index 00000000..2af9dfcd --- /dev/null +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -0,0 +1,587 @@ +import { + createPlanV1, + type EntitlementPlanV1, + type EntitlementQuotaV1, + type EntitlementSnapshotV1, + type UsageBucketV1, + type UsageLedgerEntryV1, + type UsageLedgerStateV1, + type UsageMetricV1, + type UsageReservationV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + EntitlementRepositoryPortV1, + EntitlementTransactionPortV1, +} from '../application/entitlement-repository.port.js'; + +const planCodes = new Set(['free', 'development', 'admin_granted']); +const statuses = new Set(['ACTIVE', 'SUSPENDED', 'EXPIRED']); +const metrics = new Set([ + 'artifact_bytes', + 'processing_seconds', + 'job_count', + 'member_count', + 'ocr_pages', +]); +const buckets = new Set(['RESERVED', 'COMMITTED']); +const reservationStatuses = new Set(['ACTIVE', 'FINALIZED', 'RELEASED']); + +export interface EntitlementPlanDatabaseRowV1 { + readonly planCode: string; + readonly schemaVersion: number; + readonly displayNameKey: string; + readonly features: unknown; + readonly quotas: unknown; + readonly providerIndependent: boolean; + readonly createdAt: Date; +} + +export interface EntitlementSnapshotDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly planCode: string; + readonly status: string; + readonly revision: number; + readonly securityEpoch: number; + readonly effectiveAt: Date; + readonly expiresAt: Date | null; + readonly features: unknown; + readonly quotas: unknown; + readonly createdAt: Date; +} + +export interface UsageLedgerEntryDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly metric: string; + readonly bucket: string; + readonly deltaUnits: bigint | number; + readonly sequence: number; + readonly reservationId: string | null; + readonly idempotencyKey: string; + readonly occurredAt: Date; + readonly createdAt: Date; +} + +export interface UsageReservationDatabaseRowV1 { + readonly id: string; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly metric: string; + readonly reservedUnits: bigint | number; + readonly status: string; + readonly createdAt: Date; + readonly revision: number; + readonly updatedAt: Date; +} + +interface EntitlementPlanCreateDataV1 extends Omit { + readonly createdAt: Date; +} +interface EntitlementSnapshotCreateDataV1 + extends Omit { + readonly createdAt: Date; +} +interface UsageLedgerEntryCreateDataV1 extends Omit { + readonly createdAt: Date; +} +interface UsageReservationCreateDataV1 extends Omit { + readonly updatedAt: Date; +} + +interface DelegateV1 { + create(input: { readonly data: TCreate }): Promise; + findUnique(input: { + readonly where: { readonly id?: string; readonly planCode?: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + update?(input: { + readonly where: { readonly id: string }; + readonly data: Readonly>; + }): Promise; +} + +export interface EntitlementDatabaseClientV1 { + readonly entitlementPlanRecord: DelegateV1< + EntitlementPlanDatabaseRowV1, + EntitlementPlanCreateDataV1 + >; + readonly entitlementSnapshotRecord: DelegateV1< + EntitlementSnapshotDatabaseRowV1, + EntitlementSnapshotCreateDataV1 + >; + readonly usageLedgerEntryRecord: DelegateV1< + UsageLedgerEntryDatabaseRowV1, + UsageLedgerEntryCreateDataV1 + >; + readonly usageReservationRecord: DelegateV1< + UsageReservationDatabaseRowV1, + UsageReservationCreateDataV1 + >; + $transaction( + work: (transaction: EntitlementDatabaseClientV1) => Promise, + ): Promise; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function integerUnits(input: bigint | number): number { + const value = typeof input === 'bigint' ? Number(input) : input; + if (!Number.isSafeInteger(value)) throw new Error('BUA_PERSISTED_UNITS_INVALID'); + return value; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + } as const; +} + +function scopeKey(scope: TenantScopeV1): string { + if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; + if (scope.scopeType === 'workspace') + return `workspace:${scope.organizationId}:${scope.workspaceId}`; + return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; +} + +function persistedScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId?: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === undefined || row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('BUA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function parseQuotas(input: unknown): readonly EntitlementQuotaV1[] | undefined { + if (!Array.isArray(input)) return undefined; + const values: EntitlementQuotaV1[] = []; + for (const item of input) { + if (typeof item !== 'object' || item === null) return undefined; + const metric = (item as Record)['metric']; + const limit = (item as Record)['limit']; + const normalizedLimit = positiveInteger(limit); + if (typeof metric !== 'string' || !metrics.has(metric) || normalizedLimit === undefined) + return undefined; + values.push({ metric: metric as UsageMetricV1, limit: normalizedLimit }); + } + return Object.freeze(values.map((quota) => Object.freeze(quota))); +} + +function parseFeatures(input: unknown): readonly string[] | undefined { + if (!Array.isArray(input)) return undefined; + const values = input.map((value) => text(value, 120)); + return values.every((value): value is string => value !== undefined) + ? Object.freeze(values) + : undefined; +} + +function persistedPlan(row: EntitlementPlanDatabaseRowV1): EntitlementPlanV1 { + const features = parseFeatures(row.features); + const quotas = parseQuotas(row.quotas); + const created = createPlanV1({ + planCode: row.planCode, + displayNameKey: row.displayNameKey, + features, + quotas, + }); + if ( + row.schemaVersion !== 1 || + !row.providerIndependent || + !planCodes.has(row.planCode) || + !features || + !quotas || + !created.accepted + ) + throw new Error('BUA_PERSISTED_PLAN_INVALID'); + return created.value; +} + +function persistedSnapshot(row: EntitlementSnapshotDatabaseRowV1): EntitlementSnapshotV1 { + const snapshotId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const scope = persistedScope({ ...row, projectId: null }); + const effectiveAt = parseStrictUtcTimestampV1(row.effectiveAt.toISOString()); + const expiresAt = row.expiresAt === null ? undefined : parseStrictUtcTimestampV1(row.expiresAt.toISOString()); + const features = parseFeatures(row.features); + const quotas = parseQuotas(row.quotas); + if ( + row.schemaVersion !== 1 || + !snapshotId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + !effectiveAt.accepted || + (row.expiresAt !== null && !expiresAt?.accepted) || + !features || + !quotas || + !planCodes.has(row.planCode) || + !statuses.has(row.status) || + !positiveInteger(row.revision) || + !positiveInteger(row.securityEpoch) + ) + throw new Error('BUA_PERSISTED_SNAPSHOT_INVALID'); + return Object.freeze({ + schemaVersion: 1, + snapshotId: snapshotId.value, + organizationId: organizationId.value, + ...(scope.scopeType === 'workspace' && workspaceId?.accepted + ? { workspaceId: workspaceId.value } + : {}), + planCode: row.planCode as EntitlementSnapshotV1['planCode'], + status: row.status as EntitlementSnapshotV1['status'], + revision: row.revision, + securityEpoch: row.securityEpoch, + effectiveAt: effectiveAt.value, + ...(expiresAt?.accepted ? { expiresAt: expiresAt.value } : {}), + features, + quotas, + }); +} + +function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1 { + const entryId = parseStableIdentifierV1(row.id); + const organizationId = parseStableIdentifierV1(row.organizationId); + const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const reservationId = row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId); + const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); + const scope = persistedScope({ ...row, projectId: null }); + if ( + row.schemaVersion !== 1 || + !entryId.accepted || + !organizationId.accepted || + (row.workspaceId !== null && !workspaceId?.accepted) || + (row.reservationId !== null && !reservationId?.accepted) || + !occurredAt.accepted || + !metrics.has(row.metric) || + !buckets.has(row.bucket) || + !positiveInteger(row.sequence) || + !text(row.idempotencyKey, 200) + ) + throw new Error('BUA_PERSISTED_USAGE_ENTRY_INVALID'); + return Object.freeze({ + schemaVersion: 1, + entryId: entryId.value, + tenantScope: scope, + metric: row.metric as UsageMetricV1, + bucket: row.bucket as UsageBucketV1, + deltaUnits: integerUnits(row.deltaUnits), + sequence: row.sequence, + ...(reservationId?.accepted ? { reservationId: reservationId.value } : {}), + idempotencyKey: row.idempotencyKey, + occurredAt: occurredAt.value, + }); +} + +function persistedReservation(row: UsageReservationDatabaseRowV1): UsageReservationV1 { + const reservationId = parseStableIdentifierV1(row.id); + const occurredAt = parseStrictUtcTimestampV1(row.createdAt.toISOString()); + const scope = persistedScope({ ...row, projectId: null }); + if ( + !reservationId.accepted || + !occurredAt.accepted || + !metrics.has(row.metric) || + !reservationStatuses.has(row.status) || + !positiveInteger(row.revision) || + !Number.isSafeInteger(integerUnits(row.reservedUnits)) + ) + throw new Error('BUA_PERSISTED_RESERVATION_INVALID'); + return Object.freeze({ + reservationId: reservationId.value, + tenantScope: scope, + metric: row.metric as UsageMetricV1, + reservedUnits: integerUnits(row.reservedUnits), + status: row.status as UsageReservationV1['status'], + createdAt: occurredAt.value, + revision: row.revision, + }); +} + +function planCreateData(plan: EntitlementPlanV1): EntitlementPlanCreateDataV1 { + return { + planCode: plan.planCode, + schemaVersion: plan.schemaVersion, + displayNameKey: plan.displayNameKey, + features: plan.features, + quotas: plan.quotas, + providerIndependent: plan.providerIndependent, + createdAt: new Date(), + }; +} + +function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapshotCreateDataV1 { + return { + ...databaseScope( + snapshot.workspaceId + ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + : { scopeType: 'organization', organizationId: snapshot.organizationId }, + ), + id: snapshot.snapshotId, + schemaVersion: snapshot.schemaVersion, + scopeKey: scopeKey( + snapshot.workspaceId + ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + : { scopeType: 'organization', organizationId: snapshot.organizationId }, + ), + planCode: snapshot.planCode, + status: snapshot.status, + revision: snapshot.revision, + securityEpoch: snapshot.securityEpoch, + effectiveAt: new Date(snapshot.effectiveAt), + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, + features: snapshot.features, + quotas: snapshot.quotas, + createdAt: new Date(), + }; +} + +function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV1 { + return { + ...databaseScope(entry.tenantScope), + id: entry.entryId, + schemaVersion: entry.schemaVersion, + scopeKey: scopeKey(entry.tenantScope), + metric: entry.metric, + bucket: entry.bucket, + deltaUnits: BigInt(entry.deltaUnits), + sequence: entry.sequence, + reservationId: entry.reservationId ?? null, + idempotencyKey: entry.idempotencyKey, + occurredAt: new Date(entry.occurredAt), + createdAt: new Date(), + }; +} + +function reservationCreateData( + reservation: UsageReservationV1, +): UsageReservationCreateDataV1 { + return { + ...databaseScope(reservation.tenantScope), + id: reservation.reservationId, + scopeKey: scopeKey(reservation.tenantScope), + metric: reservation.metric, + reservedUnits: BigInt(reservation.reservedUnits), + status: reservation.status, + createdAt: new Date(reservation.createdAt), + revision: reservation.revision, + updatedAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { + return ( + left.reservationId === right.reservationId && + left.metric === right.metric && + left.reservedUnits === right.reservedUnits && + JSON.stringify(left.tenantScope) === JSON.stringify(right.tenantScope) && + left.createdAt === right.createdAt + ); +} + +class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { + public constructor(private readonly client: EntitlementDatabaseClientV1) {} + + public async savePlan(plan: EntitlementPlanV1): Promise { + const existing = await this.client.entitlementPlanRecord.findUnique({ + where: { planCode: plan.planCode }, + }); + if (existing !== null) { + if (JSON.stringify(persistedPlan(existing)) !== JSON.stringify(plan)) + throw new Error('BUA_IMMUTABLE_PLAN'); + return; + } + await this.client.entitlementPlanRecord.create({ data: planCreateData(plan) }); + } + + public async findPlan(planCode: EntitlementPlanV1['planCode']): Promise { + const row = await this.client.entitlementPlanRecord.findUnique({ where: { planCode } }); + return row === null ? undefined : persistedPlan(row); + } + + public async saveSnapshot( + context: IamTenantContextV1, + snapshot: EntitlementSnapshotV1, + ): Promise { + const scope = snapshot.workspaceId + ? { scopeType: 'workspace' as const, organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; + if (!tenantScopeContainsV1(context.tenantScope, scope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.entitlementSnapshotRecord.findUnique({ + where: { id: snapshot.snapshotId }, + }); + if (existing !== null) { + if (JSON.stringify(persistedSnapshot(existing)) !== JSON.stringify(snapshot)) + throw new Error('BUA_IMMUTABLE_SNAPSHOT'); + return; + } + await this.client.entitlementSnapshotRecord.create({ data: snapshotCreateData(snapshot) }); + } + + public async findSnapshot( + context: IamTenantContextV1, + snapshotId: EntitlementSnapshotV1['snapshotId'], + ): Promise { + const row = await this.client.entitlementSnapshotRecord.findUnique({ where: { id: snapshotId } }); + if (row === null) return undefined; + const snapshot = persistedSnapshot(row); + const scope = snapshot.workspaceId + ? { scopeType: 'workspace' as const, organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; + return visible(context.tenantScope, scope) ? snapshot : undefined; + } + + public async listUsageState(context: IamTenantContextV1): Promise { + const [entryRows, reservationRows] = await Promise.all([ + this.client.usageLedgerEntryRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { sequence: 'asc' }, + }), + this.client.usageReservationRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { createdAt: 'asc' }, + }), + ]); + return Object.freeze({ + entries: Object.freeze( + entryRows + .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .map(persistedEntry), + ), + reservations: Object.freeze( + reservationRows + .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .map(persistedReservation), + ), + }); + } + + public async persistUsageState( + context: IamTenantContextV1, + state: UsageLedgerStateV1, + ): Promise { + for (const entry of state.entries) { + if (!tenantScopeContainsV1(context.tenantScope, entry.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.usageLedgerEntryRecord.findUnique({ + where: { id: entry.entryId }, + }); + if (existing !== null) { + if (JSON.stringify(persistedEntry(existing)) !== JSON.stringify(entry)) + throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); + continue; + } + await this.client.usageLedgerEntryRecord.create({ data: entryCreateData(entry) }); + } + for (const reservation of state.reservations) { + if (!tenantScopeContainsV1(context.tenantScope, reservation.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.usageReservationRecord.findUnique({ + where: { id: reservation.reservationId }, + }); + if (existing === null) { + await this.client.usageReservationRecord.create({ data: reservationCreateData(reservation) }); + continue; + } + const current = persistedReservation(existing); + if (JSON.stringify(current) === JSON.stringify(reservation)) continue; + if ( + !sameReservationExceptStatus(current, reservation) || + reservation.revision !== current.revision + 1 + ) + throw new Error('BUA_RESERVATION_CONFLICT'); + if (!this.client.usageReservationRecord.update) throw new Error('BUA_UPDATE_UNAVAILABLE'); + await this.client.usageReservationRecord.update({ + where: { id: reservation.reservationId }, + data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, + }); + } + } +} + +export class PrismaEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { + public constructor(private readonly client: EntitlementDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaEntitlementTransactionAdapter(transaction)), + ); + } + + public savePlan(plan: EntitlementPlanV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).savePlan(plan); + } + + public findPlan(planCode: EntitlementPlanV1['planCode']): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).findPlan(planCode); + } + + public saveSnapshot(context: IamTenantContextV1, snapshot: EntitlementSnapshotV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).saveSnapshot(context, snapshot); + } + + public findSnapshot( + context: IamTenantContextV1, + snapshotId: EntitlementSnapshotV1['snapshotId'], + ): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).findSnapshot(context, snapshotId); + } + + public listUsageState(context: IamTenantContextV1): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).listUsageState(context); + } + + public persistUsageState( + context: IamTenantContextV1, + state: UsageLedgerStateV1, + ): Promise { + return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state); + } +} diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 1f1d896e..643667cd 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -1,6 +1,10 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { InMemoryEntitlementRepositoryAdapter } from './adapter/in-memory-entitlement-repository.adapter.js'; +import { + PrismaEntitlementRepositoryAdapter, + type EntitlementDatabaseClientV1, +} from './adapter/prisma-entitlement-repository.adapter.js'; import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; import { ENTITLEMENT_REPOSITORY_PORT, @@ -11,12 +15,18 @@ export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVI export interface BuaModuleOptions { readonly entitlementRepository?: EntitlementRepositoryPortV1; + /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ + readonly entitlementDatabase?: EntitlementDatabaseClientV1; } @Module({}) export class BuaModule { public static register(options: BuaModuleOptions = {}): DynamicModule { - const repository = options.entitlementRepository ?? new InMemoryEntitlementRepositoryAdapter(); + const repository = + options.entitlementRepository ?? + (options.entitlementDatabase === undefined + ? new InMemoryEntitlementRepositoryAdapter() + : new PrismaEntitlementRepositoryAdapter(options.entitlementDatabase)); const service = new EntitlementAdmissionService(repository); return { module: BuaModule, diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts new file mode 100644 index 00000000..122cb3ea --- /dev/null +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createPlanV1, + type EntitlementPlanV1, + type EntitlementSnapshotV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaEntitlementRepositoryAdapter, + type EntitlementDatabaseClientV1, +} from '../../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; +import { EntitlementAdmissionService } from '../../../src/features/bua/application/entitlement-admission.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000201'; +const workspaceId = '00000000-0000-4000-8000-000000000202'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000203'; +const actorId = '00000000-0000-4000-8000-000000000210'; +const correlationId = '00000000-0000-4000-8000-000000000211'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(workspace = workspaceId, idempotencyKey = 'bua') { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid entitlement context'); + return result.value; +} + +function plan(): EntitlementPlanV1 { + const result = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 3 }], + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid plan'); + return result.value; +} + +function snapshot(): EntitlementSnapshotV1 { + return { + schemaVersion: 1, + snapshotId: stable('00000000-0000-4000-8000-000000000220'), + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + planCode: 'development', + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z' as StrictUtcTimestampV1, + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 3 }], + }; +} + +function delegate>(rows: TRow[]) { + return { + create({ data }: { readonly data: TRow }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findUnique({ where }: { readonly where: { readonly id?: string; readonly planCode?: string } }) { + const key = where.id ?? where.planCode; + return Promise.resolve(rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null); + }, + findMany({ + where, + orderBy, + }: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }) { + const filtered = rows.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; + return Promise.resolve( + [...filtered].sort((left, right) => { + if (!field) return 0; + const comparison = left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; + return direction === 'desc' ? -comparison : comparison; + }), + ); + }, + update({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Record; + }) { + const index = rows.findIndex((row) => row['id'] === where.id); + if (index < 0) throw new Error('row not found'); + rows[index] = { ...rows[index], ...data } as TRow; + return Promise.resolve(rows[index]); + }, + }; +} + +function client(): EntitlementDatabaseClientV1 { + const planRows: Record[] = []; + const snapshotRows: Record[] = []; + const entryRows: Record[] = []; + const reservationRows: Record[] = []; + const database = { + entitlementPlanRecord: delegate(planRows), + entitlementSnapshotRecord: delegate(snapshotRows), + usageLedgerEntryRecord: delegate(entryRows), + usageReservationRecord: delegate(reservationRows), + async $transaction( + work: (transaction: EntitlementDatabaseClientV1) => Promise, + ): Promise { + return work(database as unknown as EntitlementDatabaseClientV1); + }, + }; + return database as unknown as EntitlementDatabaseClientV1; +} + +function admissionInput(idempotencyKey: string, suffix: string) { + const ids: Record = { + '1': { + reservationId: '00000000-0000-4000-8000-000000000221', + entryId: '00000000-0000-4000-8000-000000000231', + }, + '2': { + reservationId: '00000000-0000-4000-8000-000000000222', + entryId: '00000000-0000-4000-8000-000000000232', + }, + }; + const selected = ids[suffix]; + if (!selected) throw new Error('unknown fixture suffix'); + return { + snapshotId: snapshot().snapshotId, + feature: 'job.execute', + reservationId: stable(selected.reservationId), + entryId: stable(selected.entryId), + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + metric: 'job_count', + requestedUnits: 1, + idempotencyKey, + now: '2026-01-01T00:01:00.000Z', + }; +} + +void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persists immutable plans, snapshots, and scoped usage', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.savePlan(plan()); + await repository.saveSnapshot(context(workspaceId, 'seed-1'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const result = await service.admit(context(workspaceId, 'admit-1'), admissionInput('admit-1', '1')); + assert.equal(result.accepted, true); + assert.equal((await repository.listUsageState(context(workspaceId, 'read'))).entries.length, 1); + assert.equal((await repository.listUsageState(context(siblingWorkspaceId, 'sibling'))).entries.length, 0); + assert.equal( + await repository.findSnapshot(context(siblingWorkspaceId, 'snapshot-sibling'), snapshot().snapshotId), + undefined, + ); +}); + +void test('[BUA-012] Prisma entitlement adapter applies reservation status revisions and preserves idempotent settlement', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.saveSnapshot(context(workspaceId, 'seed-2'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit(context(workspaceId, 'admit-2'), admissionInput('admit-2', '2')); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + const finalized = await service.finalize(context(workspaceId, 'finish-2'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000322'), + commitEntryId: stable('00000000-0000-4000-8000-000000000323'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-2', + }); + assert.equal(finalized.accepted, true); + assert.deepEqual( + await service.finalize(context(workspaceId, 'finish-2'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000322'), + commitEntryId: stable('00000000-0000-4000-8000-000000000323'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-2', + }), + finalized, + ); +}); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index ca00ad80..34c8e59e 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -6,6 +6,8 @@ import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; import { BuaModule } from '../../src/features/bua/bua.module.js'; +import { ENTITLEMENT_REPOSITORY_PORT } from '../../src/features/bua/application/entitlement-repository.port.js'; +import { PrismaEntitlementRepositoryAdapter } from '../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register(); @@ -36,3 +38,18 @@ void test('[AUD-001] configured audit persistence uses the Prisma adapter instea if (!provider || !('useValue' in provider)) return; assert.ok(provider.useValue instanceof PrismaAuditRepositoryAdapter); }); + +void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { + const database = {} as never; + const registered = BuaModule.register({ entitlementDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === ENTITLEMENT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaEntitlementRepositoryAdapter); +}); From 0f46d1662f19a557a181a69eaff158e584424909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:45:45 +0700 Subject: [PATCH 06/32] feat(iam): add versioned PKCE boundary --- packages/domain/package.json | 4 ++ packages/domain/src/pkce/v1.ts | 73 +++++++++++++++++++++ packages/domain/src/v1.ts | 1 + packages/domain/test/pkce-v1.test.mjs | 40 +++++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 5 files changed, 120 insertions(+) create mode 100644 packages/domain/src/pkce/v1.ts create mode 100644 packages/domain/test/pkce-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index ea09ca1c..65fcca7d 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -52,6 +52,10 @@ "types": "./src/data-mode/v1.ts", "import": "./dist/data-mode/v1.js" }, + "./pkce/v1": { + "types": "./src/pkce/v1.ts", + "import": "./dist/pkce/v1.js" + }, "./artifact/v1": { "types": "./src/artifact/v1.ts", "import": "./dist/artifact/v1.js" diff --git a/packages/domain/src/pkce/v1.ts b/packages/domain/src/pkce/v1.ts new file mode 100644 index 00000000..a274a316 --- /dev/null +++ b/packages/domain/src/pkce/v1.ts @@ -0,0 +1,73 @@ +/** IAM-002: provider-independent RFC 7636 S256 validation for native sign-in. */ +export const PKCE_SCHEMA_VERSION_V1 = 1 as const; +export const PKCE_VERIFIER_MIN_LENGTH_V1 = 43 as const; +export const PKCE_VERIFIER_MAX_LENGTH_V1 = 128 as const; + +export interface PkceChallengeV1 { + readonly schemaVersion: typeof PKCE_SCHEMA_VERSION_V1; + readonly method: 'S256'; + readonly challenge: string; +} + +export type PkceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: 'INVALID_VERIFIER' | 'CRYPTO_UNAVAILABLE' }; + +export interface PkceHashPortV1 { + sha256Base64Url(value: string): string; +} + +function validVerifier(input: unknown): input is string { + return ( + typeof input === 'string' && + input.length >= PKCE_VERIFIER_MIN_LENGTH_V1 && + input.length <= PKCE_VERIFIER_MAX_LENGTH_V1 && + /^[A-Za-z0-9\-._~]+$/u.test(input) + ); +} + +function equalStrings(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +export function createPkceChallengeV1( + verifierInput: unknown, + hashPort: PkceHashPortV1, +): PkceResultV1 { + if (!validVerifier(verifierInput)) return Object.freeze({ accepted: false, code: 'INVALID_VERIFIER' }); + try { + const challenge = hashPort.sha256Base64Url(verifierInput); + if (!/^[A-Za-z0-9_-]{43}$/u.test(challenge)) + return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); + return Object.freeze({ + accepted: true, + value: Object.freeze({ schemaVersion: PKCE_SCHEMA_VERSION_V1, method: 'S256' as const, challenge }), + }); + } catch { + return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); + } +} + +export function verifyPkceChallengeV1( + verifierInput: unknown, + challengeInput: unknown, + hashPort: PkceHashPortV1, +): boolean { + if (typeof challengeInput !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(challengeInput)) return false; + const created = createPkceChallengeV1(verifierInput, hashPort); + return created.accepted && equalStrings(created.value.challenge, challengeInput); +} + +export function isAllowedRedirectUriV1(input: unknown): boolean { + if (typeof input !== 'string' || input.length > 200) return false; + if (input === 'com.databreeze.desktop:/oauth2redirect' || input === 'com.databreeze.android:/oauth2redirect') + return true; + const match = /^http:\/\/127\.0\.0\.1:(\d{1,5})\/callback$/u.exec(input); + if (!match) return false; + const port = Number(match[1]); + return port >= 1 && port <= 65_535; +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index e0073f2d..f6164c55 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -23,5 +23,6 @@ export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; export * from './data-mode/v1.js'; +export * from './pkce/v1.js'; export * from './permissions/v1.js'; export * from './tenant-scope/v1.js'; diff --git a/packages/domain/test/pkce-v1.test.mjs b/packages/domain/test/pkce-v1.test.mjs new file mode 100644 index 00000000..294fdc75 --- /dev/null +++ b/packages/domain/test/pkce-v1.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + createPkceChallengeV1, + isAllowedRedirectUriV1, + verifyPkceChallengeV1, +} from '../dist/pkce/v1.js'; + +const hashPort = { + sha256Base64Url(value) { + return createHash('sha256').update(value, 'utf8').digest('base64url'); + }, +}; + +void test('[IAM-002] PKCE accepts an RFC 7636 verifier and derives an S256 challenge', async () => { + const verifier = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + const created = createPkceChallengeV1(verifier, hashPort); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.method, 'S256'); + assert.equal(verifyPkceChallengeV1(verifier, created.value.challenge, hashPort), true); + assert.equal(verifyPkceChallengeV1(`${verifier}x`, created.value.challenge, hashPort), false); +}); + +void test('[IAM-002] PKCE rejects malformed verifiers and plain challenges', async () => { + assert.deepEqual(createPkceChallengeV1('short', hashPort), { + accepted: false, + code: 'INVALID_VERIFIER', + }); + assert.equal(verifyPkceChallengeV1('short', 'plain', hashPort), false); +}); + +void test('[IAM-002] native redirect allowlist requires an exact loopback or app callback', () => { + assert.equal(isAllowedRedirectUriV1('http://127.0.0.1:43123/callback'), true); + assert.equal(isAllowedRedirectUriV1('com.databreeze.desktop:/oauth2redirect'), true); + assert.equal(isAllowedRedirectUriV1('https://evil.example/callback'), false); + assert.equal(isAllowedRedirectUriV1('http://127.0.0.1:43123/callback?next=https://evil'), false); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index f0199a0e..c1eb95e6 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -21,6 +21,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-sync/v1', './device-capability/v1', './data-mode/v1', + './pkce/v1', './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', @@ -56,6 +57,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.IDENTITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.PKCE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); From 012413913e2e1398e441d4d2f344e18a8de5bcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:46:53 +0700 Subject: [PATCH 07/32] feat(iam): add versioned CSRF boundary --- packages/domain/package.json | 4 ++ packages/domain/src/csrf/v1.ts | 43 +++++++++++++++++++++ packages/domain/src/v1.ts | 1 + packages/domain/test/csrf-v1.test.mjs | 34 ++++++++++++++++ packages/domain/test/public-api-v1.test.mjs | 2 + 5 files changed, 84 insertions(+) create mode 100644 packages/domain/src/csrf/v1.ts create mode 100644 packages/domain/test/csrf-v1.test.mjs diff --git a/packages/domain/package.json b/packages/domain/package.json index 65fcca7d..b2fe790f 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -56,6 +56,10 @@ "types": "./src/pkce/v1.ts", "import": "./dist/pkce/v1.js" }, + "./csrf/v1": { + "types": "./src/csrf/v1.ts", + "import": "./dist/csrf/v1.js" + }, "./artifact/v1": { "types": "./src/artifact/v1.ts", "import": "./dist/artifact/v1.js" diff --git a/packages/domain/src/csrf/v1.ts b/packages/domain/src/csrf/v1.ts new file mode 100644 index 00000000..a1c2e1c3 --- /dev/null +++ b/packages/domain/src/csrf/v1.ts @@ -0,0 +1,43 @@ +/** IAM-002: browser mutation requests use a validated, session-bound CSRF token. */ +export const CSRF_SCHEMA_VERSION_V1 = 1 as const; + +const MIN_TOKEN_LENGTH_V1 = 32; +const MAX_TOKEN_LENGTH_V1 = 256; +const TOKEN_PATTERN_V1 = /^[A-Za-z0-9_-]+$/u; + +export type CsrfTokenResultV1 = + | { readonly accepted: true; readonly value: string } + | { readonly accepted: false; readonly code: 'INVALID_TOKEN' }; + +function rejected(): CsrfTokenResultV1 { + return Object.freeze({ accepted: false as const, code: 'INVALID_TOKEN' as const }); +} + +/** Validate the encoded token before it is bound to a browser session. */ +export function validateCsrfTokenV1(input: unknown): CsrfTokenResultV1 { + if (typeof input !== 'string') return rejected(); + if (input.length < MIN_TOKEN_LENGTH_V1 || input.length > MAX_TOKEN_LENGTH_V1) return rejected(); + if (!TOKEN_PATTERN_V1.test(input)) return rejected(); + return Object.freeze({ accepted: true as const, value: input }); +} + +/** + * Compare cookie and header values without an early return on the first mismatch. + * Invalid values are deliberately treated as a mismatch so callers fail closed. + */ +export function compareCsrfTokensV1(cookieToken: unknown, headerToken: unknown): boolean { + const cookie = validateCsrfTokenV1(cookieToken); + const header = validateCsrfTokenV1(headerToken); + if (!cookie.accepted || !header.accepted) return false; + + const left = cookie.value; + const right = header.value; + const length = Math.max(left.length, right.length); + let difference = left.length ^ right.length; + for (let index = 0; index < length; index += 1) { + const leftCode = index < left.length ? left.charCodeAt(index) : 0; + const rightCode = index < right.length ? right.charCodeAt(index) : 0; + difference |= leftCode ^ rightCode; + } + return difference === 0; +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index f6164c55..4f5b9a48 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -24,5 +24,6 @@ export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; export * from './data-mode/v1.js'; export * from './pkce/v1.js'; +export * from './csrf/v1.js'; export * from './permissions/v1.js'; export * from './tenant-scope/v1.js'; diff --git a/packages/domain/test/csrf-v1.test.mjs b/packages/domain/test/csrf-v1.test.mjs new file mode 100644 index 00000000..4dc93cb6 --- /dev/null +++ b/packages/domain/test/csrf-v1.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareCsrfTokensV1, + CSRF_SCHEMA_VERSION_V1, + validateCsrfTokenV1, +} from '../dist/csrf/v1.js'; + +test('[IAM-002] CSRF accepts a high-entropy token and compares equal values', () => { + const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + const result = validateCsrfTokenV1(token); + + assert.equal(CSRF_SCHEMA_VERSION_V1, 1); + assert.deepEqual(result, { accepted: true, value: token }); + assert.equal(compareCsrfTokensV1(token, token), true); +}); + +test('[IAM-002] CSRF rejects missing, malformed, and oversized tokens', () => { + assert.equal(validateCsrfTokenV1(undefined).accepted, false); + assert.equal(validateCsrfTokenV1('too-short').accepted, false); + assert.equal(validateCsrfTokenV1('a'.repeat(257)).accepted, false); + assert.equal(validateCsrfTokenV1('token with spaces').accepted, false); + assert.equal(validateCsrfTokenV1('token\nwith-control').accepted, false); +}); + +test('[IAM-002] CSRF comparison fails closed for mismatches and malformed inputs', () => { + const valid = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + + assert.equal(compareCsrfTokensV1(valid, `${valid}x`), false); + assert.equal(compareCsrfTokensV1(valid, 'different-token-value-123456789012345678901234'), false); + assert.equal(compareCsrfTokensV1(valid, undefined), false); + assert.equal(compareCsrfTokensV1('short', valid), false); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index c1eb95e6..f46407dc 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -22,6 +22,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-capability/v1', './data-mode/v1', './pkce/v1', + './csrf/v1', './artifact/v1', './artifact-intake/v1', './artifact-governance/v1', @@ -58,6 +59,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PKCE_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.CSRF_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_SCHEMA_VERSION_V1, 1); From 46c2ee529b1b7f77c723b78e63ae35fe74155436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:52:11 +0700 Subject: [PATCH 08/32] feat(iam): add durable credential lookup --- .../prisma-credential-lookup.adapter.ts | 168 ++++++++++++++++++ .../iam/application/authentication.port.ts | 1 + services/api/src/features/iam/iam.module.ts | 26 ++- .../foundation-module-composition.test.ts | 29 +++ .../iam/prisma-credential-lookup.test.ts | 115 ++++++++++++ 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts create mode 100644 services/api/test/features/iam/prisma-credential-lookup.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts new file mode 100644 index 00000000..582411fb --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -0,0 +1,168 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { + AuthenticatedPrincipalV1, + CredentialLookupPortV1, +} from '../application/authentication.port.js'; + +export interface UserIdentityDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; + readonly securityEpoch: number; +} + +export interface PasswordCredentialDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly algorithm: string; + readonly encodedHash: string; +} + +export interface MembershipIdentityDatabaseRowV1 { + readonly id: string; + readonly principalId: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly scopeType: string; + readonly status: string; +} + +export interface WorkspaceIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly status: string; +} + +export interface OrganizationIdentityDatabaseRowV1 { + readonly id: string; + readonly status: string; +} + +export interface MfaFactorDatabaseRowV1 { + readonly id: string; +} + +interface UniqueDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; +} + +interface ListDelegateV1 { + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; +} + +export interface CredentialLookupDatabaseClientV1 { + readonly userIdentity: UniqueDelegateV1; + readonly passwordCredential: UniqueDelegateV1; + readonly membershipIdentity: ListDelegateV1; + readonly workspaceIdentity: UniqueDelegateV1; + readonly organizationIdentity: UniqueDelegateV1; + readonly mfaFactor: ListDelegateV1; +} + +interface ActiveMembershipV1 { + readonly organizationId: string; + readonly workspaceId: string; +} + +function stableId(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function activeMembership( + row: MembershipIdentityDatabaseRowV1, + userId: string, +): ActiveMembershipV1 | undefined { + if (row.principalId !== userId || row.status !== 'ACTIVE' || row.scopeType !== 'WORKSPACE') + return undefined; + if (row.projectId !== null) return undefined; + const organizationId = stableId(row.organizationId); + const workspaceId = stableId(row.workspaceId); + if (!organizationId || !workspaceId) return undefined; + return { organizationId, workspaceId }; +} + +/** + * PostgreSQL-backed credential lookup. The adapter deliberately performs the + * complete ancestry checks instead of trusting a membership row to establish + * tenant authority. + */ +export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { + public constructor(private readonly client: CredentialLookupDatabaseClientV1) {} + + public async findCredential(emailInput: string): Promise< + | { + readonly principal: AuthenticatedPrincipalV1; + readonly credential: { readonly algorithm: 'argon2id'; readonly encodedHash: string }; + } + | undefined + > { + const normalized = normalizeEmailAddressV1(emailInput); + if (!normalized.accepted) return undefined; + const user = await this.client.userIdentity.findUnique({ where: { email: normalized.value } }); + if (!user || user.status !== 'ACTIVE') return undefined; + const userId = stableId(user.id); + if (!userId || !Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) + return undefined; + + const [credential, memberships] = await Promise.all([ + this.client.passwordCredential.findUnique({ where: { userId } }), + this.client.membershipIdentity.findMany({ + where: { principalId: userId, status: 'ACTIVE' }, + orderBy: { createdAt: 'asc' }, + }), + ]); + if ( + !credential || + credential.userId !== userId || + credential.algorithm !== 'argon2id' || + typeof credential.encodedHash !== 'string' || + credential.encodedHash.length === 0 || + credential.encodedHash.length > 768 + ) + return undefined; + + const selected = memberships + .map((membership) => activeMembership(membership, userId)) + .find((membership): membership is ActiveMembershipV1 => membership !== undefined); + if (!selected) return undefined; + + const [organization, workspace, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), + this.client.workspaceIdentity.findUnique({ where: { id: selected.workspaceId } }), + this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), + ]); + if ( + !organization || + organization.id !== selected.organizationId || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== selected.workspaceId || + workspace.organizationId !== selected.organizationId || + workspace.status !== 'ACTIVE' + ) + return undefined; + + return Object.freeze({ + principal: Object.freeze({ + userId, + organizationId: selected.organizationId, + workspaceId: selected.workspaceId, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + }), + credential: Object.freeze({ + algorithm: 'argon2id' as const, + encodedHash: credential.encodedHash, + }), + }); + } +} diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index 1d007b4d..766dff77 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -2,6 +2,7 @@ import type { PasswordCredentialService } from './password-credential.service.js export const AUTHENTICATION_PORT = Symbol('AUTHENTICATION_PORT'); export const AUTHENTICATION_USE_CASE = Symbol('AUTHENTICATION_USE_CASE'); +export const CREDENTIAL_LOOKUP_PORT = Symbol('CREDENTIAL_LOOKUP_PORT'); export interface AuthenticatedPrincipalV1 { readonly userId: string; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 3d389873..aff6d2f4 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -4,12 +4,17 @@ import { AuthenticationController } from './api/authentication.controller.js'; import { AuthenticationService } from './application/authentication.service.js'; import { AUTHENTICATION_USE_CASE, + CREDENTIAL_LOOKUP_PORT, type CredentialLookupPortV1, type AuthenticationUseCaseV1, type SessionIssuerPortV1, } from './application/authentication.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; +import { + PrismaCredentialLookupAdapter, + type CredentialLookupDatabaseClientV1, +} from './adapter/prisma-credential-lookup.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -35,6 +40,7 @@ import { export interface IamModuleOptions { readonly authentication?: AuthenticationUseCaseV1; readonly credentials?: CredentialLookupPortV1; + readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; readonly deviceIdentityService?: DeviceIdentityService; @@ -59,6 +65,16 @@ export function composeAuthenticationUseCase(options: IamModuleOptions): Authent @Module({}) export class IamModule { static register(options: IamModuleOptions = {}): DynamicModule { + const credentials = + options.credentials ?? + (options.credentialDatabase === undefined + ? undefined + : new PrismaCredentialLookupAdapter(options.credentialDatabase)); + const authentication = + options.authentication ?? + (credentials + ? composeAuthenticationUseCase({ ...options, credentials }) + : composeAuthenticationUseCase(options)); const deviceIdentityRepository = options.deviceIdentityRepository ?? (options.deviceIdentityDatabase === undefined @@ -76,8 +92,16 @@ export class IamModule { providers: [ { provide: AUTHENTICATION_USE_CASE, - useValue: composeAuthenticationUseCase(options), + useValue: authentication, }, + ...(credentials + ? [ + { + provide: CREDENTIAL_LOOKUP_PORT, + useValue: credentials, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 34c8e59e..39cf901a 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { AppModule } from '../../src/app.module.js'; +import { IamModule } from '../../src/features/iam/iam.module.js'; +import { + AUTHENTICATION_USE_CASE, + CREDENTIAL_LOOKUP_PORT, +} from '../../src/features/iam/application/authentication.port.js'; +import { PrismaCredentialLookupAdapter } from '../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -39,6 +45,29 @@ void test('[AUD-001] configured audit persistence uses the Prisma adapter instea assert.ok(provider.useValue instanceof PrismaAuditRepositoryAdapter); }); +void test('[IAM-001] configured credential persistence uses the Prisma adapter boundary', () => { + const database = {} as never; + const registered = IamModule.register({ credentialDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === CREDENTIAL_LOOKUP_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaCredentialLookupAdapter); + const authentication = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === AUTHENTICATION_USE_CASE, + ); + assert.ok(authentication && 'useValue' in authentication); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts new file mode 100644 index 00000000..42221b1d --- /dev/null +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaCredentialLookupAdapter, + type CredentialLookupDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const membershipId = '00000000-0000-4000-8000-000000000004'; + +function database( + overrides: Partial = {}, +): CredentialLookupDatabaseClientV1 { + return { + userIdentity: { + findUnique: async () => ({ + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 3, + }), + }, + passwordCredential: { + findUnique: async () => ({ + id: '00000000-0000-4000-8000-000000000005', + userId, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=19456,t=2,p=1$hash', + }), + }, + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + workspaceIdentity: { + findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), + }, + mfaFactor: { + findMany: async () => [{ id: '00000000-0000-4000-8000-000000000006' }], + }, + ...overrides, + }; +} + +void test('[IAM-001, IAM-002, IAM-009] credential lookup returns only an active, scoped principal', async () => { + const adapter = new PrismaCredentialLookupAdapter(database()); + const result = await adapter.findCredential('USER@EXAMPLE.COM'); + + assert.deepEqual(result, { + principal: { + userId, + organizationId, + workspaceId, + securityEpoch: 3, + mfaRequired: true, + }, + credential: { + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=19456,t=2,p=1$hash', + }, + }); +}); + +void test('[IAM-001, IAM-009] lookup fails closed when persisted tenancy is inactive or malformed', async () => { + const inactive = new PrismaCredentialLookupAdapter( + database({ + organizationIdentity: { findUnique: async () => ({ id: organizationId, status: 'SUSPENDED' }) }, + }), + ); + assert.equal(await inactive.findCredential('user@example.com'), undefined); + + const malformed = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId: 'not-a-uuid', + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + }), + ); + assert.equal(await malformed.findCredential('user@example.com'), undefined); +}); + +void test('[IAM-001, IAM-002] lookup does not authenticate users without an active workspace membership', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { findMany: async () => [] }, + }), + ); + + assert.equal(await adapter.findCredential('user@example.com'), undefined); +}); From 881b5d5f18e83cb1656413f19ea023b63a42221b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 21:53:53 +0700 Subject: [PATCH 09/32] refactor(iam): make session principal lookup async --- .../iam/adapter/in-memory-session-lifecycle.adapter.ts | 3 ++- .../src/features/iam/application/session-lifecycle.port.ts | 2 +- services/api/test/features/iam/session-lifecycle.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index 6cd843fe..8364aac0 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -180,7 +180,8 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return true; } - public findPrincipal(sessionIdInput: unknown): AuthenticatedPrincipalV1 | undefined { + public async findPrincipal(sessionIdInput: unknown): Promise { + await Promise.resolve(); if (typeof sessionIdInput !== 'string') return undefined; const session = this.sessions.get(sessionIdInput); if (!session || session.familyStatus !== 'ACTIVE') return undefined; diff --git a/services/api/src/features/iam/application/session-lifecycle.port.ts b/services/api/src/features/iam/application/session-lifecycle.port.ts index 99060783..ac6ac2cb 100644 --- a/services/api/src/features/iam/application/session-lifecycle.port.ts +++ b/services/api/src/features/iam/application/session-lifecycle.port.ts @@ -28,5 +28,5 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { clientPlatform: 'android' | 'desktop' | 'web', ): Promise; revoke(sessionId: unknown): Promise; - findPrincipal(sessionId: unknown): AuthenticatedPrincipalV1 | undefined; + findPrincipal(sessionId: unknown): Promise; } diff --git a/services/api/test/features/iam/session-lifecycle.test.ts b/services/api/test/features/iam/session-lifecycle.test.ts index 94ce3438..396edc60 100644 --- a/services/api/test/features/iam/session-lifecycle.test.ts +++ b/services/api/test/features/iam/session-lifecycle.test.ts @@ -19,7 +19,7 @@ void test('[IAM-005, IAM-006] session issuer creates opaque bounded sessions', a assert.match(session.accessToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); assert.match(session.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); assert.equal(session.accessExpiresAt, '2026-01-01T00:15:00.000Z'); - assert.equal(adapter.findPrincipal(session.sessionId)?.userId, principal.userId); + assert.equal((await adapter.findPrincipal(session.sessionId))?.userId, principal.userId); }); void test('[IAM-005] refresh rotation is single-use and reuse revokes the family', async () => { @@ -32,7 +32,7 @@ void test('[IAM-005] refresh rotation is single-use and reuse revokes the family assert.notEqual(rotated.value.refreshToken, first.refreshToken); const reuse = await adapter.refresh(first.refreshToken, 'desktop'); assert.deepEqual(reuse, { accepted: false, code: 'REUSE_DETECTED' }); - assert.equal(adapter.findPrincipal(first.sessionId), undefined); + assert.equal(await adapter.findPrincipal(first.sessionId), undefined); assert.deepEqual(await adapter.refresh(rotated.value.refreshToken, 'desktop'), { accepted: false, code: 'REVOKED_FAMILY', From a10e443906faf37deb412baaea2bc233e3cde6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:02:30 +0700 Subject: [PATCH 10/32] feat(iam): add durable session lifecycle --- .../prisma-session-lifecycle.adapter.ts | 450 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 28 +- .../foundation-module-composition.test.ts | 17 + .../iam/prisma-session-lifecycle.test.ts | 174 +++++++ 4 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts create mode 100644 services/api/test/features/iam/prisma-session-lifecycle.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts new file mode 100644 index 00000000..1adc7607 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -0,0 +1,450 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { + createSessionRecordV1, + rotateRefreshFamilyV1, + type SessionRecordV1, +} from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + AuthenticationSessionV1, + AuthenticatedPrincipalV1, + SessionIssuerPortV1, +} from '../application/authentication.port.js'; +import type { + SessionLifecyclePortV1, + SessionRefreshFailureCodeV1, + SessionRefreshResultV1, +} from '../application/session-lifecycle.port.js'; + +export interface SessionRecordDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly familyId: string; + readonly issuedAt: Date; + readonly accessExpiresAt: Date; + readonly inactivityExpiresAt: Date; + readonly absoluteExpiresAt: Date; + readonly status: string; + readonly revokedAt?: Date | null; +} + +export interface RefreshTokenDatabaseRowV1 { + readonly id: string; + readonly sessionId: string; + readonly familyId: string; + readonly tokenDigest: string; + readonly status: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly usedAt?: Date | null; +} + +export interface SessionUserDatabaseRowV1 { + readonly id: string; + readonly status: string; + readonly securityEpoch: number; +} + +export interface SessionMembershipDatabaseRowV1 { + readonly id: string; + readonly principalId: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly scopeType: string; + readonly status: string; +} + +export interface SessionWorkspaceDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly status: string; +} + +export interface SessionOrganizationDatabaseRowV1 { + readonly id: string; + readonly status: string; +} + +export interface SessionMfaFactorDatabaseRowV1 { + readonly id: string; +} + +interface SessionDelegateV1 { + create(input: { + readonly data: SessionRecordDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +interface RefreshTokenDelegateV1 { + create(input: { + readonly data: RefreshTokenDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly tokenDigest: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface UniqueDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; +} + +interface ListDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface SessionLifecycleDatabaseClientV1 { + readonly sessionRecord: SessionDelegateV1; + readonly refreshTokenRecord: RefreshTokenDelegateV1; + readonly userIdentity: UniqueDelegateV1; + readonly membershipIdentity: ListDelegateV1; + readonly workspaceIdentity: UniqueDelegateV1; + readonly organizationIdentity: UniqueDelegateV1; + readonly mfaFactor: ListDelegateV1; + $transaction( + work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, + ): Promise; +} + +export interface SessionLifecycleAdapterOptionsV1 { + readonly clock?: () => Date; +} + +const ACCESS_TOKEN_SECONDS_V1 = 15 * 60; +const INACTIVITY_SECONDS_V1 = 60 * 60; +const ABSOLUTE_SECONDS_V1 = 30 * 24 * 60 * 60; + +function stableIdentifier(input: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error('IAM_INVALID_IDENTIFIER'); + return parsed.value; +} + +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function addSeconds(now: Date, seconds: number, upperBound?: string): string { + const candidate = new Date(now.getTime() + seconds * 1_000); + if (!upperBound || candidate.toISOString() <= upperBound) return candidate.toISOString(); + return upperBound; +} + +function digestToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('base64url'); +} + +function tokenFor(tokenId: string): string { + return `${tokenId}.${randomBytes(32).toString('base64url')}`; +} + +function sessionFromRow(row: SessionRecordDatabaseRowV1): SessionRecordV1 { + const created = createSessionRecordV1({ + sessionId: row.id, + userId: row.userId, + familyId: row.familyId, + issuedAt: timestamp(row.issuedAt), + accessExpiresAt: timestamp(row.accessExpiresAt), + inactivityExpiresAt: timestamp(row.inactivityExpiresAt), + absoluteExpiresAt: timestamp(row.absoluteExpiresAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_SESSION_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'REVOKED' && row.status !== 'EXPIRED') + throw new Error('IAM_PERSISTED_SESSION_INVALID'); + return Object.freeze({ ...created.value, status: row.status }); +} + +function tokenFromRow(row: RefreshTokenDatabaseRowV1): { + readonly id: StableIdentifierV1; + readonly sessionId: StableIdentifierV1; + readonly familyId: StableIdentifierV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly status: 'ACTIVE' | 'USED' | 'REVOKED' | 'EXPIRED'; +} { + const id = stableIdentifier(row.id); + const sessionId = stableIdentifier(row.sessionId); + const familyId = stableIdentifier(row.familyId); + const expiresAt = timestamp(row.expiresAt); + if (!expiresAt || row.tokenDigest.length < 32 || row.tokenDigest.length > 128) + throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'USED' && row.status !== 'REVOKED' && row.status !== 'EXPIRED') + throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); + return { id, sessionId, familyId, expiresAt, status: row.status }; +} + +function successfulSession(session: AuthenticationSessionV1): SessionRefreshResultV1 { + return Object.freeze({ accepted: true, value: Object.freeze(session) }); +} + +/** PostgreSQL-backed, transactional session and refresh-token family lifecycle. */ +export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { + private readonly clock: () => Date; + + public constructor( + private readonly client: SessionLifecycleDatabaseClientV1, + options: SessionLifecycleAdapterOptionsV1 = {}, + ) { + this.clock = options.clock ?? (() => new Date()); + } + + public async issue( + principal: AuthenticatedPrincipalV1, + clientPlatform: 'android' | 'desktop' | 'web', + ): Promise { + void clientPlatform; + const now = this.clock(); + const sessionId = stableIdentifier(randomUUID()); + const familyId = stableIdentifier(randomUUID()); + const refreshTokenId = stableIdentifier(randomUUID()); + const created = createSessionRecordV1({ + sessionId, + userId: principal.userId, + familyId, + issuedAt: now.toISOString(), + accessExpiresAt: addSeconds(now, ACCESS_TOKEN_SECONDS_V1), + inactivityExpiresAt: addSeconds(now, INACTIVITY_SECONDS_V1), + absoluteExpiresAt: addSeconds(now, ABSOLUTE_SECONDS_V1), + }); + if (!created.accepted) throw new Error(`IAM_${created.code}`); + const refreshToken = tokenFor(refreshTokenId); + const record = created.value; + await this.client.$transaction(async (transaction) => { + await transaction.sessionRecord.create({ + data: { + id: record.sessionId, + userId: record.userId, + familyId: record.familyId, + issuedAt: new Date(record.issuedAt), + accessExpiresAt: new Date(record.accessExpiresAt), + inactivityExpiresAt: new Date(record.inactivityExpiresAt), + absoluteExpiresAt: new Date(record.absoluteExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); + await transaction.refreshTokenRecord.create({ + data: { + id: refreshTokenId, + sessionId: record.sessionId, + familyId: record.familyId, + tokenDigest: digestToken(refreshToken), + status: 'ACTIVE', + issuedAt: new Date(record.issuedAt), + expiresAt: new Date(record.absoluteExpiresAt), + usedAt: null, + }, + }); + }); + return { + sessionId: record.sessionId, + accessToken: tokenFor(stableIdentifier(randomUUID())), + refreshToken, + accessExpiresAt: record.accessExpiresAt, + }; + } + + public async refresh( + refreshTokenInput: unknown, + clientPlatform: 'android' | 'desktop' | 'web', + ): Promise { + void clientPlatform; + if (typeof refreshTokenInput !== 'string' || refreshTokenInput.length < 80) + return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const digest = digestToken(refreshTokenInput); + const now = this.clock(); + return this.client.$transaction(async (transaction) => { + const persisted = await transaction.refreshTokenRecord.findUnique({ + where: { tokenDigest: digest }, + }); + if (!persisted) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const token = tokenFromRow(persisted); + const sessionRow = await transaction.sessionRecord.findUnique({ + where: { id: token.sessionId }, + }); + if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const session = sessionFromRow(sessionRow); + const active = await transaction.refreshTokenRecord.findMany({ + where: { sessionId: token.sessionId, familyId: token.familyId, status: 'ACTIVE' }, + }); + const activeToken = active[0] ? tokenFromRow(active[0]) : undefined; + const rotated = rotateRefreshFamilyV1({ + now: now.toISOString(), + presentedTokenId: token.id, + activeTokenId: activeToken?.id ?? token.id, + nextTokenId: stableIdentifier(randomUUID()), + familyStatus: session.status === 'ACTIVE' ? 'ACTIVE' : 'REVOKED', + tokenExpiresAt: token.expiresAt, + }); + if (!rotated.accepted || !rotated.nextTokenId) { + if (rotated.code === 'REUSE_DETECTED') { + await transaction.refreshTokenRecord.updateMany({ + where: { familyId: token.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await transaction.sessionRecord.update({ + where: { id: token.sessionId }, + data: { status: 'REVOKED', revokedAt: now }, + }); + } else if (rotated.code === 'EXPIRED' && token.status === 'ACTIVE') { + await transaction.refreshTokenRecord.updateMany({ + where: { id: token.id, status: 'ACTIVE' }, + data: { status: 'EXPIRED' }, + }); + } + const failureCode: SessionRefreshFailureCodeV1 = + rotated.code === 'EXPIRED' + ? 'EXPIRED' + : rotated.code === 'REUSE_DETECTED' + ? 'REUSE_DETECTED' + : 'REVOKED_FAMILY'; + return { + accepted: false, + code: failureCode, + }; + } + const consumed = await transaction.refreshTokenRecord.updateMany({ + where: { id: token.id, status: 'ACTIVE' }, + data: { status: 'USED', usedAt: now }, + }); + if (consumed.count !== 1) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; + const accessExpiresAt = addSeconds(now, ACCESS_TOKEN_SECONDS_V1, session.absoluteExpiresAt); + const inactivityExpiresAt = addSeconds(now, INACTIVITY_SECONDS_V1, session.absoluteExpiresAt); + await transaction.sessionRecord.update({ + where: { id: session.sessionId }, + data: { accessExpiresAt: new Date(accessExpiresAt), inactivityExpiresAt: new Date(inactivityExpiresAt) }, + }); + const nextRefreshToken = tokenFor(rotated.nextTokenId); + await transaction.refreshTokenRecord.create({ + data: { + id: rotated.nextTokenId, + sessionId: session.sessionId, + familyId: session.familyId, + tokenDigest: digestToken(nextRefreshToken), + status: 'ACTIVE', + issuedAt: now, + expiresAt: new Date(session.absoluteExpiresAt), + usedAt: null, + }, + }); + return successfulSession({ + sessionId: session.sessionId, + accessToken: tokenFor(stableIdentifier(randomUUID())), + refreshToken: nextRefreshToken, + accessExpiresAt, + }); + }); + } + + public async revoke(sessionIdInput: unknown): Promise { + if (typeof sessionIdInput !== 'string') return false; + const sessionId = parseStableIdentifierV1(sessionIdInput); + if (!sessionId.accepted) return false; + const now = this.clock(); + return this.client.$transaction(async (transaction) => { + const session = await transaction.sessionRecord.findUnique({ + where: { id: sessionId.value }, + }); + if (!session) return false; + await transaction.sessionRecord.update({ + where: { id: sessionId.value }, + data: { status: 'REVOKED', revokedAt: session.revokedAt ?? now }, + }); + await transaction.refreshTokenRecord.updateMany({ + where: { familyId: session.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + return true; + }); + } + + public async findPrincipal(sessionIdInput: unknown): Promise { + if (typeof sessionIdInput !== 'string') return undefined; + const parsed = parseStableIdentifierV1(sessionIdInput); + if (!parsed.accepted) return undefined; + try { + const sessionRow = await this.client.sessionRecord.findUnique({ where: { id: parsed.value } }); + if (!sessionRow) return undefined; + const session = sessionFromRow(sessionRow); + const now = Date.parse(this.clock().toISOString()); + if ( + session.status !== 'ACTIVE' || + now >= Date.parse(session.inactivityExpiresAt) || + now >= Date.parse(session.absoluteExpiresAt) + ) + return undefined; + const user = await this.client.userIdentity.findUnique({ where: { id: session.userId } }); + if (!user || user.status !== 'ACTIVE' || user.id !== session.userId) return undefined; + if (!Number.isSafeInteger(user.securityEpoch) || user.securityEpoch < 1) return undefined; + const memberships = await this.client.membershipIdentity.findMany({ + where: { principalId: session.userId, status: 'ACTIVE' }, + }); + const membership = memberships.find( + (candidate) => + candidate.principalId === session.userId && + candidate.scopeType === 'WORKSPACE' && + candidate.projectId === null && + parseStableIdentifierV1(candidate.organizationId).accepted && + parseStableIdentifierV1(candidate.workspaceId).accepted, + ); + if (!membership || !membership.workspaceId) return undefined; + const organizationId = parseStableIdentifierV1(membership.organizationId); + const workspaceId = parseStableIdentifierV1(membership.workspaceId); + if (!organizationId.accepted || !workspaceId.accepted) return undefined; + const [organization, workspace, factors] = await Promise.all([ + this.client.organizationIdentity.findUnique({ where: { id: organizationId.value } }), + this.client.workspaceIdentity.findUnique({ where: { id: workspaceId.value } }), + this.client.mfaFactor.findMany({ where: { userId: session.userId, status: 'ACTIVE' } }), + ]); + if ( + !organization || + organization.id !== organizationId.value || + organization.status !== 'ACTIVE' || + !workspace || + workspace.id !== workspaceId.value || + workspace.organizationId !== organizationId.value || + workspace.status !== 'ACTIVE' + ) + return undefined; + return Object.freeze({ + userId: session.userId, + organizationId: organizationId.value, + workspaceId: workspaceId.value, + securityEpoch: user.securityEpoch, + mfaRequired: factors.length > 0, + }); + } catch { + return undefined; + } + } +} + +export const asSessionIssuerPortV1 = ( + adapter: PrismaSessionLifecycleAdapter, +): SessionIssuerPortV1 => adapter; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index aff6d2f4..be331051 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -9,12 +9,17 @@ import { type AuthenticationUseCaseV1, type SessionIssuerPortV1, } from './application/authentication.port.js'; +import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { PrismaCredentialLookupAdapter, type CredentialLookupDatabaseClientV1, } from './adapter/prisma-credential-lookup.adapter.js'; +import { + PrismaSessionLifecycleAdapter, + type SessionLifecycleDatabaseClientV1, +} from './adapter/prisma-session-lifecycle.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -43,6 +48,7 @@ export interface IamModuleOptions { readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; + readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -70,10 +76,15 @@ export class IamModule { (options.credentialDatabase === undefined ? undefined : new PrismaCredentialLookupAdapter(options.credentialDatabase)); + const sessions = + options.sessions ?? + (options.sessionDatabase === undefined + ? undefined + : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); const authentication = options.authentication ?? - (credentials - ? composeAuthenticationUseCase({ ...options, credentials }) + (credentials && sessions + ? composeAuthenticationUseCase({ ...options, credentials, sessions }) : composeAuthenticationUseCase(options)); const deviceIdentityRepository = options.deviceIdentityRepository ?? @@ -86,6 +97,9 @@ export class IamModule { deviceIdentityRepository, options.deviceEnrollmentProofVerifier ?? new UnavailableDeviceEnrollmentProofVerifier(), ); + const exports = [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE]; + if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); + if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -102,6 +116,14 @@ export class IamModule { }, ] : []), + ...(sessions + ? [ + { + provide: SESSION_LIFECYCLE_PORT, + useValue: sessions, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, @@ -115,7 +137,7 @@ export class IamModule { useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE], + exports, }; } } diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 39cf901a..f10f19e8 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -8,6 +8,8 @@ import { CREDENTIAL_LOOKUP_PORT, } from '../../src/features/iam/application/authentication.port.js'; import { PrismaCredentialLookupAdapter } from '../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; +import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; +import { SESSION_LIFECYCLE_PORT } from '../../src/features/iam/application/session-lifecycle.port.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -68,6 +70,21 @@ void test('[IAM-001] configured credential persistence uses the Prisma adapter b assert.ok(authentication && 'useValue' in authentication); }); +void test('[IAM-005] configured session persistence uses the Prisma lifecycle adapter', () => { + const database = {} as never; + const registered = IamModule.register({ sessionDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SESSION_LIFECYCLE_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaSessionLifecycleAdapter); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts new file mode 100644 index 00000000..0e5294e4 --- /dev/null +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaSessionLifecycleAdapter, + type SessionLifecycleDatabaseClientV1, + type SessionRecordDatabaseRowV1, + type RefreshTokenDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const principal = { + userId, + organizationId, + workspaceId, + securityEpoch: 4, + mfaRequired: true, +}; + +function createDatabase(): { + readonly client: SessionLifecycleDatabaseClientV1; + readonly sessions: Map; + readonly refreshTokens: Map; +} { + const sessions = new Map(); + const refreshTokens = new Map(); + const client = { + sessionRecord: { + create: async ({ data }: { readonly data: SessionRecordDatabaseRowV1 }) => { + sessions.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + sessions.get(where.id) ?? null, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = sessions.get(where.id); + if (!current) throw new Error('SESSION_NOT_FOUND'); + const updated = { ...current, ...data }; + sessions.set(where.id, updated); + return updated; + }, + }, + refreshTokenRecord: { + create: async ({ data }: { readonly data: RefreshTokenDatabaseRowV1 }) => { + refreshTokens.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => + [...refreshTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, + findMany: async ({ + where, + }: { + readonly where: Readonly>; + }) => + [...refreshTokens.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value), + ), + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + let count = 0; + for (const [id, row] of refreshTokens) { + if (!Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value)) + continue; + refreshTokens.set(id, { ...row, ...data }); + count += 1; + } + return { count }; + }, + }, + userIdentity: { + findUnique: async () => ({ id: userId, status: 'ACTIVE', securityEpoch: 4 }), + }, + membershipIdentity: { + findMany: async () => [ + { + id: '00000000-0000-4000-8000-000000000004', + principalId: userId, + organizationId, + workspaceId, + projectId: null, + scopeType: 'WORKSPACE', + status: 'ACTIVE', + }, + ], + }, + workspaceIdentity: { + findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), + }, + mfaFactor: { + findMany: async () => [{ id: '00000000-0000-4000-8000-000000000005' }], + }, + $transaction: async (work: (transaction: SessionLifecycleDatabaseClientV1) => Promise) => + work(client), + } as unknown as SessionLifecycleDatabaseClientV1; + return { client, sessions, refreshTokens }; +} + +void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { + const { client, sessions, refreshTokens } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + + const session = await adapter.issue(principal, 'web'); + assert.match(session.sessionId, /^[0-9a-f-]{36}$/u); + assert.match(session.accessToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); + assert.match(session.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); + assert.equal(sessions.size, 1); + assert.equal(refreshTokens.size, 1); + assert.equal((await adapter.findPrincipal(session.sessionId))?.userId, userId); +}); + +void test('[IAM-005] refresh rotation is transactional and reuse revokes the complete family', async () => { + const { client, refreshTokens } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const first = await adapter.issue(principal, 'desktop'); + const rotated = await adapter.refresh(first.refreshToken, 'desktop'); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.notEqual(rotated.value.refreshToken, first.refreshToken); + assert.equal([...refreshTokens.values()].filter((row) => row.status === 'USED').length, 1); + assert.deepEqual(await adapter.refresh(first.refreshToken, 'desktop'), { + accepted: false, + code: 'REUSE_DETECTED', + }); + assert.deepEqual(await adapter.refresh(rotated.value.refreshToken, 'desktop'), { + accepted: false, + code: 'REVOKED_FAMILY', + }); + assert.equal(await adapter.findPrincipal(first.sessionId), undefined); +}); + +void test('[IAM-005] expired refresh tokens fail closed without returning token material', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date(now) }); + const session = await adapter.issue(principal, 'android'); + now = new Date('2026-02-01T00:00:00.000Z'); + assert.deepEqual(await adapter.refresh(session.refreshToken, 'android'), { + accepted: false, + code: 'EXPIRED', + }); + assert.deepEqual(await adapter.refresh('not-a-token', 'android'), { + accepted: false, + code: 'INVALID_REFRESH_TOKEN', + }); +}); + +void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => { + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client); + const session = await adapter.issue(principal, 'web'); + assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(await adapter.findPrincipal(session.sessionId), undefined); +}); From 5360c15b4487359192db90b1b02478b364a4ff6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:09:03 +0700 Subject: [PATCH 11/32] feat(iam): persist personal identity bootstrap --- ...a-identity-bootstrap-repository.adapter.ts | 306 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 24 ++ .../foundation-module-composition.test.ts | 17 + ...isma-identity-bootstrap-repository.test.ts | 163 ++++++++++ 4 files changed, 510 insertions(+) create mode 100644 services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts new file mode 100644 index 00000000..eeaf1ded --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -0,0 +1,306 @@ +import { + bootstrapPersonalOrganizationV1, + createUserIdentityV1, + type MembershipIdentityV1, + type PersonalOrganizationBootstrapV1, + type UserIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1, parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { + IdentityBootstrapRepositoryPortV1, + IdentityBootstrapTransactionPortV1, +} from '../application/identity-bootstrap-repository.port.js'; + +export interface UserIdentityDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly displayName: string; + readonly locale: string; + readonly status: string; + readonly securityEpoch: number; + readonly createdAt: Date; +} + +export interface OrganizationIdentityDatabaseRowV1 { + readonly id: string; + readonly name: string; + readonly personal: boolean; + readonly status: string; + readonly createdAt: Date; +} + +export interface WorkspaceIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly name: string; + readonly status: string; + readonly authorizationEpoch: number; + readonly createdAt: Date; +} + +export interface ProjectIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly workspaceId: string; + readonly kind: string; + readonly name: string; + readonly status: string; + readonly createdAt: Date; +} + +export interface MembershipIdentityDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt?: Date | null; + readonly expiresAt?: Date | null; + readonly revision: number; +} + +interface IdentityDelegateV1 { + findUnique(input: { readonly where: { readonly id: string } }): Promise; + create(input: { readonly data: TRow }): Promise; +} + +interface ListDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +interface UserDelegateV1 { + findUnique(input: { readonly where: { readonly id: string } }): Promise; +} + +interface MembershipDelegateV1 extends IdentityDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface IdentityBootstrapDatabaseClientV1 { + readonly userIdentity: UserDelegateV1; + readonly organizationIdentity: IdentityDelegateV1; + readonly workspaceIdentity: IdentityDelegateV1 & + ListDelegateV1; + readonly projectIdentity: IdentityDelegateV1 & + ListDelegateV1; + readonly membershipIdentity: MembershipDelegateV1; + $transaction( + work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, + ): Promise; +} + +function stableId(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: Date | null | undefined): string | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { + const created = createUserIdentityV1({ + id: row.id, + displayName: row.displayName, + locale: row.locale, + securityEpoch: row.securityEpoch, + status: row.status, + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_USER_INVALID'); + return created.value; +} + +function membershipMatches(row: MembershipIdentityDatabaseRowV1, expected: MembershipIdentityV1): boolean { + return ( + row.id === expected.id && + row.principalType === expected.principalType && + row.principalId === expected.principalId && + row.scopeType === 'ORGANIZATION' && + row.organizationId === expected.scope.organizationId && + row.workspaceId === null && + row.projectId === null && + row.roleId === expected.roleId && + row.status === expected.status && + row.revision === expected.revision && + row.startsAt === null && + row.expiresAt === null + ); +} + +function bootstrapRowsMatch( + bootstrap: PersonalOrganizationBootstrapV1, + organization: OrganizationIdentityDatabaseRowV1, + workspace: WorkspaceIdentityDatabaseRowV1, + project: ProjectIdentityDatabaseRowV1, + membership: MembershipIdentityDatabaseRowV1, +): boolean { + return ( + organization.id === bootstrap.organization.id && + organization.name === bootstrap.organization.name && + organization.personal === bootstrap.organization.personal && + organization.status === bootstrap.organization.status && + timestamp(organization.createdAt) === bootstrap.organization.createdAt && + workspace.id === bootstrap.workspace.id && + workspace.organizationId === bootstrap.workspace.organizationId && + workspace.name === bootstrap.workspace.name && + workspace.status === bootstrap.workspace.status && + workspace.authorizationEpoch === bootstrap.workspace.authorizationEpoch && + timestamp(workspace.createdAt) === bootstrap.workspace.createdAt && + project.id === bootstrap.project.id && + project.organizationId === bootstrap.project.organizationId && + project.workspaceId === bootstrap.project.workspaceId && + project.kind === bootstrap.project.kind && + project.name === bootstrap.project.name && + project.status === bootstrap.project.status && + timestamp(project.createdAt) === bootstrap.project.createdAt && + membershipMatches(membership, bootstrap.membership) + ); +} + +class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { + public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} + + public async findByUserId(userId: PersonalOrganizationBootstrapV1['user']['id']): Promise { + const userRow = await this.client.userIdentity.findUnique({ where: { id: userId } }); + if (!userRow) return undefined; + const user = userFromRow(userRow); + const memberships = await this.client.membershipIdentity.findMany({ + where: { principalId: user.id, status: 'ACTIVE', scopeType: 'ORGANIZATION' }, + }); + const membershipRow = memberships.find( + (candidate) => + candidate.principalId === user.id && + candidate.scopeType === 'ORGANIZATION' && + candidate.workspaceId === null && + candidate.projectId === null && + candidate.roleId === 'owner', + ); + if (!membershipRow) return undefined; + const organizationId = stableId(membershipRow.organizationId); + if (!organizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + const organization = await this.client.organizationIdentity.findUnique({ where: { id: organizationId } }); + if (!organization || !organization.personal) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + const workspaceRows = await this.client.workspaceIdentity.findMany({ + where: { organizationId, status: 'ACTIVE' }, + }); + const workspace = workspaceRows.find((candidate) => candidate.name === 'Personal workspace'); + if (!workspace) throw new Error('IAM_PERSISTED_WORKSPACE_INVALID'); + const projectRows = await this.client.projectIdentity.findMany({ + where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE' }, + }); + const project = projectRows.find((candidate) => candidate.kind === 'INTERNAL'); + if (!project) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + const canonical = bootstrapPersonalOrganizationV1({ + user: { + id: user.id, + displayName: user.displayName, + locale: user.locale, + securityEpoch: user.securityEpoch, + status: user.status, + createdAt: user.createdAt, + }, + organizationId, + workspaceId: workspace.id, + projectId: project.id, + membershipId: membershipRow.id, + createdAt: organization.createdAt.toISOString(), + }); + if (!canonical.accepted) throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); + if (!bootstrapRowsMatch(canonical.value, organization, workspace, project, membershipRow)) + throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); + return canonical.value; + } + + public async save(bootstrap: PersonalOrganizationBootstrapV1): Promise { + const userRow = await this.client.userIdentity.findUnique({ where: { id: bootstrap.user.id } }); + if (!userRow) throw new Error('IAM_USER_NOT_FOUND'); + if (JSON.stringify(userFromRow(userRow)) !== JSON.stringify(bootstrap.user)) + throw new Error('IAM_BOOTSTRAP_CONFLICT'); + const organizationData: OrganizationIdentityDatabaseRowV1 = { + id: bootstrap.organization.id, + name: bootstrap.organization.name, + personal: bootstrap.organization.personal, + status: bootstrap.organization.status, + createdAt: new Date(bootstrap.organization.createdAt), + }; + const workspaceData: WorkspaceIdentityDatabaseRowV1 = { + id: bootstrap.workspace.id, + organizationId: bootstrap.workspace.organizationId, + name: bootstrap.workspace.name, + status: bootstrap.workspace.status, + authorizationEpoch: bootstrap.workspace.authorizationEpoch, + createdAt: new Date(bootstrap.workspace.createdAt), + }; + const projectData: ProjectIdentityDatabaseRowV1 = { + id: bootstrap.project.id, + organizationId: bootstrap.project.organizationId, + workspaceId: bootstrap.project.workspaceId, + kind: bootstrap.project.kind, + name: bootstrap.project.name, + status: bootstrap.project.status, + createdAt: new Date(bootstrap.project.createdAt), + }; + const membershipData: MembershipIdentityDatabaseRowV1 = { + id: bootstrap.membership.id, + principalType: bootstrap.membership.principalType, + principalId: bootstrap.membership.principalId, + scopeType: 'ORGANIZATION', + organizationId: bootstrap.membership.scope.organizationId, + workspaceId: null, + projectId: null, + roleId: bootstrap.membership.roleId, + status: bootstrap.membership.status, + startsAt: null, + expiresAt: null, + revision: bootstrap.membership.revision, + }; + await this.saveImmutable(this.client.organizationIdentity, organizationData); + await this.saveImmutable(this.client.workspaceIdentity, workspaceData); + await this.saveImmutable(this.client.projectIdentity, projectData); + await this.saveImmutable(this.client.membershipIdentity, membershipData); + } + + private async saveImmutable( + delegate: IdentityDelegateV1, + expected: TRow, + ): Promise { + const existing = await delegate.findUnique({ where: { id: expected.id } }); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); + return; + } + await delegate.create({ data: expected }); + } +} + +export class PrismaIdentityBootstrapRepositoryAdapter implements IdentityBootstrapRepositoryPortV1 { + public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} + + public findByUserId(userId: PersonalOrganizationBootstrapV1['user']['id']) { + return new PrismaIdentityBootstrapTransactionAdapter(this.client).findByUserId(userId); + } + + public save(bootstrap: PersonalOrganizationBootstrapV1) { + return new PrismaIdentityBootstrapTransactionAdapter(this.client).save(bootstrap); + } + + public withTransaction( + work: (transaction: IdentityBootstrapTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIdentityBootstrapTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index be331051..7ae5c70a 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -10,6 +10,10 @@ import { type SessionIssuerPortV1, } from './application/authentication.port.js'; import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.port.js'; +import { + IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + type IdentityBootstrapRepositoryPortV1, +} from './application/identity-bootstrap-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -20,6 +24,10 @@ import { PrismaSessionLifecycleAdapter, type SessionLifecycleDatabaseClientV1, } from './adapter/prisma-session-lifecycle.adapter.js'; +import { + PrismaIdentityBootstrapRepositoryAdapter, + type IdentityBootstrapDatabaseClientV1, +} from './adapter/prisma-identity-bootstrap-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -49,6 +57,8 @@ export interface IamModuleOptions { readonly passwordCredentials?: PasswordCredentialService; readonly sessions?: SessionIssuerPortV1; readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; + readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; + readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -81,6 +91,11 @@ export class IamModule { (options.sessionDatabase === undefined ? undefined : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); + const identityBootstrapRepository = + options.identityBootstrapRepository ?? + (options.identityBootstrapDatabase === undefined + ? undefined + : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -100,6 +115,7 @@ export class IamModule { const exports = [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE]; if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); + if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -124,6 +140,14 @@ export class IamModule { }, ] : []), + ...(identityBootstrapRepository + ? [ + { + provide: IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + useValue: identityBootstrapRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index f10f19e8..fa480829 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -10,6 +10,8 @@ import { import { PrismaCredentialLookupAdapter } from '../../src/features/iam/adapter/prisma-credential-lookup.adapter.js'; import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; import { SESSION_LIFECYCLE_PORT } from '../../src/features/iam/application/session-lifecycle.port.js'; +import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/application/identity-bootstrap-repository.port.js'; +import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -85,6 +87,21 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad assert.ok(provider.useValue instanceof PrismaSessionLifecycleAdapter); }); +void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ identityBootstrapDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IDENTITY_BOOTSTRAP_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaIdentityBootstrapRepositoryAdapter); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts new file mode 100644 index 00000000..a1a2cbd3 --- /dev/null +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { bootstrapPersonalOrganizationV1 } from '@databreeze/domain/identity/v1'; + +import { + PrismaIdentityBootstrapRepositoryAdapter, + type IdentityBootstrapDatabaseClientV1, + type UserIdentityDatabaseRowV1, + type OrganizationIdentityDatabaseRowV1, + type WorkspaceIdentityDatabaseRowV1, + type ProjectIdentityDatabaseRowV1, + type MembershipIdentityDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const projectId = '00000000-0000-4000-8000-000000000004'; +const membershipId = '00000000-0000-4000-8000-000000000005'; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); +const input = { + user: { + id: userId, + displayName: 'Nguyen An', + createdAt: createdAt.toISOString(), + }, + organizationId, + workspaceId, + projectId, + membershipId, + createdAt: createdAt.toISOString(), +}; + +function createDatabase(): { + readonly client: IdentityBootstrapDatabaseClientV1; + readonly users: Map; + readonly organizations: Map; + readonly workspaces: Map; + readonly projects: Map; + readonly memberships: Map; +} { + const users = new Map([ + [userId, { id: userId, email: 'an@example.com', displayName: 'Nguyen An', locale: 'vi-VN', status: 'ACTIVE', securityEpoch: 1, createdAt }], + ]); + const organizations = new Map(); + const workspaces = new Map(); + const projects = new Map(); + const memberships = new Map(); + const client = { + userIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => users.get(where.id) ?? null, + }, + organizationIdentity: { + create: async ({ data }: { readonly data: OrganizationIdentityDatabaseRowV1 }) => { + organizations.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => organizations.get(where.id) ?? null, + }, + workspaceIdentity: { + create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { + workspaces.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => workspaces.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...workspaces.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof WorkspaceIdentityDatabaseRowV1] === value), + ), + }, + projectIdentity: { + create: async ({ data }: { readonly data: ProjectIdentityDatabaseRowV1 }) => { + projects.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => projects.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...projects.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof ProjectIdentityDatabaseRowV1] === value), + ), + }, + membershipIdentity: { + create: async ({ data }: { readonly data: MembershipIdentityDatabaseRowV1 }) => { + memberships.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => memberships.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...memberships.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof MembershipIdentityDatabaseRowV1] === value), + ), + }, + $transaction: async (work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise) => { + const before = { + organizations: new Map(organizations), + workspaces: new Map(workspaces), + projects: new Map(projects), + memberships: new Map(memberships), + }; + try { + return await work(client); + } catch (error) { + organizations.clear(); + workspaces.clear(); + projects.clear(); + memberships.clear(); + for (const [id, row] of before.organizations) organizations.set(id, row); + for (const [id, row] of before.workspaces) workspaces.set(id, row); + for (const [id, row] of before.projects) projects.set(id, row); + for (const [id, row] of before.memberships) memberships.set(id, row); + throw error; + } + }, + } as unknown as IdentityBootstrapDatabaseClientV1; + return { client, users, organizations, workspaces, projects, memberships }; +} + +void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstructs a personal owner hierarchy', async () => { + const { client, organizations, workspaces, projects, memberships } = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + + await adapter.save(validated.value); + assert.equal(organizations.size, 1); + assert.equal(workspaces.size, 1); + assert.equal(projects.size, 1); + assert.equal(memberships.size, 1); + assert.deepEqual(await adapter.findByUserId(validated.value.user.id), validated.value); +}); + +void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { + const { client } = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + + await adapter.save(validated.value); + await assert.doesNotReject(() => adapter.save(validated.value)); + await assert.rejects( + adapter.save({ ...validated.value, organization: { ...validated.value.organization, name: 'Changed' } }), + /IAM_BOOTSTRAP_CONFLICT/, + ); +}); + +void test('[IAM-001] bootstrap transaction rollback does not retain a partially written hierarchy', async () => { + const state = createDatabase(); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + await assert.rejects( + adapter.withTransaction(async (transaction) => { + await transaction.save(validated.value); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.equal(state.organizations.size, 0); +}); From 54cc4943d1a138a191fc4928c7cc1865da0efa44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:10:59 +0700 Subject: [PATCH 12/32] fix(iam): resolve organization memberships for sign-in --- .../prisma-credential-lookup.adapter.ts | 46 ++++++++++++++----- .../iam/prisma-credential-lookup.test.ts | 25 ++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index 582411fb..1492db36 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -51,6 +51,12 @@ interface UniqueDelegateV1 { }): Promise; } +interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { + readonly findMany?: (input: { + readonly where: Readonly>; + }) => Promise; +} + interface ListDelegateV1 { findMany(input: { readonly where: Readonly>; @@ -62,14 +68,14 @@ export interface CredentialLookupDatabaseClientV1 { readonly userIdentity: UniqueDelegateV1; readonly passwordCredential: UniqueDelegateV1; readonly membershipIdentity: ListDelegateV1; - readonly workspaceIdentity: UniqueDelegateV1; + readonly workspaceIdentity: WorkspaceLookupDelegateV1; readonly organizationIdentity: UniqueDelegateV1; readonly mfaFactor: ListDelegateV1; } interface ActiveMembershipV1 { readonly organizationId: string; - readonly workspaceId: string; + readonly workspaceId?: string; } function stableId(input: unknown): string | undefined { @@ -81,13 +87,18 @@ function activeMembership( row: MembershipIdentityDatabaseRowV1, userId: string, ): ActiveMembershipV1 | undefined { - if (row.principalId !== userId || row.status !== 'ACTIVE' || row.scopeType !== 'WORKSPACE') + if (row.principalId !== userId || row.status !== 'ACTIVE') return undefined; - if (row.projectId !== null) return undefined; const organizationId = stableId(row.organizationId); - const workspaceId = stableId(row.workspaceId); - if (!organizationId || !workspaceId) return undefined; - return { organizationId, workspaceId }; + if (!organizationId) return undefined; + if (row.scopeType === 'WORKSPACE') { + if (row.projectId !== null) return undefined; + const workspaceId = stableId(row.workspaceId); + return workspaceId ? { organizationId, workspaceId } : undefined; + } + if (row.scopeType === 'ORGANIZATION' && row.workspaceId === null && row.projectId === null) + return { organizationId }; + return undefined; } /** @@ -135,17 +146,30 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { .find((membership): membership is ActiveMembershipV1 => membership !== undefined); if (!selected) return undefined; - const [organization, workspace, factors] = await Promise.all([ + const [organization, factors] = await Promise.all([ this.client.organizationIdentity.findUnique({ where: { id: selected.organizationId } }), - this.client.workspaceIdentity.findUnique({ where: { id: selected.workspaceId } }), this.client.mfaFactor.findMany({ where: { userId, status: 'ACTIVE' } }), ]); + let workspaceId = selected.workspaceId; + if (!workspaceId) { + if (!this.client.workspaceIdentity.findMany) return undefined; + const workspaces = await this.client.workspaceIdentity.findMany({ + where: { organizationId: selected.organizationId, status: 'ACTIVE' }, + }); + const workspace = workspaces.find( + (candidate) => + candidate.organizationId === selected.organizationId && candidate.status === 'ACTIVE', + ); + workspaceId = workspace ? stableId(workspace.id) : undefined; + } + if (!workspaceId) return undefined; + const workspace = await this.client.workspaceIdentity.findUnique({ where: { id: workspaceId } }); if ( !organization || organization.id !== selected.organizationId || organization.status !== 'ACTIVE' || !workspace || - workspace.id !== selected.workspaceId || + workspace.id !== workspaceId || workspace.organizationId !== selected.organizationId || workspace.status !== 'ACTIVE' ) @@ -155,7 +179,7 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { principal: Object.freeze({ userId, organizationId: selected.organizationId, - workspaceId: selected.workspaceId, + workspaceId, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, }), diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 42221b1d..3b134c15 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -46,6 +46,7 @@ function database( }, workspaceIdentity: { findUnique: async () => ({ id: workspaceId, organizationId, status: 'ACTIVE' }), + findMany: async () => [{ id: workspaceId, organizationId, status: 'ACTIVE' }], }, organizationIdentity: { findUnique: async () => ({ id: organizationId, status: 'ACTIVE' }), @@ -113,3 +114,27 @@ void test('[IAM-001, IAM-002] lookup does not authenticate users without an acti assert.equal(await adapter.findCredential('user@example.com'), undefined); }); + +void test('[IAM-001, IAM-009] an organization owner resolves the canonical active workspace', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + membershipIdentity: { + findMany: async () => [ + { + id: membershipId, + principalId: userId, + organizationId, + workspaceId: null, + projectId: null, + scopeType: 'ORGANIZATION', + status: 'ACTIVE', + }, + ], + }, + }), + ); + + const result = await adapter.findCredential('user@example.com'); + assert.equal(result?.principal.organizationId, organizationId); + assert.equal(result?.principal.workspaceId, workspaceId); +}); From 5f7c58f4718ac1e86f978ec03aaeedb7a6d641c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:17:45 +0700 Subject: [PATCH 13/32] feat(iam): persist MFA factors and recovery codes --- .../migration.sql | 20 ++ services/api/prisma/schema/iam.prisma | 15 + .../adapter/prisma-mfa-repository.adapter.ts | 266 ++++++++++++++++++ services/api/src/features/iam/iam.module.ts | 18 ++ .../foundation-module-composition.test.ts | 17 ++ .../iam/prisma-mfa-repository.test.ts | 161 +++++++++++ services/api/test/prisma-foundation.test.mjs | 2 + 7 files changed, 499 insertions(+) create mode 100644 services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql create mode 100644 services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-mfa-repository.test.ts diff --git a/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql b/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql new file mode 100644 index 00000000..d80c0c76 --- /dev/null +++ b/services/api/prisma/migrations/20260802210000_iam_mfa_recovery/migration.sql @@ -0,0 +1,20 @@ +-- Add revisioned MFA factor transitions required by the domain state machine. +ALTER TABLE "iam"."mfa_factors" + ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1; + +-- Recovery-code digests are stored separately so they can be redeemed once +-- without exposing the presented value or mutating the immutable digest. +CREATE TABLE "iam"."mfa_recovery_codes" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "digest" VARCHAR(256) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'AVAILABLE', + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "used_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "mfa_recovery_codes_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "mfa_recovery_codes_user_status_idx" + ON "iam"."mfa_recovery_codes"("user_id", "status"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index d529d923..387c0b2c 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -171,12 +171,27 @@ model MfaFactor { enrolledAt DateTime @map("enrolled_at") @db.Timestamptz(6) verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) @@index([userId, status], map: "mfa_factors_user_status_idx") @@map("mfa_factors") @@schema("iam") } +model MfaRecoveryCode { + id String @id @db.Uuid + userId String @map("user_id") @db.Uuid + digest String @db.VarChar(256) + status String @default("AVAILABLE") @db.VarChar(24) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + usedAt DateTime? @map("used_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([userId, status], map: "mfa_recovery_codes_user_status_idx") + @@map("mfa_recovery_codes") + @@schema("iam") +} + model AuthorizationSnapshot { id String @id @db.Uuid deviceId String @map("device_id") @db.Uuid diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts new file mode 100644 index 00000000..3f570176 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -0,0 +1,266 @@ +import { + createMfaFactorV1, + createRecoveryCodeV1, + type MfaFactorV1, + type MfaStateV1, + type RecoveryCodeV1, +} from '@databreeze/domain/mfa/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + MfaRepositoryPortV1, + MfaTransactionPortV1, +} from '../application/mfa-repository.port.js'; + +export interface MfaFactorDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly factorType: string; + readonly secretReference: string; + readonly status: string; + readonly enrolledAt: Date; + readonly verifiedAt: Date | null; + readonly revokedAt: Date | null; + readonly revision: number; +} + +export interface MfaRecoveryCodeDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly digest: string; + readonly status: string; + readonly createdAt: Date; + readonly usedAt: Date | null; + readonly revision: number; +} + +interface MfaFactorDelegateV1 { + findMany(input: { readonly where: Readonly> }): Promise; + findUnique(input: { readonly where: { readonly id: string } }): Promise; + create(input: { readonly data: MfaFactorDatabaseRowV1 }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +interface MfaRecoveryCodeDelegateV1 { + findMany(input: { readonly where: Readonly> }): Promise; + findUnique(input: { readonly where: { readonly id: string } }): Promise; + create(input: { readonly data: MfaRecoveryCodeDatabaseRowV1 }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +export interface MfaDatabaseClientV1 { + readonly mfaFactor: MfaFactorDelegateV1; + readonly mfaRecoveryCode: MfaRecoveryCodeDelegateV1; + $transaction( + work: (transaction: MfaDatabaseClientV1) => Promise, + ): Promise; +} + +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!input) return undefined; + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function stable(input: unknown): string | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function factorFromRow(row: MfaFactorDatabaseRowV1): MfaFactorV1 { + const created = createMfaFactorV1({ + id: row.id, + userId: row.userId, + method: row.factorType, + secretReference: row.secretReference, + enrolledAt: timestamp(row.enrolledAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + if ( + (row.status !== 'PENDING' && row.status !== 'ACTIVE' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + const verifiedAt = timestamp(row.verifiedAt); + const revokedAt = timestamp(row.revokedAt); + if ((row.verifiedAt && !verifiedAt) || (row.revokedAt && !revokedAt)) + throw new Error('IAM_PERSISTED_MFA_FACTOR_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + revision: row.revision, + ...(verifiedAt ? { verifiedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function recoveryCodeFromRow(row: MfaRecoveryCodeDatabaseRowV1): RecoveryCodeV1 { + const created = createRecoveryCodeV1({ + id: row.id, + userId: row.userId, + digest: row.digest, + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + if ( + (row.status !== 'AVAILABLE' && row.status !== 'USED' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + const usedAt = timestamp(row.usedAt); + if (row.usedAt && !usedAt) throw new Error('IAM_PERSISTED_RECOVERY_CODE_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + revision: row.revision, + ...(usedAt ? { usedAt } : {}), + }); +} + +function freezeState(state: MfaStateV1): MfaStateV1 { + return Object.freeze({ + factors: Object.freeze(state.factors.map((factor) => Object.freeze({ ...factor }))), + recoveryCodes: Object.freeze(state.recoveryCodes.map((code) => Object.freeze({ ...code }))), + }); +} + +function factorRow(factor: MfaFactorV1): MfaFactorDatabaseRowV1 { + return { + id: factor.id, + userId: factor.userId, + factorType: factor.method, + secretReference: factor.secretReference, + status: factor.status, + enrolledAt: new Date(factor.enrolledAt), + verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, + revokedAt: factor.revokedAt ? new Date(factor.revokedAt) : null, + revision: factor.revision, + }; +} + +function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { + return { + id: code.id, + userId: code.userId, + digest: code.digest, + status: code.status, + createdAt: new Date(code.createdAt), + usedAt: code.usedAt ? new Date(code.usedAt) : null, + revision: code.revision, + }; +} + +function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); + const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); + if (existing.factors.some((factor) => !next.factors.some((candidate) => candidate.id === factor.id))) + return false; + if (existing.recoveryCodes.some((code) => !next.recoveryCodes.some((candidate) => candidate.id === code.id))) + return false; + for (const factor of next.factors) { + const prior = existingFactors.get(factor.id); + if (prior && (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference)) return false; + if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) return false; + if (!prior && factor.revision !== 1) return false; + } + for (const code of next.recoveryCodes) { + const prior = existingCodes.get(code.id); + if (prior && (prior.userId !== code.userId || prior.digest !== code.digest)) return false; + if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) return false; + if (!prior && code.revision !== 1) return false; + } + return true; +} + +class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { + public constructor(private readonly client: MfaDatabaseClientV1) {} + + public async findState(userId: string): Promise { + const [factors, recoveryCodes] = await Promise.all([ + this.client.mfaFactor.findMany({ where: { userId } }), + this.client.mfaRecoveryCode.findMany({ where: { userId } }), + ]); + return freezeState({ + factors: factors.map(factorFromRow), + recoveryCodes: recoveryCodes.map(recoveryCodeFromRow), + }); + } + + public async saveState(userId: string, state: MfaStateV1): Promise { + if (!stable(userId)) throw new Error('MFA_INVALID_USER'); + if ( + !state.factors.every((factor) => factor.userId === userId) || + !state.recoveryCodes.every((code) => code.userId === userId) + ) + throw new Error('MFA_SCOPE_MISMATCH'); + const existing = await this.findState(userId); + if (!immutableState(existing, state)) throw new Error('IAM_MFA_REVISION_CONFLICT'); + for (const factor of state.factors) { + const prior = existing.factors.find((candidate) => candidate.id === factor.id); + if (!prior) { + await this.client.mfaFactor.create({ data: factorRow(factor) }); + continue; + } + if (JSON.stringify(prior) === JSON.stringify(factor)) continue; + await this.client.mfaFactor.update({ + where: { id: factor.id }, + data: { + status: factor.status, + verifiedAt: factor.verifiedAt ? new Date(factor.verifiedAt) : null, + revokedAt: factor.revokedAt ? new Date(factor.revokedAt) : null, + revision: factor.revision, + }, + }); + } + for (const code of state.recoveryCodes) { + const prior = existing.recoveryCodes.find((candidate) => candidate.id === code.id); + if (!prior) { + await this.client.mfaRecoveryCode.create({ data: recoveryRow(code) }); + continue; + } + if (JSON.stringify(prior) === JSON.stringify(code)) continue; + await this.client.mfaRecoveryCode.update({ + where: { id: code.id }, + data: { + status: code.status, + usedAt: code.usedAt ? new Date(code.usedAt) : null, + revision: code.revision, + }, + }); + } + } +} + +export class PrismaMfaRepositoryAdapter implements MfaRepositoryPortV1 { + public constructor(private readonly client: MfaDatabaseClientV1) {} + + public findState(userId: string) { + return new PrismaMfaTransactionAdapter(this.client).findState(userId); + } + + public saveState(userId: string, state: MfaStateV1) { + return this.client.$transaction((transaction) => + new PrismaMfaTransactionAdapter(transaction).saveState(userId, state), + ); + } + + public withTransaction( + work: (transaction: MfaTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaMfaTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 7ae5c70a..4c08ce91 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -14,6 +14,7 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT, type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; +import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1 } from './application/mfa-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -28,6 +29,7 @@ import { PrismaIdentityBootstrapRepositoryAdapter, type IdentityBootstrapDatabaseClientV1, } from './adapter/prisma-identity-bootstrap-repository.adapter.js'; +import { PrismaMfaRepositoryAdapter, type MfaDatabaseClientV1 } from './adapter/prisma-mfa-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -59,6 +61,8 @@ export interface IamModuleOptions { readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; + readonly mfaRepository?: MfaRepositoryPortV1; + readonly mfaDatabase?: MfaDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -96,6 +100,11 @@ export class IamModule { (options.identityBootstrapDatabase === undefined ? undefined : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); + const mfaRepository = + options.mfaRepository ?? + (options.mfaDatabase === undefined + ? undefined + : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -116,6 +125,7 @@ export class IamModule { if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); + if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -148,6 +158,14 @@ export class IamModule { }, ] : []), + ...(mfaRepository + ? [ + { + provide: MFA_REPOSITORY_PORT, + useValue: mfaRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index fa480829..ff0847ff 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -12,6 +12,8 @@ import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/pr import { SESSION_LIFECYCLE_PORT } from '../../src/features/iam/application/session-lifecycle.port.js'; import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/application/identity-bootstrap-repository.port.js'; import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; +import { MFA_REPOSITORY_PORT } from '../../src/features/iam/application/mfa-repository.port.js'; +import { PrismaMfaRepositoryAdapter } from '../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -102,6 +104,21 @@ void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the assert.ok(provider.useValue instanceof PrismaIdentityBootstrapRepositoryAdapter); }); +void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ mfaDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === MFA_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaMfaRepositoryAdapter); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts new file mode 100644 index 00000000..29094f6c --- /dev/null +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createMfaFactorV1, createRecoveryCodeV1, transitionMfaFactorV1 } from '@databreeze/domain/mfa/v1'; +import type { MfaStateV1 } from '@databreeze/domain/mfa/v1'; + +import { + PrismaMfaRepositoryAdapter, + type MfaDatabaseClientV1, + type MfaFactorDatabaseRowV1, + type MfaRecoveryCodeDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const factorId = '00000000-0000-4000-8000-000000000002'; +const recoveryId = '00000000-0000-4000-8000-000000000003'; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); + +function createDatabase(): { + readonly client: MfaDatabaseClientV1; + readonly factors: Map; + readonly recoveryCodes: Map; +} { + const factors = new Map(); + const recoveryCodes = new Map(); + const client = { + mfaFactor: { + findMany: async ({ where }: { readonly where: Readonly> }) => + [...factors.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof MfaFactorDatabaseRowV1] === value), + ), + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => factors.get(where.id) ?? null, + create: async ({ data }: { readonly data: MfaFactorDatabaseRowV1 }) => { + factors.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = factors.get(where.id); + if (!current) throw new Error('MFA_FACTOR_NOT_FOUND'); + const updated = { ...current, ...data }; + factors.set(where.id, updated); + return updated; + }, + }, + mfaRecoveryCode: { + findMany: async ({ where }: { readonly where: Readonly> }) => + [...recoveryCodes.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof MfaRecoveryCodeDatabaseRowV1] === value), + ), + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => recoveryCodes.get(where.id) ?? null, + create: async ({ data }: { readonly data: MfaRecoveryCodeDatabaseRowV1 }) => { + recoveryCodes.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = recoveryCodes.get(where.id); + if (!current) throw new Error('MFA_RECOVERY_CODE_NOT_FOUND'); + const updated = { ...current, ...data }; + recoveryCodes.set(where.id, updated); + return updated; + }, + }, + $transaction: async (work: (transaction: MfaDatabaseClientV1) => Promise) => { + const beforeFactors = new Map(factors); + const beforeCodes = new Map(recoveryCodes); + try { + return await work(client); + } catch (error) { + factors.clear(); + recoveryCodes.clear(); + for (const [id, row] of beforeFactors) factors.set(id, row); + for (const [id, row] of beforeCodes) recoveryCodes.set(id, row); + throw error; + } + }, + } as unknown as MfaDatabaseClientV1; + return { client, factors, recoveryCodes }; +} + +function state(): MfaStateV1 { + const factor = createMfaFactorV1({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'kms://mfa/secret/1', + enrolledAt: createdAt.toISOString(), + }); + const code = createRecoveryCodeV1({ + id: recoveryId, + userId, + digest: 'digest-1', + createdAt: createdAt.toISOString(), + }); + assert.equal(factor.accepted, true); + assert.equal(code.accepted, true); + if (!factor.accepted || !code.accepted) throw new Error('fixture invalid'); + return { factors: [factor.value], recoveryCodes: [code.value] }; +} + +void test('[IAM-012, IAM-014] Prisma MFA persistence round-trips opaque factors and recovery digests', async () => { + const { client, factors, recoveryCodes } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await adapter.saveState(factor.userId, input); + assert.equal(factors.size, 1); + assert.equal(recoveryCodes.size, 1); + assert.deepEqual(await adapter.findState(factor.userId), input); +}); + +void test('[IAM-012, IAM-014] status transitions persist by revision while immutable secrets and digests remain fixed', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await adapter.saveState(factor.userId, input); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', '2026-01-01T00:01:00.000Z'); + assert.equal(transitioned.accepted, true); + if (!transitioned.accepted) return; + await adapter.saveState(factor.userId, { + factors: [transitioned.value], + recoveryCodes: input.recoveryCodes, + }); + const stored = await adapter.findState(factor.userId); + const storedFactor = stored.factors[0]; + if (!storedFactor) throw new Error('stored factor missing'); + assert.equal(storedFactor.status, 'ACTIVE'); + assert.equal(storedFactor.revision, 2); + assert.equal(storedFactor.secretReference, 'kms://mfa/secret/1'); +}); + +void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactions roll back', async () => { + const { client, factors } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await assert.rejects(adapter.saveState('00000000-0000-4000-8000-000000000099', input), /MFA_SCOPE_MISMATCH/); + await assert.rejects( + adapter.withTransaction(async (transaction) => { + await transaction.saveState(factor.userId, input); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.equal(factors.size, 0); +}); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index cccfcf59..15026d46 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -74,6 +74,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_conflicts"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -102,6 +103,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802180000_iam_device_enrollment', '20260802190000_dso_capabilities_grants', '20260802200000_dso_data_mode_policies', + '20260802210000_iam_mfa_recovery', 'migration_lock.toml', ]); const migration = await readFile( From 3c904751a815824e05bc276440fb0675119bee99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:23:21 +0700 Subject: [PATCH 14/32] feat(iam): persist tenant-scoped memberships --- .../adapter/prisma-iam-repository.adapter.ts | 199 ++++++++++++++++++ .../iam/application/iam-repository.port.ts | 2 + services/api/src/features/iam/iam.module.ts | 18 ++ .../foundation-module-composition.test.ts | 17 ++ .../iam/prisma-iam-repository.test.ts | 175 +++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts create mode 100644 services/api/test/features/iam/prisma-iam-repository.test.ts diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts new file mode 100644 index 00000000..d44da532 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -0,0 +1,199 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + parseStableIdentifierV1, + parseTenantScopeV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { validateMembershipV1, type MembershipIdentityV1 } from '@databreeze/domain/identity/v1'; + +import type { + IamMembershipRecordV1, + IamRepositoryPortV1, + IamTransactionPortV1, +} from '../application/iam-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; + +export interface IamMembershipDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt?: Date | null; + readonly expiresAt?: Date | null; + readonly revision: number; +} + +interface IamMembershipDelegateV1 { + findUnique(input: { readonly where: { readonly id: string } }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Partial; + }): Promise; +} + +export interface IamDatabaseClientV1 { + readonly membershipIdentity: IamMembershipDelegateV1; + $transaction( + work: (transaction: IamDatabaseClientV1) => Promise, + ): Promise; +} + +function timestamp(input: Date | null | undefined): string | undefined { + return input?.toISOString(); +} + +function scopeFromRow(row: IamMembershipDatabaseRowV1): TenantScopeV1 | undefined { + const organizationId = parseStableIdentifierV1(row.organizationId); + if (!organizationId.accepted) return undefined; + if (row.scopeType === 'ORGANIZATION' && row.workspaceId === null && row.projectId === null) + return { scopeType: 'organization', organizationId: organizationId.value }; + const workspaceId = parseStableIdentifierV1(row.workspaceId); + if (!workspaceId.accepted) return undefined; + if (row.scopeType === 'WORKSPACE' && row.projectId === null) + return { + scopeType: 'workspace', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + }; + const projectId = parseStableIdentifierV1(row.projectId); + if (!projectId.accepted || row.scopeType !== 'PROJECT') return undefined; + return { + scopeType: 'project', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + projectId: projectId.value, + }; +} + +function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecordV1 { + const scope = scopeFromRow(row); + const validated = validateMembershipV1({ + id: row.id, + principalType: row.principalType, + principalId: row.principalId, + scope, + roleId: row.roleId, + status: row.status, + ...(row.startsAt ? { startsAt: timestamp(row.startsAt) } : {}), + ...(row.expiresAt ? { expiresAt: timestamp(row.expiresAt) } : {}), + revision: row.revision, + }); + if (!validated.accepted) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return validated.value; +} + +function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseRowV1 { + return { + id: membership.id, + principalType: membership.principalType, + principalId: membership.principalId, + scopeType: membership.scope.scopeType.toUpperCase(), + organizationId: membership.scope.organizationId, + workspaceId: membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, + projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, + roleId: membership.roleId, + status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, + revision: membership.revision, + }; +} + +function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); +} + +class PrismaIamTransactionAdapter implements IamTransactionPortV1 { + public constructor(private readonly client: IamDatabaseClientV1) {} + + public async findMembership( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + const rows = await this.client.membershipIdentity.findMany({ where: { principalId } }); + return rows + .map(membershipFromRow) + .find( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + visibleInScope(context.tenantScope, membership.scope), + ); + } + + public async listMemberships(context: IamTenantContextV1): Promise { + const rows = await this.client.membershipIdentity.findMany({ where: {} }); + return rows + .map(membershipFromRow) + .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); + } + + public async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + const existingRow = await this.client.membershipIdentity.findUnique({ + where: { id: membership.id }, + }); + if (!existingRow) { + if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); + await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + return; + } + const existing = membershipFromRow(existingRow); + if (context.expectedRevision !== existing.revision) throw new Error('IAM_REVISION_CONFLICT'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + if (existing.principalId !== membership.principalId || !tenantScopesEqualV1(existing.scope, membership.scope)) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + await this.client.membershipIdentity.update({ + where: { id: membership.id }, + data: { + roleId: membership.roleId, + status: membership.status, + revision: membership.revision, + }, + }); + } +} + +export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { + public constructor(private readonly client: IamDatabaseClientV1) {} + + public findMembership(context: IamTenantContextV1, principalId: StableIdentifierV1) { + return new PrismaIamTransactionAdapter(this.client).findMembership(context, principalId); + } + + public listMemberships(context: IamTenantContextV1) { + return new PrismaIamTransactionAdapter(this.client).listMemberships(context); + } + + public saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1) { + return this.client.$transaction((transaction) => + new PrismaIamTransactionAdapter(transaction).saveMembership(context, membership), + ); + } + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: IamTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIamTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/application/iam-repository.port.ts b/services/api/src/features/iam/application/iam-repository.port.ts index ae8cd688..f52be91b 100644 --- a/services/api/src/features/iam/application/iam-repository.port.ts +++ b/services/api/src/features/iam/application/iam-repository.port.ts @@ -2,6 +2,8 @@ import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenan import type { IamTenantContextV1 } from './tenant-context.js'; +export const IAM_REPOSITORY_PORT = Symbol('IAM_REPOSITORY_PORT'); + export interface IamMembershipRecordV1 { readonly id: StableIdentifierV1; readonly principalId: StableIdentifierV1; diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 4c08ce91..5ebb25af 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -15,6 +15,7 @@ import { type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1 } from './application/mfa-repository.port.js'; +import { IAM_REPOSITORY_PORT, type IamRepositoryPortV1 } from './application/iam-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -30,6 +31,7 @@ import { type IdentityBootstrapDatabaseClientV1, } from './adapter/prisma-identity-bootstrap-repository.adapter.js'; import { PrismaMfaRepositoryAdapter, type MfaDatabaseClientV1 } from './adapter/prisma-mfa-repository.adapter.js'; +import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1 } from './adapter/prisma-iam-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -63,6 +65,8 @@ export interface IamModuleOptions { readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; + readonly iamRepository?: IamRepositoryPortV1; + readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -105,6 +109,11 @@ export class IamModule { (options.mfaDatabase === undefined ? undefined : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); + const iamRepository = + options.iamRepository ?? + (options.iamDatabase === undefined + ? undefined + : new PrismaIamRepositoryAdapter(options.iamDatabase)); const authentication = options.authentication ?? (credentials && sessions @@ -126,6 +135,7 @@ export class IamModule { if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); + if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, controllers: [AuthenticationController, DeviceIdentityController], @@ -166,6 +176,14 @@ export class IamModule { }, ] : []), + ...(iamRepository + ? [ + { + provide: IAM_REPOSITORY_PORT, + useValue: iamRepository, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index ff0847ff..7cf04d8c 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -14,6 +14,8 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/appli import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; import { MFA_REPOSITORY_PORT } from '../../src/features/iam/application/mfa-repository.port.js'; import { PrismaMfaRepositoryAdapter } from '../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; +import { IAM_REPOSITORY_PORT } from '../../src/features/iam/application/iam-repository.port.js'; +import { PrismaIamRepositoryAdapter } from '../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -119,6 +121,21 @@ void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter assert.ok(provider.useValue instanceof PrismaMfaRepositoryAdapter); }); +void test('[IAM-009, IAM-019] configured IAM membership persistence uses the Prisma adapter', () => { + const database = {} as never; + const registered = IamModule.register({ iamDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaIamRepositoryAdapter); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts new file mode 100644 index 00000000..c8f9fbe0 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1, type StableIdentifierV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaIamRepositoryAdapter, + type IamDatabaseClientV1, + type IamMembershipDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const id = (tail: string): string => `00000000-0000-4000-8000-${tail.padStart(12, '0')}`; +const stable = (tail: string): StableIdentifierV1 => { + const parsed = parseStableIdentifierV1(id(tail)); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test identifier'); + return parsed.value; +}; +const organizationId = stable('1'); +const workspaceId = stable('2'); +const siblingWorkspaceId = stable('3'); +const principalId = stable('4'); + +function context(scope: TenantScopeV1, expectedRevision?: number) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId: principalId, + correlationId: id('5'), + idempotencyKey: 'membership-update-1', + authorizationEpoch: 1, + expectedRevision, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid test context'); + return result.value; +} + +function row(idValue: string, scope: 'WORKSPACE' | 'ORGANIZATION', workspace: string | null, roleId: string): IamMembershipDatabaseRowV1 { + return { + id: idValue, + principalType: 'USER', + principalId, + scopeType: scope, + organizationId, + workspaceId: workspace, + projectId: null, + roleId, + status: 'ACTIVE', + startsAt: null, + expiresAt: null, + revision: 1, + }; +} + +function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { + readonly client: IamDatabaseClientV1; + readonly memberships: Map; +} { + const memberships = new Map(rows.map((value) => [value.id, value])); + const client = { + membershipIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => memberships.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + [...memberships.values()].filter((candidate) => + Object.entries(where).every(([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value), + ), + create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { + memberships.set(data.id, data); + return data; + }, + update: async ({ + where, + data, + }: { + readonly where: { readonly id: string }; + readonly data: Partial; + }) => { + const current = memberships.get(where.id); + if (!current) throw new Error('MEMBERSHIP_NOT_FOUND'); + const updated = { ...current, ...data }; + memberships.set(where.id, updated); + return updated; + }, + }, + $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => { + const before = new Map(memberships); + try { + return await work(client); + } catch (error) { + memberships.clear(); + for (const [key, value] of before) memberships.set(key, value); + throw error; + } + }, + } as unknown as IamDatabaseClientV1; + return { client, memberships }; +} + +void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { + const { client } = createDatabase([ + row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), + row(id('11'), 'WORKSPACE', siblingWorkspaceId, 'owner'), + row(id('12'), 'ORGANIZATION', null, 'admin'), + ]); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + assert.equal((await repository.listMemberships(context(workspaceScope))).length, 2); + assert.equal( + (await repository.listMemberships(context({ scopeType: 'organization', organizationId }))).length, + 3, + ); + assert.equal( + (await repository.findMembership(context(workspaceScope), principalId))?.id, + stable('10'), + ); +}); + +void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { + const { client, memberships } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + await assert.rejects( + repository.saveMembership(context(workspaceScope), { + id: stable('20'), + principalId, + scope: { scopeType: 'organization', organizationId }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }), + /IAM_SCOPE_NARROWING_REQUIRED/u, + ); + await repository.saveMembership(context(workspaceScope), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + await assert.rejects( + repository.saveMembership(context(workspaceScope, 1), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 3, + }), + /IAM_REVISION_CONFLICT/u, + ); + assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); +}); + +void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership', async () => { + const { client, memberships } = createDatabase(); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + await assert.rejects( + repository.withTransaction(context(workspaceScope), async (transaction) => { + await transaction.saveMembership(context(workspaceScope), { + id: stable('22'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }); + throw new Error('rollback'); + }), + /rollback/u, + ); + assert.equal(memberships.size, 0); +}); From 7ff5bd4ad93f25acf0f4c6546b507f3abb9f7c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:27:50 +0700 Subject: [PATCH 15/32] feat(api): enforce browser CSRF boundary --- services/api/src/bootstrap.ts | 8 +- .../api/src/platform/http/csrf-protection.ts | 114 ++++++++++++++++ .../api/src/platform/http/request-context.ts | 38 +++++- services/api/test/http-contract.test.ts | 49 +++++++ .../platform/http/csrf-protection.test.ts | 124 ++++++++++++++++++ 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 services/api/src/platform/http/csrf-protection.ts create mode 100644 services/api/test/platform/http/csrf-protection.test.ts diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 241e07db..94eb97d4 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -13,7 +13,10 @@ import type { ClientCompatibilityPort } from './features/system/application/clie import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; import { configureOpenApi } from './platform/http/openapi.js'; -import { installRequestContext } from './platform/http/request-context.js'; +import { + installRequestContext, + type RequestContextOptions, +} from './platform/http/request-context.js'; import { createValidationPipe } from './platform/http/validation.js'; export interface ApiApplication { @@ -28,13 +31,14 @@ export interface ApiApplicationOptions DsoModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; + readonly requestContext?: RequestContextOptions; } export async function createApiApplication( options: ApiApplicationOptions = {}, ): Promise { const adapter = new FastifyAdapter({ bodyLimit: 65_536, logger: false }); - installRequestContext(adapter.getInstance()); + installRequestContext(adapter.getInstance(), options.requestContext); const app = await NestFactory.create( AppModule.register(options), adapter, diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts new file mode 100644 index 00000000..d16bba3c --- /dev/null +++ b/services/api/src/platform/http/csrf-protection.ts @@ -0,0 +1,114 @@ +import { compareCsrfTokensV1 } from '@databreeze/domain/v1'; + +export const DEFAULT_CSRF_ALLOWED_ORIGINS_V1 = Object.freeze([ + 'http://localhost:3000', + 'http://127.0.0.1:3000', +]); + +export interface CsrfRequestV1 { + readonly method: string; + readonly headers: Readonly>; +} + +export interface CsrfProtectionOptionsV1 { + readonly allowedOrigins: readonly string[]; +} + +export type CsrfRequestResultV1 = + | { readonly accepted: true } + | { readonly accepted: false; readonly code: 'CSRF_REQUIRED' | 'CSRF_INVALID' | 'ORIGIN_INVALID' }; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const COOKIE_AUTH_NAMES = new Set(['databreeze_access', 'databreeze_refresh', 'databreeze_session']); +const CSRF_COOKIE_NAME = 'databreeze_csrf'; + +function oneHeader( + headers: CsrfRequestV1['headers'], + name: string, +): { readonly present: false } | { readonly present: true; readonly value: string } | { readonly present: true; readonly ambiguous: true } { + const matching = Object.entries(headers) + .filter(([key]) => key.toLowerCase() === name) + .map(([, value]) => value) + .filter((value): value is string | readonly string[] => value !== undefined); + if (matching.length !== 1) return matching.length === 0 ? { present: false } : { present: true, ambiguous: true }; + const value = matching[0]; + if (typeof value !== 'string') return { present: true, ambiguous: true }; + return { present: true, value }; +} + +function parseCookies(raw: string): { readonly values: ReadonlyMap; readonly duplicateNames: ReadonlySet; readonly malformed: boolean } { + const values = new Map(); + const duplicateNames = new Set(); + let malformed = false; + for (const segment of raw.split(';')) { + const trimmed = segment.trim(); + if (trimmed.length === 0) continue; + const equals = trimmed.indexOf('='); + if (equals <= 0) { + malformed = true; + continue; + } + const name = trimmed.slice(0, equals).trim(); + const value = trimmed.slice(equals + 1).trim(); + if (!/^[A-Za-z0-9_]+$/u.test(name) || value.includes('\r') || value.includes('\n')) { + malformed = true; + continue; + } + if (values.has(name)) duplicateNames.add(name); + values.set(name, value); + } + return { values, duplicateNames, malformed }; +} + +function hasCookieAuth(cookies: ReturnType): boolean { + for (const name of COOKIE_AUTH_NAMES) { + if (cookies.values.has(name)) return true; + } + return false; +} + +function originAccepted(headers: CsrfRequestV1['headers'], options: CsrfProtectionOptionsV1): boolean { + const origin = oneHeader(headers, 'origin'); + if (origin.present && 'ambiguous' in origin) return false; + if (origin.present) return options.allowedOrigins.includes(origin.value); + + const fetchSite = oneHeader(headers, 'sec-fetch-site'); + if (!fetchSite.present || 'ambiguous' in fetchSite) return false; + return fetchSite.value === 'same-origin' || fetchSite.value === 'same-site'; +} + +/** + * Enforce CSRF only at the browser-cookie boundary. Native clients use bearer + * or device proof-of-possession credentials and must not be forced to invent a + * browser token. Every ambiguous header/cookie state fails closed. + */ +export function evaluateCsrfRequestV1( + request: CsrfRequestV1, + options: CsrfProtectionOptionsV1, +): CsrfRequestResultV1 { + const method = request.method.toUpperCase(); + if (SAFE_METHODS.has(method)) return Object.freeze({ accepted: true as const }); + + const cookie = oneHeader(request.headers, 'cookie'); + if (!cookie.present) return Object.freeze({ accepted: true as const }); + if ('ambiguous' in cookie) return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + + const cookies = parseCookies(cookie.value); + if (!hasCookieAuth(cookies)) return Object.freeze({ accepted: true as const }); + if (!originAccepted(request.headers, options)) { + return Object.freeze({ accepted: false as const, code: 'ORIGIN_INVALID' as const }); + } + if (cookies.malformed || cookies.duplicateNames.has(CSRF_COOKIE_NAME)) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } + + const csrfCookie = cookies.values.get(CSRF_COOKIE_NAME); + const csrfHeader = oneHeader(request.headers, 'x-csrf-token'); + if (csrfCookie === undefined || !csrfHeader.present || 'ambiguous' in csrfHeader) { + return Object.freeze({ accepted: false as const, code: 'CSRF_REQUIRED' as const }); + } + if (!compareCsrfTokensV1(csrfCookie, csrfHeader.value)) { + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + } + return Object.freeze({ accepted: true as const }); +} diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 51343853..3b9068d2 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -2,6 +2,11 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { + DEFAULT_CSRF_ALLOWED_ORIGINS_V1, + evaluateCsrfRequestV1, + type CsrfProtectionOptionsV1, +} from './csrf-protection.js'; import { createProblem } from './problem-details.js'; export interface RequestContext { @@ -16,6 +21,10 @@ export type CorrelationHeaderResult = | { readonly accepted: true; readonly correlationId: string } | { readonly accepted: false }; +export interface RequestContextOptions { + readonly csrf?: Partial; +} + export function parseCorrelationHeader( values: readonly string[], requestId: string, @@ -35,7 +44,10 @@ export function getRequestContext(request: FastifyRequest): RequestContext { return context; } -export function installRequestContext(fastify: FastifyInstance): void { +export function installRequestContext( + fastify: FastifyInstance, + options: RequestContextOptions = {}, +): void { fastify.addHook('onRequest', (request, reply, done) => { const requestId = randomUUID(); const context: RequestContext = { correlationId: requestId, requestId }; @@ -68,6 +80,30 @@ export function installRequestContext(fastify: FastifyInstance): void { const acceptedContext = { correlationId: parsed.correlationId, requestId }; requestContexts.set(request, acceptedContext); reply.header('X-Correlation-Id', acceptedContext.correlationId); + const csrf = evaluateCsrfRequestV1( + { method: request.method, headers: request.headers }, + { allowedOrigins: options.csrf?.allowedOrigins ?? DEFAULT_CSRF_ALLOWED_ORIGINS_V1 }, + ); + if (!csrf.accepted) { + reply + .code(403) + .type('application/problem+json') + .send( + createProblem({ + code: csrf.code, + correlationId: acceptedContext.correlationId, + messageKey: + csrf.code === 'ORIGIN_INVALID' + ? 'api.error.origin_invalid' + : csrf.code === 'CSRF_REQUIRED' + ? 'api.error.csrf_required' + : 'api.error.csrf_invalid', + retryable: false, + status: 403, + }), + ); + return; + } done(); }); } diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 97b22f86..6d80c148 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -23,6 +23,7 @@ const problemSchemaId = 'https://schemas.databreeze.dev/contracts/v1/problem-det const correlationId = '123e4567-e89b-42d3-a456-426614174000'; const secondCorrelationId = '018f1f08-7b2c-7c74-8e12-f639c7c92b15'; const leakedMarker = 'do-not-leak-7f6290'; +const csrfToken = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; async function withApp( options: Parameters[0], @@ -178,6 +179,54 @@ void test('validates closed compatibility bodies without implicit scalar coercio }); }); +void test('enforces CSRF only for browser-cookie mutations and keeps token values out of errors', async () => { + await withApp({}, async (app) => { + const missing = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: 'databreeze_refresh=session-value', + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assertProblem(missing, 403, 'CSRF_REQUIRED'); + assert.doesNotMatch(missing.body, new RegExp(csrfToken)); + + const hostile = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'https://evil.example', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assertProblem(hostile, 403, 'ORIGIN_INVALID'); + assert.doesNotMatch(hostile.body, new RegExp(csrfToken)); + + const accepted = await app.inject({ + method: 'POST', + url: '/v1/system/compatibility/check', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web', clientVersion: '1.0.0' }, + }); + assert.equal(accepted.statusCode, 200); + + const safeRead = await app.inject({ + method: 'GET', + url: '/health/live', + headers: { cookie: 'databreeze_refresh=session-value' }, + }); + assert.equal(safeRead.statusCode, 200); + }); +}); + void test('maps an oversized JSON body to safe Problem Details', async () => { await withApp({}, async (app) => { const response = await app.inject({ diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts new file mode 100644 index 00000000..cd0c9735 --- /dev/null +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { evaluateCsrfRequestV1 } from '../../../src/platform/http/csrf-protection.js'; + +const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + +const allowedOrigins = ['https://app.databreeze.example']; + +test('allows safe methods and non-cookie clients without a CSRF token', () => { + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'GET', headers: {} }, + { allowedOrigins }, + ), + { accepted: true }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers: { authorization: 'Bearer access-token' } }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +test('requires a valid double-submit token for cookie-authenticated mutations', () => { + const base = { + method: 'POST', + headers: { + cookie: 'databreeze_refresh=session-value', + origin: 'https://app.databreeze.example', + }, + } as const; + + assert.deepEqual(evaluateCsrfRequestV1(base, { allowedOrigins }), { + accepted: false, + code: 'CSRF_REQUIRED', + }); + assert.deepEqual( + evaluateCsrfRequestV1( + { + ...base, + headers: { + ...base.headers, + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': `${token}x`, + }, + }, + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { + ...base, + headers: { + ...base.headers, + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': token, + }, + }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +test('rejects hostile, ambiguous, or missing browser origin signals', () => { + const headers = { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, + 'x-csrf-token': token, + }; + + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers: { ...headers, origin: 'https://evil.example' } }, + { allowedOrigins }, + ), + { accepted: false, code: 'ORIGIN_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers: { ...headers, origin: ['https://app.databreeze.example', 'https://evil.example'] } }, + { allowedOrigins }, + ), + { accepted: false, code: 'ORIGIN_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { method: 'POST', headers }, + { allowedOrigins }, + ), + { accepted: false, code: 'ORIGIN_INVALID' }, + ); + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'POST', + headers: { ...headers, 'sec-fetch-site': 'same-origin' }, + }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + +test('fails closed for duplicate cookies and duplicate token headers', () => { + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'PATCH', + headers: { + cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}; databreeze_csrf=${token}`, + origin: 'https://app.databreeze.example', + 'x-csrf-token': [token, token], + }, + }, + { allowedOrigins }, + ), + { accepted: false, code: 'CSRF_INVALID' }, + ); +}); From 9f140f15411da9a8180bccb8889fa84bd74876aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:30:43 +0700 Subject: [PATCH 16/32] feat(iam): add secure session cookie boundary --- .../src/features/iam/api/session-cookies.ts | 56 +++++++++++++++++++ .../test/features/iam/session-cookies.test.ts | 36 ++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 services/api/src/features/iam/api/session-cookies.ts create mode 100644 services/api/test/features/iam/session-cookies.test.ts diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts new file mode 100644 index 00000000..0a623380 --- /dev/null +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -0,0 +1,56 @@ +const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; +const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9_-]+$/u; + +export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; +export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; + +export interface CookieOptionsV1 { + readonly httpOnly: boolean; + readonly maxAgeSeconds: number; +} + +function validCookieNameV1(name: string): boolean { + return COOKIE_NAME_PATTERN_V1.test(name); +} + +function validCookieValueV1(value: string): boolean { + return COOKIE_VALUE_PATTERN_V1.test(value); +} + +export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { + if (!validCookieNameV1(name) || !validCookieValueV1(value)) { + throw new Error('Cookie name or value is invalid'); + } + if (!Number.isSafeInteger(options.maxAgeSeconds) || options.maxAgeSeconds < 0) { + throw new Error('Cookie max age is invalid'); + } + return [ + `${name}=${value}`, + `Max-Age=${options.maxAgeSeconds}`, + 'Path=/', + options.httpOnly ? 'HttpOnly' : undefined, + 'Secure', + 'SameSite=Lax', + ] + .filter((part): part is string => part !== undefined) + .join('; '); +} + +/** Read one unencoded, token-shaped cookie without accepting duplicate names. */ +export function readCookieValueV1(rawCookie: unknown, name: string): string | undefined { + if (typeof rawCookie !== 'string' || !validCookieNameV1(name)) return undefined; + let found: string | undefined; + for (const segment of rawCookie.split(';')) { + const trimmed = segment.trim(); + if (trimmed.length === 0) continue; + const equals = trimmed.indexOf('='); + if (equals <= 0) return undefined; + const segmentName = trimmed.slice(0, equals).trim(); + const segmentValue = trimmed.slice(equals + 1).trim(); + if (!validCookieNameV1(segmentName) || !validCookieValueV1(segmentValue)) return undefined; + if (segmentName !== name) continue; + if (found !== undefined) return undefined; + found = segmentValue; + } + return found; +} diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts new file mode 100644 index 00000000..f6483fbe --- /dev/null +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + readCookieValueV1, + serializeCookieV1, +} from '../../../src/features/iam/api/session-cookies.js'; + +const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; + +test('serializes bounded session cookies with explicit browser security attributes', () => { + assert.equal( + serializeCookieV1(REFRESH_COOKIE_NAME_V1, token, { httpOnly: true, maxAgeSeconds: 2_592_000 }), + `${REFRESH_COOKIE_NAME_V1}=${token}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, + ); + assert.equal( + serializeCookieV1(CSRF_COOKIE_NAME_V1, token, { httpOnly: false, maxAgeSeconds: 900 }), + `${CSRF_COOKIE_NAME_V1}=${token}; Max-Age=900; Path=/; Secure; SameSite=Lax`, + ); +}); + +test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { + assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), token); + assert.equal( + readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), + token, + ); + assert.equal( + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}; ${REFRESH_COOKIE_NAME_V1}=other`, REFRESH_COOKIE_NAME_V1), + undefined, + ); + assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); + assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); +}); From a909d9c1bf0ce30b72befdfc16d317a4df055468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:35:31 +0700 Subject: [PATCH 17/32] feat(iam): keep browser refresh credentials in cookies --- services/api/openapi/v1.json | 1 - .../src/features/iam/api/auth-session.dto.ts | 15 ++++++++-- .../iam/api/authentication.controller.ts | 30 +++++++++++++++++-- services/api/test/http-contract.test.ts | 6 +++- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 987e8834..5ab3fc38 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3150,7 +3150,6 @@ "organizationId", "workspaceId", "accessToken", - "refreshToken", "accessExpiresAt", "securityEpoch", "mfaRequired" diff --git a/services/api/src/features/iam/api/auth-session.dto.ts b/services/api/src/features/iam/api/auth-session.dto.ts index 0fe5876c..1f181768 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsISO8601, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + IsBoolean, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; /** IAM-005, IAM-006, IAM-012: public response shape for browser and native clients. */ export class AuthSessionDto { @@ -25,11 +33,12 @@ export class AuthSessionDto { @MaxLength(4096) accessToken!: string; - @ApiProperty({ minLength: 1, maxLength: 4096 }) + @ApiProperty({ minLength: 1, maxLength: 4096, required: false }) + @IsOptional() @IsString() @MinLength(1) @MaxLength(4096) - refreshToken!: string; + refreshToken?: string; @ApiProperty({ format: 'date-time' }) @IsISO8601() diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 270b2b19..6d5f9071 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,4 +1,6 @@ -import { Body, Controller, HttpCode, Inject, Post } from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; + +import { Body, Controller, HttpCode, Inject, Post, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -13,8 +15,14 @@ import { type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; +import { + CSRF_COOKIE_NAME_V1, + REFRESH_COOKIE_NAME_V1, + serializeCookieV1, +} from './session-cookies.js'; import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; +import type { FastifyReply } from 'fastify'; @ApiTags('auth') @Controller('v1/auth') @@ -31,16 +39,32 @@ export class AuthenticationController { @ApiOkResponse({ type: AuthSessionDto }) @ApiUnauthorizedResponse({ description: 'Credentials were rejected.' }) @ApiServiceUnavailableResponse({ description: 'Authentication provider is unavailable.' }) - async signIn(@Body() input: SignInDto): Promise { + async signIn( + @Body() input: SignInDto, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { const result = await this.authentication.signIn(input); if (!result.accepted) throw new AuthenticationProblemError(result.code); + if (input.clientPlatform === 'web') { + const csrfToken = randomBytes(32).toString('base64url'); + reply.header('Set-Cookie', [ + serializeCookieV1(REFRESH_COOKIE_NAME_V1, result.value.session.refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), + serializeCookieV1(CSRF_COOKIE_NAME_V1, csrfToken, { + httpOnly: false, + maxAgeSeconds: 2_592_000, + }), + ]); + } return { sessionId: result.value.session.sessionId, userId: result.value.principal.userId, organizationId: result.value.principal.organizationId, workspaceId: result.value.principal.workspaceId, accessToken: result.value.session.accessToken, - refreshToken: result.value.session.refreshToken, + ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.session.refreshToken }), accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 6d80c148..5802e12b 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -300,11 +300,15 @@ void test('sign-in returns a session DTO and maps authentication failures withou organizationId: '00000000-0000-4000-8000-000000000002', workspaceId: '00000000-0000-4000-8000-000000000003', accessToken: 'access-token', - refreshToken: 'refresh-token', accessExpiresAt: '2026-01-01T00:15:00.000Z', securityEpoch: 2, mfaRequired: true, }); + const setCookies = response.headers['set-cookie']; + assert.ok(Array.isArray(setCookies)); + assert.equal(setCookies.length, 2); + assert.match(setCookies[0] ?? '', /^databreeze_refresh=refresh-token; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match(setCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); assertResponseIdentifiers(response); }, ); From 305fdecb11d123668e0fa4e3794fe9784feba12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:35:37 +0700 Subject: [PATCH 18/32] fix(iam): accept rotating refresh token cookie values --- .../api/src/features/iam/api/session-cookies.ts | 2 +- .../api/test/features/iam/session-cookies.test.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 0a623380..75673ac6 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,5 +1,5 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; -const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9_-]+$/u; +const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index f6483fbe..8e8c3aa4 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -9,11 +9,12 @@ import { } from '../../../src/features/iam/api/session-cookies.js'; const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; +const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; test('serializes bounded session cookies with explicit browser security attributes', () => { assert.equal( - serializeCookieV1(REFRESH_COOKIE_NAME_V1, token, { httpOnly: true, maxAgeSeconds: 2_592_000 }), - `${REFRESH_COOKIE_NAME_V1}=${token}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, + serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, { httpOnly: true, maxAgeSeconds: 2_592_000 }), + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, ); assert.equal( serializeCookieV1(CSRF_COOKIE_NAME_V1, token, { httpOnly: false, maxAgeSeconds: 900 }), @@ -22,13 +23,13 @@ test('serializes bounded session cookies with explicit browser security attribut }); test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { - assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), token); + assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), refreshToken); assert.equal( - readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${token}`, REFRESH_COOKIE_NAME_V1), - token, + readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), + refreshToken, ); assert.equal( - readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${token}; ${REFRESH_COOKIE_NAME_V1}=other`, REFRESH_COOKIE_NAME_V1), + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${REFRESH_COOKIE_NAME_V1}=other`, REFRESH_COOKIE_NAME_V1), undefined, ); assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); From 3af91014389988c14179926be4fd5a2a9a7a7692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:43:25 +0700 Subject: [PATCH 19/32] feat(iam): rotate refresh sessions through HTTP --- services/api/openapi/v1.json | 127 ++++++++++++++++++ .../iam/api/authentication.controller.ts | 55 +++++++- .../iam/api/session-refresh-response.dto.ts | 25 ++++ .../features/iam/api/session-refresh.dto.ts | 15 +++ .../iam/application/session-problem.error.ts | 8 ++ services/api/src/features/iam/iam.module.ts | 8 +- .../platform/http/problem-details.filter.ts | 11 ++ services/api/test/http-contract.test.ts | 76 +++++++++++ services/api/test/openapi.test.ts | 1 + 9 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 services/api/src/features/iam/api/session-refresh-response.dto.ts create mode 100644 services/api/src/features/iam/api/session-refresh.dto.ts create mode 100644 services/api/src/features/iam/application/session-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 5ab3fc38..b148260e 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -454,6 +454,110 @@ "tags": ["auth"] } }, + "/v1/auth/refresh": { + "post": { + "operationId": "AuthenticationController.refresh", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/SessionRefreshDto" } } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SessionRefreshResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "401": { + "description": "The refresh session was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Session persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Rotate a short-lived session", + "tags": ["auth"] + } + }, "/v1/devices/enrollment-challenges": { "post": { "operationId": "DeviceIdentityController.issueChallenge", @@ -3155,6 +3259,29 @@ "mfaRequired" ] }, + "SessionRefreshDto": { + "type": "object", + "properties": { + "clientPlatform": { "type": "string", "enum": ["android", "desktop", "web"] }, + "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true } + }, + "required": ["clientPlatform"] + }, + "SessionRefreshResponseDto": { + "type": "object", + "properties": { + "sessionId": { "type": "string", "format": "uuid" }, + "accessToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "refreshToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "writeOnly": true + }, + "accessExpiresAt": { "type": "string", "format": "date-time" } + }, + "required": ["sessionId", "accessToken", "accessExpiresAt"] + }, "IssueDeviceEnrollmentChallengeDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 6d5f9071..e9b4c781 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { Body, Controller, HttpCode, Inject, Post, Res } from '@nestjs/common'; +import { Body, Controller, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -15,14 +15,19 @@ import { type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; +import { SESSION_LIFECYCLE_PORT, type SessionLifecyclePortV1 } from '../application/session-lifecycle.port.js'; +import { SessionProblemError } from '../application/session-problem.error.js'; import { CSRF_COOKIE_NAME_V1, REFRESH_COOKIE_NAME_V1, + readCookieValueV1, serializeCookieV1, } from './session-cookies.js'; import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; -import type { FastifyReply } from 'fastify'; +import { SessionRefreshDto } from './session-refresh.dto.js'; +import { SessionRefreshResponseDto } from './session-refresh-response.dto.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @Controller('v1/auth') @@ -30,6 +35,9 @@ export class AuthenticationController { constructor( @Inject(AUTHENTICATION_USE_CASE) private readonly authentication: AuthenticationUseCaseV1, + @Optional() + @Inject(SESSION_LIFECYCLE_PORT) + private readonly sessions?: SessionLifecyclePortV1, ) {} @Post('sign-in') @@ -70,4 +78,47 @@ export class AuthenticationController { mfaRequired: result.value.principal.mfaRequired, }; } + + @Post('refresh') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate a short-lived session' }) + @ApiBody({ type: SessionRefreshDto }) + @ApiOkResponse({ type: SessionRefreshResponseDto }) + @ApiUnauthorizedResponse({ description: 'The refresh session was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Session persistence is unavailable.' }) + async refresh( + @Body() input: SessionRefreshDto, + @Req() request: FastifyRequest, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const refreshToken = + input.clientPlatform === 'web' + ? readCookieValueV1(request.headers.cookie, REFRESH_COOKIE_NAME_V1) + : input.refreshToken; + if (refreshToken === undefined || (input.clientPlatform === 'web' && input.refreshToken !== undefined)) { + throw new SessionProblemError('SESSION_INVALID'); + } + const result = await this.sessions.refresh(refreshToken, input.clientPlatform); + if (!result.accepted) throw new SessionProblemError('SESSION_INVALID'); + if (input.clientPlatform === 'web') { + const csrfToken = randomBytes(32).toString('base64url'); + reply.header('Set-Cookie', [ + serializeCookieV1(REFRESH_COOKIE_NAME_V1, result.value.refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), + serializeCookieV1(CSRF_COOKIE_NAME_V1, csrfToken, { + httpOnly: false, + maxAgeSeconds: 2_592_000, + }), + ]); + } + return { + sessionId: result.value.sessionId, + accessToken: result.value.accessToken, + accessExpiresAt: result.value.accessExpiresAt, + ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.refreshToken }), + }; + } } diff --git a/services/api/src/features/iam/api/session-refresh-response.dto.ts b/services/api/src/features/iam/api/session-refresh-response.dto.ts new file mode 100644 index 00000000..a440e029 --- /dev/null +++ b/services/api/src/features/iam/api/session-refresh-response.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class SessionRefreshResponseDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; + + @ApiProperty({ minLength: 1, maxLength: 4096 }) + @IsString() + @MinLength(1) + @MaxLength(4096) + accessToken!: string; + + @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(4096) + refreshToken?: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + accessExpiresAt!: string; +} diff --git a/services/api/src/features/iam/api/session-refresh.dto.ts b/services/api/src/features/iam/api/session-refresh.dto.ts new file mode 100644 index 00000000..dab25789 --- /dev/null +++ b/services/api/src/features/iam/api/session-refresh.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SessionRefreshDto { + @ApiProperty({ enum: ['android', 'desktop', 'web'] }) + @IsIn(['android', 'desktop', 'web']) + clientPlatform!: 'android' | 'desktop' | 'web'; + + @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(4096) + refreshToken?: string; +} diff --git a/services/api/src/features/iam/application/session-problem.error.ts b/services/api/src/features/iam/application/session-problem.error.ts new file mode 100644 index 00000000..99b6cac0 --- /dev/null +++ b/services/api/src/features/iam/application/session-problem.error.ts @@ -0,0 +1,8 @@ +export type SessionProblemCodeV1 = 'SESSION_INVALID' | 'SESSION_UNAVAILABLE'; + +export class SessionProblemError extends Error { + constructor(readonly code: SessionProblemCodeV1) { + super(code); + this.name = 'SessionProblemError'; + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 5ebb25af..f09f8551 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -7,9 +7,11 @@ import { CREDENTIAL_LOOKUP_PORT, type CredentialLookupPortV1, type AuthenticationUseCaseV1, - type SessionIssuerPortV1, } from './application/authentication.port.js'; -import { SESSION_LIFECYCLE_PORT } from './application/session-lifecycle.port.js'; +import { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from './application/session-lifecycle.port.js'; import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT, type IdentityBootstrapRepositoryPortV1, @@ -59,7 +61,7 @@ export interface IamModuleOptions { readonly credentials?: CredentialLookupPortV1; readonly credentialDatabase?: CredentialLookupDatabaseClientV1; readonly passwordCredentials?: PasswordCredentialService; - readonly sessions?: SessionIssuerPortV1; + readonly sessions?: SessionLifecyclePortV1; readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 284cbb53..e400a402 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -8,6 +8,7 @@ import { import type { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationProblemError } from '../../features/iam/application/authentication-problem.error.js'; +import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; import { createProblem, type ProblemInput } from './problem-details.js'; @@ -32,6 +33,16 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, }; } + if (error instanceof SessionProblemError) { + const unavailable = error.code === 'SESSION_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable ? 'api.error.session_unavailable' : 'api.error.session_invalid', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, + }; + } if (error instanceof InputValidationException) { return { code: 'VALIDATION_FAILED', diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 5802e12b..9a3e3472 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -348,3 +348,79 @@ void test('sign-in returns a session DTO and maps authentication failures withou assertProblem(response, 503, 'AUTHENTICATION_UNAVAILABLE'); }); }); + +void test('refresh rotates Web cookies without returning the refresh token and preserves native delivery', async () => { + const refreshed = { + sessionId: '00000000-0000-4000-8000-000000000020', + accessToken: 'next-access-token', + refreshToken: 'next-refresh-token', + accessExpiresAt: '2026-01-01T00:15:00.000Z', + }; + const presented: string[] = []; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: (token, platform) => { + if (platform === 'web') presented.push(String(token)); + return Promise.resolve({ accepted: true as const, value: refreshed }); + }, + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const web = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + headers: { + cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { clientPlatform: 'web' }, + }); + assert.equal(web.statusCode, 200); + assert.deepEqual(web.json(), { + sessionId: refreshed.sessionId, + accessToken: refreshed.accessToken, + accessExpiresAt: refreshed.accessExpiresAt, + }); + const webCookies = web.headers['set-cookie']; + assert.ok(Array.isArray(webCookies)); + assert.equal(webCookies.length, 2); + assert.match(webCookies[0] ?? '', /^databreeze_refresh=next-refresh-token; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match(webCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); + + const native = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { clientPlatform: 'desktop', refreshToken: 'desktop-refresh-token' }, + }); + assert.equal(native.statusCode, 200); + assert.equal(native.json().refreshToken, refreshed.refreshToken); + assert.equal(native.headers['set-cookie'], undefined); + assert.deepEqual(presented, ['current-refresh-token']); + }, + ); + + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { clientPlatform: 'desktop', refreshToken: 'reused-token' }, + }); + assertProblem(response, 401, 'SESSION_INVALID'); + assert.doesNotMatch(response.body, /REUSE_DETECTED/); + }, + ); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 43beaa2c..5ff8985a 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/evidence-grants/{grantId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/auth/refresh', '/v1/auth/sign-in', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', From 15d7018075bf92d04c71cc87c19621f3b5297402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:48:04 +0700 Subject: [PATCH 20/32] feat(iam): revoke sessions through HTTP --- services/api/openapi/v1.json | 94 +++++++++++++++++++ .../iam/api/authentication.controller.ts | 22 +++++ .../src/features/iam/api/session-cookies.ts | 14 +++ .../features/iam/api/session-sign-out.dto.ts | 12 +++ .../test/features/iam/session-cookies.test.ts | 12 +++ services/api/test/http-contract.test.ts | 53 +++++++++++ services/api/test/openapi.test.ts | 1 + 7 files changed, 208 insertions(+) create mode 100644 services/api/src/features/iam/api/session-sign-out.dto.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b148260e..3fc8b9a2 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -558,6 +558,92 @@ "tags": ["auth"] } }, + "/v1/auth/sign-out": { + "post": { + "operationId": "AuthenticationController.signOut", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/SessionSignOutDto" } } + } + }, + "responses": { + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "401": { + "description": "The session could not be authenticated.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Session persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Revoke a session and clear browser credentials", + "tags": ["auth"] + } + }, "/v1/devices/enrollment-challenges": { "post": { "operationId": "DeviceIdentityController.issueChallenge", @@ -3282,6 +3368,14 @@ }, "required": ["sessionId", "accessToken", "accessExpiresAt"] }, + "SessionSignOutDto": { + "type": "object", + "properties": { + "clientPlatform": { "type": "string", "enum": ["android", "desktop", "web"] }, + "sessionId": { "type": "string", "format": "uuid" } + }, + "required": ["clientPlatform", "sessionId"] + }, "IssueDeviceEnrollmentChallengeDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index e9b4c781..d222efd3 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -20,6 +20,7 @@ import { SessionProblemError } from '../application/session-problem.error.js'; import { CSRF_COOKIE_NAME_V1, REFRESH_COOKIE_NAME_V1, + clearCookieV1, readCookieValueV1, serializeCookieV1, } from './session-cookies.js'; @@ -27,6 +28,7 @@ import { AuthSessionDto } from './auth-session.dto.js'; import { SignInDto } from './sign-in.dto.js'; import { SessionRefreshDto } from './session-refresh.dto.js'; import { SessionRefreshResponseDto } from './session-refresh-response.dto.js'; +import { SessionSignOutDto } from './session-sign-out.dto.js'; import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @@ -121,4 +123,24 @@ export class AuthenticationController { ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.refreshToken }), }; } + + @Post('sign-out') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke a session and clear browser credentials' }) + @ApiBody({ type: SessionSignOutDto }) + @ApiUnauthorizedResponse({ description: 'The session could not be authenticated.' }) + @ApiServiceUnavailableResponse({ description: 'Session persistence is unavailable.' }) + async signOut( + @Body() input: SessionSignOutDto, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + await this.sessions.revoke(input.sessionId); + if (input.clientPlatform === 'web') { + reply.header('Set-Cookie', [ + clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), + clearCookieV1(CSRF_COOKIE_NAME_V1, { httpOnly: false }), + ]); + } + } } diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index 75673ac6..5feab2a3 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -36,6 +36,20 @@ export function serializeCookieV1(name: string, value: string, options: CookieOp .join('; '); } +export function clearCookieV1(name: string, options: Pick): string { + if (!validCookieNameV1(name)) throw new Error('Cookie name is invalid'); + return [ + `${name}=`, + 'Max-Age=0', + 'Path=/', + options.httpOnly ? 'HttpOnly' : undefined, + 'Secure', + 'SameSite=Lax', + ] + .filter((part): part is string => part !== undefined) + .join('; '); +} + /** Read one unencoded, token-shaped cookie without accepting duplicate names. */ export function readCookieValueV1(rawCookie: unknown, name: string): string | undefined { if (typeof rawCookie !== 'string' || !validCookieNameV1(name)) return undefined; diff --git a/services/api/src/features/iam/api/session-sign-out.dto.ts b/services/api/src/features/iam/api/session-sign-out.dto.ts new file mode 100644 index 00000000..47de34ed --- /dev/null +++ b/services/api/src/features/iam/api/session-sign-out.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsUUID } from 'class-validator'; + +export class SessionSignOutDto { + @ApiProperty({ enum: ['android', 'desktop', 'web'] }) + @IsIn(['android', 'desktop', 'web']) + clientPlatform!: 'android' | 'desktop' | 'web'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + sessionId!: string; +} diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 8e8c3aa4..2e6345a3 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { CSRF_COOKIE_NAME_V1, REFRESH_COOKIE_NAME_V1, + clearCookieV1, readCookieValueV1, serializeCookieV1, } from '../../../src/features/iam/api/session-cookies.js'; @@ -35,3 +36,14 @@ test('reads one exact cookie value and fails closed for ambiguity or malformed i assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); + +test('creates deletion cookies without weakening the original security attributes', () => { + assert.equal( + clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), + `${REFRESH_COOKIE_NAME_V1}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`, + ); + assert.equal( + clearCookieV1(CSRF_COOKIE_NAME_V1, { httpOnly: false }), + `${CSRF_COOKIE_NAME_V1}=; Max-Age=0; Path=/; Secure; SameSite=Lax`, + ); +}); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 9a3e3472..c3d00810 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -424,3 +424,56 @@ void test('refresh rotates Web cookies without returning the refresh token and p }, ); }); + +void test('sign-out revokes idempotently and clears browser credentials', async () => { + const revoked: string[] = []; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: (sessionId) => { + revoked.push(String(sessionId)); + return Promise.resolve(false); + }, + findPrincipal: () => Promise.resolve(undefined), + }, + }, + async (app) => { + const web = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { + cookie: `databreeze_refresh=current-refresh-token; databreeze_csrf=${csrfToken}`, + 'x-csrf-token': csrfToken, + origin: 'http://localhost:3000', + }, + payload: { + clientPlatform: 'web', + sessionId: '00000000-0000-4000-8000-000000000010', + }, + }); + assert.equal(web.statusCode, 204); + assert.equal(web.body, ''); + const webCookies = web.headers['set-cookie']; + assert.ok(Array.isArray(webCookies)); + assert.match(webCookies[0] ?? '', /^databreeze_refresh=; Max-Age=0; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match(webCookies[1] ?? '', /^databreeze_csrf=; Max-Age=0; .*Secure; SameSite=Lax$/); + + const native = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000011', + }, + }); + assert.equal(native.statusCode, 204); + assert.equal(native.headers['set-cookie'], undefined); + assert.deepEqual(revoked, [ + '00000000-0000-4000-8000-000000000010', + '00000000-0000-4000-8000-000000000011', + ]); + }, + ); +}); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 5ff8985a..6cdd3359 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -68,6 +68,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/auth/refresh', '/v1/auth/sign-in', + '/v1/auth/sign-out', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/datasets', From 5dc17798a388009006114b16834526228f1ade23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:53:28 +0700 Subject: [PATCH 21/32] feat(iam): persist access-token lookup digests --- .../migration.sql | 16 ++++ services/api/prisma/schema/iam.prisma | 15 ++++ .../in-memory-session-lifecycle.adapter.ts | 18 ++++- .../prisma-session-lifecycle.adapter.ts | 77 ++++++++++++++++++- .../iam/application/session-lifecycle.port.ts | 7 ++ .../iam/prisma-session-lifecycle.test.ts | 35 ++++++++- services/api/test/prisma-foundation.test.mjs | 15 ++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql diff --git a/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql b/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql new file mode 100644 index 00000000..2c57dc9f --- /dev/null +++ b/services/api/prisma/migrations/20260802220000_iam_access_tokens/migration.sql @@ -0,0 +1,16 @@ +-- IAM-005: persist only digests for short-lived access-token lookup. +CREATE TABLE "iam"."access_tokens" ( + "id" UUID NOT NULL, + "session_id" UUID NOT NULL, + "token_digest" VARCHAR(128) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'ACTIVE', + "revoked_at" TIMESTAMPTZ(6), + + CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "access_tokens_digest_key" ON "iam"."access_tokens"("token_digest"); +CREATE INDEX "access_tokens_session_status_idx" ON "iam"."access_tokens"("session_id", "status"); +CREATE INDEX "access_tokens_expiry_idx" ON "iam"."access_tokens"("expires_at"); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 387c0b2c..0dd1e5b9 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -192,6 +192,21 @@ model MfaRecoveryCode { @@schema("iam") } +model AccessTokenRecord { + id String @id @db.Uuid + sessionId String @map("session_id") @db.Uuid + tokenDigest String @unique(map: "access_tokens_digest_key") @map("token_digest") @db.VarChar(128) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(24) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + + @@index([sessionId, status], map: "access_tokens_session_status_idx") + @@index([expiresAt], map: "access_tokens_expiry_idx") + @@map("access_tokens") + @@schema("iam") +} + model AuthorizationSnapshot { id String @id @db.Uuid deviceId String @map("device_id") @db.Uuid diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index 8364aac0..e25db51b 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -71,6 +71,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { private readonly clock: () => Date; private readonly sessions = new Map(); private readonly refreshTokens = new Map(); + private readonly accessTokens = new Map(); public constructor(options: SessionLifecycleAdapterOptionsV1 = {}) { this.clock = options.clock ?? (() => new Date()); @@ -99,6 +100,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!created.accepted) return Promise.reject(new Error(`IAM_${created.code}`)); const refreshToken = tokenFor(activeTokenIdentifier); + const accessToken = tokenFor(stableIdentifier(randomUUID())); this.sessions.set(sessionId, { record: created.value, principal: Object.freeze({ ...principal }), @@ -112,9 +114,10 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { expiresAt: created.value.absoluteExpiresAt, status: 'ACTIVE', }); + this.accessTokens.set(digestToken(accessToken), sessionId); return Promise.resolve({ sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken, refreshToken, accessExpiresAt: created.value.accessExpiresAt, }); @@ -156,6 +159,7 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { token.status = 'USED'; session.activeTokenId = nextTokenId; const nextRefreshToken = tokenFor(nextTokenId); + const nextAccessToken = tokenFor(stableIdentifier(randomUUID())); this.refreshTokens.set(digestToken(nextRefreshToken), { tokenId: nextTokenId, sessionId: session.record.sessionId, @@ -163,9 +167,10 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { expiresAt: session.record.absoluteExpiresAt, status: 'ACTIVE', }); + this.accessTokens.set(digestToken(nextAccessToken), token.sessionId); return acceptedSession({ sessionId: session.record.sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken: nextAccessToken, refreshToken: nextRefreshToken, accessExpiresAt: addSeconds(this.clock(), ACCESS_TOKEN_SECONDS_V1), }); @@ -188,6 +193,15 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return session.principal; } + public async findPrincipalByAccessToken( + accessTokenInput: unknown, + ): Promise { + await Promise.resolve(); + if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; + const sessionId = this.accessTokens.get(digestToken(accessTokenInput)); + return sessionId === undefined ? undefined : this.findPrincipal(sessionId); + } + private revokeFamily(familyId: StableIdentifierV1): void { for (const session of this.sessions.values()) { if (session.record.familyId === familyId) session.familyStatus = 'REVOKED'; diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 1adc7607..257fda7b 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -46,6 +46,16 @@ export interface RefreshTokenDatabaseRowV1 { readonly usedAt?: Date | null; } +export interface AccessTokenDatabaseRowV1 { + readonly id: string; + readonly sessionId: string; + readonly tokenDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: string; + readonly revokedAt?: Date | null; +} + export interface SessionUserDatabaseRowV1 { readonly id: string; readonly status: string; @@ -106,6 +116,19 @@ interface RefreshTokenDelegateV1 { }): Promise<{ readonly count: number }>; } +interface AccessTokenDelegateV1 { + create(input: { + readonly data: AccessTokenDatabaseRowV1; + }): Promise; + findUnique(input: { + readonly where: { readonly tokenDigest: string }; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + interface UniqueDelegateV1 { findUnique(input: { readonly where: Readonly>; @@ -121,6 +144,7 @@ interface ListDelegateV1 { export interface SessionLifecycleDatabaseClientV1 { readonly sessionRecord: SessionDelegateV1; readonly refreshTokenRecord: RefreshTokenDelegateV1; + readonly accessTokenRecord: AccessTokenDelegateV1; readonly userIdentity: UniqueDelegateV1; readonly membershipIdentity: ListDelegateV1; readonly workspaceIdentity: UniqueDelegateV1; @@ -234,6 +258,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!created.accepted) throw new Error(`IAM_${created.code}`); const refreshToken = tokenFor(refreshTokenId); + const accessTokenId = stableIdentifier(randomUUID()); + const accessToken = tokenFor(accessTokenId); const record = created.value; await this.client.$transaction(async (transaction) => { await transaction.sessionRecord.create({ @@ -261,10 +287,21 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { usedAt: null, }, }); + await transaction.accessTokenRecord.create({ + data: { + id: accessTokenId, + sessionId: record.sessionId, + tokenDigest: digestToken(accessToken), + issuedAt: new Date(record.issuedAt), + expiresAt: new Date(record.accessExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); }); return { sessionId: record.sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken, refreshToken, accessExpiresAt: record.accessExpiresAt, }; @@ -312,6 +349,10 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { where: { id: token.sessionId }, data: { status: 'REVOKED', revokedAt: now }, }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId: token.sessionId, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: now }, + }); } else if (rotated.code === 'EXPIRED' && token.status === 'ACTIVE') { await transaction.refreshTokenRecord.updateMany({ where: { id: token.id, status: 'ACTIVE' }, @@ -341,6 +382,8 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { data: { accessExpiresAt: new Date(accessExpiresAt), inactivityExpiresAt: new Date(inactivityExpiresAt) }, }); const nextRefreshToken = tokenFor(rotated.nextTokenId); + const nextAccessTokenId = stableIdentifier(randomUUID()); + const nextAccessToken = tokenFor(nextAccessTokenId); await transaction.refreshTokenRecord.create({ data: { id: rotated.nextTokenId, @@ -353,9 +396,20 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { usedAt: null, }, }); + await transaction.accessTokenRecord.create({ + data: { + id: nextAccessTokenId, + sessionId: session.sessionId, + tokenDigest: digestToken(nextAccessToken), + issuedAt: now, + expiresAt: new Date(accessExpiresAt), + status: 'ACTIVE', + revokedAt: null, + }, + }); return successfulSession({ sessionId: session.sessionId, - accessToken: tokenFor(stableIdentifier(randomUUID())), + accessToken: nextAccessToken, refreshToken: nextRefreshToken, accessExpiresAt, }); @@ -380,10 +434,29 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { where: { familyId: session.familyId, status: 'ACTIVE' }, data: { status: 'REVOKED' }, }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId: session.id, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: now }, + }); return true; }); } + public async findPrincipalByAccessToken( + accessTokenInput: unknown, + ): Promise { + if (typeof accessTokenInput !== 'string' || accessTokenInput.length < 80) return undefined; + try { + const row = await this.client.accessTokenRecord.findUnique({ + where: { tokenDigest: digestToken(accessTokenInput) }, + }); + if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) return undefined; + return this.findPrincipal(row.sessionId); + } catch { + return undefined; + } + } + public async findPrincipal(sessionIdInput: unknown): Promise { if (typeof sessionIdInput !== 'string') return undefined; const parsed = parseStableIdentifierV1(sessionIdInput); diff --git a/services/api/src/features/iam/application/session-lifecycle.port.ts b/services/api/src/features/iam/application/session-lifecycle.port.ts index ac6ac2cb..a1105a21 100644 --- a/services/api/src/features/iam/application/session-lifecycle.port.ts +++ b/services/api/src/features/iam/application/session-lifecycle.port.ts @@ -5,6 +5,7 @@ import type { } from './authentication.port.js'; export const SESSION_LIFECYCLE_PORT = Symbol('SESSION_LIFECYCLE_PORT'); +export const SESSION_ACCESS_TOKEN_LOOKUP_PORT = Symbol('SESSION_ACCESS_TOKEN_LOOKUP_PORT'); export type SessionRefreshFailureCodeV1 = | 'INVALID_REFRESH_TOKEN' @@ -29,4 +30,10 @@ export interface SessionLifecyclePortV1 extends SessionIssuerPortV1 { ): Promise; revoke(sessionId: unknown): Promise; findPrincipal(sessionId: unknown): Promise; + /** Optional until a host enables authenticated request-context resolution. */ + findPrincipalByAccessToken?(accessToken: unknown): Promise; +} + +export interface SessionAccessTokenLookupPortV1 { + findPrincipalByAccessToken(accessToken: unknown): Promise; } diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 0e5294e4..3a3cd095 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -6,6 +6,7 @@ import { type SessionLifecycleDatabaseClientV1, type SessionRecordDatabaseRowV1, type RefreshTokenDatabaseRowV1, + type AccessTokenDatabaseRowV1, } from '../../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; const userId = '00000000-0000-4000-8000-000000000001'; @@ -23,9 +24,11 @@ function createDatabase(): { readonly client: SessionLifecycleDatabaseClientV1; readonly sessions: Map; readonly refreshTokens: Map; + readonly accessTokens: Map; } { const sessions = new Map(); const refreshTokens = new Map(); + const accessTokens = new Map(); const client = { sessionRecord: { create: async ({ data }: { readonly data: SessionRecordDatabaseRowV1 }) => { @@ -80,6 +83,30 @@ function createDatabase(): { return { count }; }, }, + accessTokenRecord: { + create: async ({ data }: { readonly data: AccessTokenDatabaseRowV1 }) => { + accessTokens.set(data.id, data); + return data; + }, + findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => + [...accessTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + let count = 0; + for (const [id, row] of accessTokens) { + if (!Object.entries(where).every(([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value)) + continue; + accessTokens.set(id, { ...row, ...data }); + count += 1; + } + return { count }; + }, + }, userIdentity: { findUnique: async () => ({ id: userId, status: 'ACTIVE', securityEpoch: 4 }), }, @@ -108,11 +135,11 @@ function createDatabase(): { $transaction: async (work: (transaction: SessionLifecycleDatabaseClientV1) => Promise) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; - return { client, sessions, refreshTokens }; + return { client, sessions, refreshTokens, accessTokens }; } void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and refresh credentials', async () => { - const { client, sessions, refreshTokens } = createDatabase(); + const { client, sessions, refreshTokens, accessTokens } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date('2026-01-01T00:00:00.000Z'), }); @@ -123,7 +150,10 @@ void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and assert.match(session.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/u); assert.equal(sessions.size, 1); assert.equal(refreshTokens.size, 1); + assert.equal(accessTokens.size, 1); assert.equal((await adapter.findPrincipal(session.sessionId))?.userId, userId); + assert.equal((await adapter.findPrincipalByAccessToken(session.accessToken))?.userId, userId); + assert.equal(await adapter.findPrincipalByAccessToken('not-a-token'), undefined); }); void test('[IAM-005] refresh rotation is transactional and reuse revokes the complete family', async () => { @@ -171,4 +201,5 @@ void test('[IAM-005] revocation is idempotent and hides session principals after assert.equal(await adapter.revoke(session.sessionId), true); assert.equal(await adapter.revoke(session.sessionId), true); assert.equal(await adapter.findPrincipal(session.sessionId), undefined); + assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); }); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 15026d46..43e8de30 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -75,6 +75,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); @@ -104,6 +105,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802190000_dso_capabilities_grants', '20260802200000_dso_data_mode_policies', '20260802210000_iam_mfa_recovery', + '20260802220000_iam_access_tokens', 'migration_lock.toml', ]); const migration = await readFile( @@ -383,4 +385,17 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const accessTokenMigration = await readFile( + path.join(migrationsDirectory, inventory[23], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iam"."access_tokens"', + 'CREATE UNIQUE INDEX "access_tokens_digest_key"', + ]) { + assert.match( + accessTokenMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } }); From 0b19a0b2048e96d2bf536dc8f253585919bec9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 22:58:56 +0700 Subject: [PATCH 22/32] feat(iam): derive tenant context from live sessions --- services/api/src/app.module.ts | 25 +++-- .../platform/http/problem-details.filter.ts | 13 +++ .../http/session-tenant-context.adapter.ts | 102 ++++++++++++++++++ .../foundation-module-composition.test.ts | 25 +++++ .../http/session-tenant-context.test.ts | 79 ++++++++++++++ 5 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 services/api/src/platform/http/session-tenant-context.adapter.ts create mode 100644 services/api/test/platform/http/session-tenant-context.test.ts diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index 5781d5ad..7a0ee73b 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -7,6 +7,7 @@ import { DsmModule, type DsmModuleOptions } from './features/dsm/dsm.module.js'; import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; +import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & @@ -19,16 +20,26 @@ export type AppModuleOptions = SystemModuleOptions & @Module({}) export class AppModule { static register(options: AppModuleOptions = {}): DynamicModule { + const sessions = options.sessions; + const requestTenantContext = + options.requestTenantContext ?? + (typeof sessions?.findPrincipalByAccessToken === 'function' + ? new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: sessions.findPrincipalByAccessToken.bind(sessions), + }) + : undefined); + const composedOptions = + requestTenantContext === undefined ? options : { ...options, requestTenantContext }; return { module: AppModule, imports: [ - SystemModule.register(options), - IamModule.register(options), - IaeModule.register(options), - DsmModule.register(options), - DsoModule.register(options), - AudModule.register(options), - BuaModule.register(options), + SystemModule.register(composedOptions), + IamModule.register(composedOptions), + IaeModule.register(composedOptions), + DsmModule.register(composedOptions), + DsoModule.register(composedOptions), + AudModule.register(composedOptions), + BuaModule.register(composedOptions), ], }; } diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index e400a402..9f4a4b31 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -9,6 +9,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationProblemError } from '../../features/iam/application/authentication-problem.error.js'; import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; +import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; import { createProblem, type ProblemInput } from './problem-details.js'; @@ -43,6 +44,18 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, }; } + if (error instanceof RequestTenantContextProblemError) { + const invalidContext = error.code === 'CONTEXT_INVALID'; + return { + code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', + correlationId, + messageKey: invalidContext + ? 'api.error.context_invalid' + : 'api.error.authentication_failed', + retryable: false, + status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, + }; + } if (error instanceof InputValidationException) { return { code: 'VALIDATION_FAILED', diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts new file mode 100644 index 00000000..9e642e7c --- /dev/null +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; + +import { type AuthenticatedPrincipalV1 } from '../../features/iam/application/authentication.port.js'; +import { createIamTenantContextV1 } from '../../features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from './request-tenant-context.port.js'; +import { getRequestContext } from './request-context.js'; + +export type RequestTenantContextProblemCodeV1 = 'AUTHENTICATION_FAILED' | 'CONTEXT_INVALID'; + +export class RequestTenantContextProblemError extends Error { + constructor(readonly code: RequestTenantContextProblemCodeV1) { + super(code); + this.name = 'RequestTenantContextProblemError'; + } +} + +type HeaderValueV1 = string | readonly string[] | undefined; + +interface RequestLikeV1 { + readonly id?: unknown; + readonly method?: unknown; + readonly headers?: Readonly>; +} + +export interface SessionPrincipalLookupV1 { + findPrincipalByAccessToken( + accessToken: unknown, + ): Promise; +} + +function requestLike(input: unknown): RequestLikeV1 | undefined { + if (typeof input !== 'object' || input === null) return undefined; + const candidate = input as RequestLikeV1; + return candidate.headers !== undefined && typeof candidate.headers === 'object' + ? candidate + : undefined; +} + +function oneHeader(request: RequestLikeV1, name: string): string | undefined { + const headers = request.headers ?? {}; + const values = Object.entries(headers) + .filter(([key]) => key.toLowerCase() === name) + .map(([, value]) => value) + .filter((value): value is string | readonly string[] => value !== undefined); + if (values.length !== 1 || typeof values[0] !== 'string') return undefined; + return values[0]; +} + +function correlationId(request: RequestLikeV1): string { + try { + return getRequestContext(request as never).correlationId; + } catch { + return oneHeader(request, 'x-correlation-id') ?? randomUUID(); + } +} + +function idempotencyKey(request: RequestLikeV1): string { + const header = oneHeader(request, 'idempotency-key'); + if (header !== undefined) return header; + if (typeof request.id === 'string' && request.id.length > 0) return request.id; + return randomUUID(); +} + +function bearerToken(request: RequestLikeV1): string | undefined { + const value = oneHeader(request, 'authorization'); + if (value === undefined) return undefined; + const match = /^Bearer ([A-Za-z0-9._~-]{20,4096})$/u.exec(value); + return match?.[1]; +} + +/** Resolve every protected request from the live IAM session, never from body scope hints. */ +export class SessionRequestTenantContextAdapter implements RequestTenantContextPortV1 { + public constructor(private readonly sessions: SessionPrincipalLookupV1) {} + + public async resolve(request: unknown) { + const input = requestLike(request); + const token = input === undefined ? undefined : bearerToken(input); + if (input === undefined || token === undefined) { + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + } + let principal: AuthenticatedPrincipalV1 | undefined; + try { + principal = await this.sessions.findPrincipalByAccessToken(token); + } catch { + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + } + if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + const context = createIamTenantContextV1({ + tenantScope: { + scopeType: 'workspace', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + }, + actorId: principal.userId, + correlationId: correlationId(input), + idempotencyKey: idempotencyKey(input), + authorizationEpoch: principal.securityEpoch, + }); + if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); + return context.value; + } +} diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 7cf04d8c..37a74dd9 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -22,6 +22,8 @@ import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/pri import { BuaModule } from '../../src/features/bua/bua.module.js'; import { ENTITLEMENT_REPOSITORY_PORT } from '../../src/features/bua/application/entitlement-repository.port.js'; import { PrismaEntitlementRepositoryAdapter } from '../../src/features/bua/adapter/prisma-entitlement-repository.adapter.js'; +import { REQUEST_TENANT_CONTEXT } from '../../src/platform/http/request-tenant-context.port.js'; +import { SessionRequestTenantContextAdapter } from '../../src/platform/http/session-tenant-context.adapter.js'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register(); @@ -91,6 +93,29 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad assert.ok(provider.useValue instanceof PrismaSessionLifecycleAdapter); }); +void test('[IAM-009] a session access-token lookup composes one live tenant-context adapter across feature modules', () => { + const sessions = { + findPrincipalByAccessToken: async () => undefined, + } as never; + const registered = AppModule.register({ sessions }); + const iam = registered.imports?.find( + (candidate) => + typeof candidate === 'object' && candidate !== null && 'module' in candidate && candidate.module === IamModule, + ); + assert.ok(iam && typeof iam === 'object' && 'providers' in iam); + if (!iam || typeof iam !== 'object' || !('providers' in iam)) return; + const provider = iam.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === REQUEST_TENANT_CONTEXT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof SessionRequestTenantContextAdapter); +}); + void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the Prisma adapter', () => { const database = {} as never; const registered = IamModule.register({ identityBootstrapDatabase: database }); diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts new file mode 100644 index 00000000..327a5b53 --- /dev/null +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SessionRequestTenantContextAdapter } from '../../../src/platform/http/session-tenant-context.adapter.js'; + +const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 7, + mfaRequired: false, +}; +const correlationId = '00000000-0000-4000-8000-000000000010'; + +test('derives a workspace tenant context from a bearer session and never accepts client scope fields', async () => { + const seen: string[] = []; + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: async (token) => { + seen.push(String(token)); + return principal; + }, + }); + + const context = await adapter.resolve({ + id: 'request-001', + headers: { + authorization: 'Bearer opaque-access-token-1', + 'idempotency-key': 'mutation-001', + 'x-correlation-id': correlationId, + }, + body: { + organizationId: '00000000-0000-4000-8000-000000000099', + workspaceId: '00000000-0000-4000-8000-000000000099', + }, + }); + + assert.deepEqual(seen, ['opaque-access-token-1']); + assert.deepEqual(context, { + tenantScope: { + scopeType: 'workspace', + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + }, + actorId: principal.userId, + correlationId, + idempotencyKey: 'mutation-001', + authorizationEpoch: principal.securityEpoch, + }); +}); + +test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: async () => undefined, + }); + for (const request of [ + { headers: {} }, + { headers: { authorization: ['Bearer one', 'Bearer two'] } }, + { headers: { authorization: 'Basic credential' } }, + { headers: { authorization: 'Bearer' } }, + ]) { + await assert.rejects(adapter.resolve(request), (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + return true; + }); + } +}); + +test('uses the request id for read-only calls and rejects unsafe principal state', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: async () => ({ ...principal, securityEpoch: 0 }), + }); + await assert.rejects( + adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'AUTHENTICATION_FAILED'); + return true; + }, + ); +}); From 152eae37ef064977aaf6068327698d9f98a0ac4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:01:07 +0700 Subject: [PATCH 23/32] test(iam): verify protected tenant context integration --- services/api/test/http-contract.test.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index c3d00810..405adbae 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -477,3 +477,37 @@ void test('sign-out revokes idempotently and clears browser credentials', async }, ); }); + +void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 3, + mfaRequired: false, + }; + await withApp( + { + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: async (token) => + token === 'access-token-for-context-1' ? principal : undefined, + }, + }, + async (app) => { + const unauthenticated = await app.inject({ method: 'GET', url: '/v1/artifacts/inbox' }); + assertProblem(unauthenticated, 401, 'AUTHENTICATION_FAILED'); + + const authenticated = await app.inject({ + method: 'GET', + url: '/v1/artifacts/inbox', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(authenticated.statusCode, 200); + assert.deepEqual(authenticated.json(), []); + }, + ); +}); From 785240255e7fa622743477b1360bd5f4a9855362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:02:31 +0700 Subject: [PATCH 24/32] feat(iam): compose durable MFA service --- .../features/iam/application/mfa.service.ts | 2 ++ services/api/src/features/iam/iam.module.ts | 29 +++++++++++++++++++ .../foundation-module-composition.test.ts | 16 ++++++++++ 3 files changed, 47 insertions(+) diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index df43a426..b7916b47 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -13,6 +13,8 @@ import { import type { MfaRepositoryPortV1 } from './mfa-repository.port.js'; +export const MFA_SERVICE = Symbol('MFA_SERVICE'); + function invalidState(): MfaResultV1 { return Object.freeze({ accepted: false, code: 'INVALID_STATE' }); } diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index f09f8551..53018dc7 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -17,6 +17,7 @@ import { type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1 } from './application/mfa-repository.port.js'; +import { MFA_SERVICE, MfaService } from './application/mfa.service.js'; import { IAM_REPOSITORY_PORT, type IamRepositoryPortV1 } from './application/iam-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; @@ -67,6 +68,8 @@ export interface IamModuleOptions { readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; + readonly mfaService?: MfaService; + readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean }; readonly iamRepository?: IamRepositoryPortV1; readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; @@ -111,6 +114,23 @@ export class IamModule { (options.mfaDatabase === undefined ? undefined : new PrismaMfaRepositoryAdapter(options.mfaDatabase)); + const mfaService = + options.mfaService ?? + (mfaRepository === undefined + ? undefined + : new MfaService( + mfaRepository, + options.recoveryCodeMatcher ?? { + matches: (presentedDigest, storedDigest) => { + if (presentedDigest.length !== storedDigest.length) return false; + let difference = 0; + for (let index = 0; index < presentedDigest.length; index += 1) { + difference |= presentedDigest.charCodeAt(index) ^ storedDigest.charCodeAt(index); + } + return difference === 0; + }, + }, + )); const iamRepository = options.iamRepository ?? (options.iamDatabase === undefined @@ -137,6 +157,7 @@ export class IamModule { if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); + if (mfaService) exports.unshift(MFA_SERVICE); if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, @@ -178,6 +199,14 @@ export class IamModule { }, ] : []), + ...(mfaService + ? [ + { + provide: MFA_SERVICE, + useValue: mfaService, + }, + ] + : []), ...(iamRepository ? [ { diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 37a74dd9..83fa1c74 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -14,6 +14,7 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/appli import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; import { MFA_REPOSITORY_PORT } from '../../src/features/iam/application/mfa-repository.port.js'; import { PrismaMfaRepositoryAdapter } from '../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; +import { MFA_SERVICE, MfaService } from '../../src/features/iam/application/mfa.service.js'; import { IAM_REPOSITORY_PORT } from '../../src/features/iam/application/iam-repository.port.js'; import { PrismaIamRepositoryAdapter } from '../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; @@ -146,6 +147,21 @@ void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter assert.ok(provider.useValue instanceof PrismaMfaRepositoryAdapter); }); +void test('[IAM-012, IAM-014] configured MFA persistence composes the application service', () => { + const database = {} as never; + const registered = IamModule.register({ mfaDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === MFA_SERVICE, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof MfaService); +}); + void test('[IAM-009, IAM-019] configured IAM membership persistence uses the Prisma adapter', () => { const database = {} as never; const registered = IamModule.register({ iamDatabase: database }); From 02c18226454db44f1b1e0d5c7522c5aa594ba435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:05:31 +0700 Subject: [PATCH 25/32] feat(iam): expose authenticated MFA lifecycle --- services/api/openapi/v1.json | 249 ++++++++++++++++++ .../src/features/iam/api/mfa.controller.ts | 70 +++++ services/api/src/features/iam/api/mfa.dto.ts | 44 ++++ .../iam/application/mfa-problem.error.ts | 8 + services/api/src/features/iam/iam.module.ts | 3 +- .../platform/http/problem-details.filter.ts | 11 + services/api/test/openapi.test.ts | 3 + 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/api/mfa.controller.ts create mode 100644 services/api/src/features/iam/api/mfa.dto.ts create mode 100644 services/api/src/features/iam/application/mfa-problem.error.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 3fc8b9a2..6e909196 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1093,6 +1093,231 @@ "tags": ["devices"] } }, + "/v1/auth/mfa/factors": { + "post": { + "operationId": "MfaController.enroll", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/EnrollMfaFactorDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Enroll a pending MFA factor for the authenticated user", + "tags": ["auth"] + } + }, + "/v1/auth/mfa/factors/{factorId}/verify": { + "post": { + "operationId": "MfaController.verify", + "parameters": [ + { "name": "factorId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/VerifyMfaFactorDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Verify a pending MFA factor", + "tags": ["auth"] + } + }, + "/v1/auth/mfa/recovery/redeem": { + "post": { + "operationId": "MfaController.redeemRecovery", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RedeemMfaRecoveryCodeDto" } + } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Redeem one hashed MFA recovery code", + "tags": ["auth"] + } + }, "/v1/artifacts/inbox": { "post": { "operationId": "InboxController.create", @@ -3423,6 +3648,30 @@ }, "required": ["expectedRevision", "at", "nextPublicKey"] }, + "EnrollMfaFactorDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "method": { "type": "string", "enum": ["TOTP", "WEBAUTHN"] }, + "secretReference": { "type": "string", "maxLength": 512, "writeOnly": true }, + "enrolledAt": { "type": "string", "format": "date-time" }, + "revision": { "type": "number", "minimum": 1 } + }, + "required": ["id", "method", "secretReference", "enrolledAt"] + }, + "VerifyMfaFactorDto": { + "type": "object", + "properties": { "at": { "type": "string", "format": "date-time" } }, + "required": ["at"] + }, + "RedeemMfaRecoveryCodeDto": { + "type": "object", + "properties": { + "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true }, + "at": { "type": "string", "format": "date-time" } + }, + "required": ["presentedDigest", "at"] + }, "CreateInboxItemDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts new file mode 100644 index 00000000..5e015517 --- /dev/null +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, HttpCode, Inject, Optional, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { MFA_SERVICE, MfaService } from '../application/mfa.service.js'; +import { MfaProblemError } from '../application/mfa-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + EnrollMfaFactorDto, + RedeemMfaRecoveryCodeDto, + VerifyMfaFactorDto, +} from './mfa.dto.js'; + +@ApiTags('auth') +@ApiBearerAuth() +@Controller('v1/auth/mfa') +export class MfaController { + public constructor( + @Optional() @Inject(MFA_SERVICE) private readonly mfa: MfaService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Post('factors') + @HttpCode(200) + @ApiOperation({ summary: 'Enroll a pending MFA factor for the authenticated user' }) + @ApiBody({ type: EnrollMfaFactorDto }) + async enroll(@Req() request: unknown, @Body() input: EnrollMfaFactorDto): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.enroll({ + ...input, + userId: context.actorId, + }); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } + + @Post('factors/:factorId/verify') + @HttpCode(200) + @ApiOperation({ summary: 'Verify a pending MFA factor' }) + @ApiBody({ type: VerifyMfaFactorDto }) + async verify( + @Req() request: unknown, + @Param('factorId') factorId: string, + @Body() input: VerifyMfaFactorDto, + ): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.verifyFactor(context.actorId, factorId, input.at); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } + + @Post('recovery/redeem') + @HttpCode(200) + @ApiOperation({ summary: 'Redeem one hashed MFA recovery code' }) + @ApiBody({ type: RedeemMfaRecoveryCodeDto }) + async redeemRecovery( + @Req() request: unknown, + @Body() input: RedeemMfaRecoveryCodeDto, + ): Promise { + if (this.mfa === undefined) throw new MfaProblemError('MFA_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + const result = await this.mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at); + if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); + return result.value; + } +} diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts new file mode 100644 index 00000000..d0b57489 --- /dev/null +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class EnrollMfaFactorDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ enum: ['TOTP', 'WEBAUTHN'] }) + @IsIn(['TOTP', 'WEBAUTHN']) + method!: 'TOTP' | 'WEBAUTHN'; + + @ApiProperty({ maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(512) + secretReference!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + enrolledAt!: string; + + @ApiProperty({ minimum: 1, required: false }) + @IsOptional() + revision?: number; +} + +export class VerifyMfaFactorDto { + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + at!: string; +} + +export class RedeemMfaRecoveryCodeDto { + @ApiProperty({ maxLength: 256, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(256) + presentedDigest!: string; + + @ApiProperty({ format: 'date-time' }) + @IsISO8601() + at!: string; +} diff --git a/services/api/src/features/iam/application/mfa-problem.error.ts b/services/api/src/features/iam/application/mfa-problem.error.ts new file mode 100644 index 00000000..723029a5 --- /dev/null +++ b/services/api/src/features/iam/application/mfa-problem.error.ts @@ -0,0 +1,8 @@ +export type MfaProblemCodeV1 = 'MFA_REQUEST_REJECTED' | 'MFA_UNAVAILABLE'; + +export class MfaProblemError extends Error { + constructor(readonly code: MfaProblemCodeV1) { + super(code); + this.name = 'MfaProblemError'; + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 53018dc7..8a5fabfa 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,6 +1,7 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; +import { MfaController } from './api/mfa.controller.js'; import { AuthenticationService } from './application/authentication.service.js'; import { AUTHENTICATION_USE_CASE, @@ -161,7 +162,7 @@ export class IamModule { if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); return { module: IamModule, - controllers: [AuthenticationController, DeviceIdentityController], + controllers: [AuthenticationController, DeviceIdentityController, MfaController], providers: [ { provide: AUTHENTICATION_USE_CASE, diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 9f4a4b31..1fc4353a 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -9,6 +9,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationProblemError } from '../../features/iam/application/authentication-problem.error.js'; import { SessionProblemError } from '../../features/iam/application/session-problem.error.js'; +import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { RequestTenantContextProblemError } from './session-tenant-context.adapter.js'; import { NotReadyError } from '../../features/system/application/not-ready.error.js'; import { InputValidationException } from './input-validation.exception.js'; @@ -44,6 +45,16 @@ function describe(error: unknown, correlationId: string): ProblemInput { status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED, }; } + if (error instanceof MfaProblemError) { + const unavailable = error.code === 'MFA_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable ? 'api.error.mfa_unavailable' : 'api.error.mfa_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof RequestTenantContextProblemError) { const invalidContext = error.code === 'CONTEXT_INVALID'; return { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6cdd3359..6192d768 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/evidence-grants/{grantId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/auth/mfa/factors', + '/v1/auth/mfa/factors/{factorId}/verify', + '/v1/auth/mfa/recovery/redeem', '/v1/auth/refresh', '/v1/auth/sign-in', '/v1/auth/sign-out', From 676c632290593f74b492663347d430c649e3a423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:07:49 +0700 Subject: [PATCH 26/32] test(iam): verify MFA HTTP lifecycle --- services/api/test/http-contract.test.ts | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 405adbae..0b80ccf5 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -6,6 +6,9 @@ import { parseV1Contract } from '@databreeze/contracts/v1'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import { createApiApplication } from '../src/bootstrap.js'; +import { createIamTenantContextV1 } from '../src/features/iam/application/tenant-context.js'; +import { InMemoryMfaRepositoryAdapter } from '../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; +import { MfaService } from '../src/features/iam/application/mfa.service.js'; interface InjectResponse { readonly body: string; @@ -511,3 +514,54 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }, ); }); + +void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { + const actorId = '00000000-0000-4000-8000-000000000001'; + const mfaService = new MfaService(new InMemoryMfaRepositoryAdapter(), { + matches: (presented, stored) => presented === stored, + }); + const contextResult = createIamTenantContextV1({ + tenantScope: { + scopeType: 'workspace', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + }, + actorId, + correlationId: '00000000-0000-4000-8000-000000000004', + idempotencyKey: 'mfa-http-test', + authorizationEpoch: 1, + }); + assert.equal(contextResult.accepted, true); + if (!contextResult.accepted) return; + const requestTenantContext = { resolve: async () => contextResult.value }; + await withApp({ mfaService, requestTenantContext }, async (app) => { + const enrolled = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors', + payload: { + id: '00000000-0000-4000-8000-000000000010', + method: 'TOTP', + secretReference: 'vault://iam/mfa/test-factor', + enrolledAt: '2026-01-01T00:00:00.000Z', + }, + }); + assert.equal(enrolled.statusCode, 200); + assert.equal(enrolled.json().factors[0].status, 'PENDING'); + assert.equal(enrolled.json().factors[0].secretReference, undefined); + + const verified = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000010/verify', + payload: { at: '2026-01-01T00:01:00.000Z' }, + }); + assert.equal(verified.statusCode, 200); + assert.equal(verified.json().factors[0].status, 'ACTIVE'); + + const invalid = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000099/verify', + payload: { at: '2026-01-01T00:02:00.000Z' }, + }); + assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); + }); +}); From 293ed25b69c3d006ec75f1b4dd6a3d34a97e0956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:12:10 +0700 Subject: [PATCH 27/32] feat(aud): expose authenticated immutable audit reads --- services/api/openapi/v1.json | 136 ++++++++++++++++++ .../src/features/aud/api/audit.controller.ts | 35 +++++ services/api/src/features/aud/aud.module.ts | 12 ++ services/api/test/http-contract.test.ts | 16 +++ services/api/test/openapi.test.ts | 2 + 5 files changed, 201 insertions(+) create mode 100644 services/api/src/features/aud/api/audit.controller.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 6e909196..2d9d482b 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3507,6 +3507,142 @@ "summary": "List immutable versions of one workspace data-mode policy", "tags": ["devices"] } + }, + "/v1/audit/events": { + "get": { + "operationId": "AuditController.events", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List immutable audit events visible to the caller", + "tags": ["audit"] + } + }, + "/v1/audit/seals": { + "get": { + "operationId": "AuditController.seals", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List verified audit seals visible to the caller", + "tags": ["audit"] + } } }, "info": { diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts new file mode 100644 index 00000000..75c6b9bb --- /dev/null +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Inject, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AUDIT_REPOSITORY_PORT, + type AuditRepositoryPortV1, +} from '../application/audit-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('audit') +@ApiBearerAuth() +@Controller('v1/audit') +export class AuditController { + public constructor( + @Inject(AUDIT_REPOSITORY_PORT) private readonly repository: AuditRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('events') + @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) + async events(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listEvents(context); + } + + @Get('seals') + @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) + async seals(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listSeals(context); + } +} diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index cb4ee6d6..906e60f1 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -11,6 +11,12 @@ import { type AuditDatabaseClientV1, } from './adapter/prisma-audit-repository.adapter.js'; import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; +import { AuditController } from './api/audit.controller.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; export const AUDIT_LEDGER_SERVICE = Symbol('AUDIT_LEDGER_SERVICE'); @@ -18,6 +24,7 @@ export interface AudModuleOptions { readonly auditRepository?: AuditRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly auditDatabase?: AuditDatabaseClientV1; + readonly requestTenantContext?: RequestTenantContextPortV1; } @Module({}) @@ -32,9 +39,14 @@ export class AudModule { const service = new AuditLedgerService(repository, digest); return { module: AudModule, + controllers: [AuditController], providers: [ { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, ], exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], }; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 0b80ccf5..82d68fd4 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -511,6 +511,22 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }); assert.equal(authenticated.statusCode, 200); assert.deepEqual(authenticated.json(), []); + + const auditEvents = await app.inject({ + method: 'GET', + url: '/v1/audit/events', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(auditEvents.statusCode, 200); + assert.deepEqual(auditEvents.json(), []); + + const auditSeals = await app.inject({ + method: 'GET', + url: '/v1/audit/seals', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(auditSeals.statusCode, 200); + assert.deepEqual(auditSeals.json(), []); }, ); }); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6192d768..bc618ea1 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -66,6 +66,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/evidence-grants/{grantId}', '/v1/artifacts/inbox', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/audit/events', + '/v1/audit/seals', '/v1/auth/mfa/factors', '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', From dc045fd8100090aa114d55cadae5cfc405eaeb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:17:36 +0700 Subject: [PATCH 28/32] feat(bua): expose scoped entitlement reads --- services/api/openapi/v1.json | 137 ++++++++++++++++++ .../bua/api/entitlement.controller.ts | 54 +++++++ services/api/src/features/bua/bua.module.ts | 12 ++ services/api/test/http-contract.test.ts | 30 ++++ services/api/test/openapi.test.ts | 2 + 5 files changed, 235 insertions(+) create mode 100644 services/api/src/features/bua/api/entitlement.controller.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 2d9d482b..50010fa7 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3643,6 +3643,143 @@ "summary": "List verified audit seals visible to the caller", "tags": ["audit"] } + }, + "/v1/entitlements/snapshots/{snapshotId}": { + "get": { + "operationId": "EntitlementController.snapshot", + "parameters": [ + { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read one immutable entitlement snapshot in the caller scope", + "tags": ["entitlements"] + } + }, + "/v1/entitlements/usage": { + "get": { + "operationId": "EntitlementController.usage", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read the append-only usage ledger state in the caller scope", + "tags": ["entitlements"] + } } }, "info": { diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts new file mode 100644 index 00000000..82b3e5bc --- /dev/null +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { + EntitlementSnapshotV1, + UsageLedgerStateV1, +} from '@databreeze/domain/entitlements/v1'; + +import { + ENTITLEMENT_REPOSITORY_PORT, + type EntitlementRepositoryPortV1, +} from '../application/entitlement-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +type EntitlementNotFoundV1 = { readonly accepted: false; readonly code: 'ENTITLEMENT_NOT_FOUND' }; + +@ApiTags('entitlements') +@ApiBearerAuth() +@Controller('v1/entitlements') +export class EntitlementController { + public constructor( + @Inject(ENTITLEMENT_REPOSITORY_PORT) + private readonly repository: EntitlementRepositoryPortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('snapshots/:snapshotId') + @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + async snapshot( + @Req() request: unknown, + @Param('snapshotId') snapshotIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const parsed = parseStableIdentifierV1(snapshotIdInput); + if (!parsed.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; + return ( + (await this.repository.findSnapshot(context, parsed.value)) ?? { + accepted: false, + code: 'ENTITLEMENT_NOT_FOUND', + } + ); + } + + @Get('usage') + @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + async usage(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.repository.listUsageState(context); + } +} diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 643667cd..5a956f6b 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -10,6 +10,12 @@ import { ENTITLEMENT_REPOSITORY_PORT, type EntitlementRepositoryPortV1, } from './application/entitlement-repository.port.js'; +import { EntitlementController } from './api/entitlement.controller.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; export const ENTITLEMENT_ADMISSION_SERVICE = Symbol('ENTITLEMENT_ADMISSION_SERVICE'); @@ -17,6 +23,7 @@ export interface BuaModuleOptions { readonly entitlementRepository?: EntitlementRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly entitlementDatabase?: EntitlementDatabaseClientV1; + readonly requestTenantContext?: RequestTenantContextPortV1; } @Module({}) @@ -30,9 +37,14 @@ export class BuaModule { const service = new EntitlementAdmissionService(repository); return { module: BuaModule, + controllers: [EntitlementController], providers: [ { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, ], exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], }; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 82d68fd4..e225c763 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -527,6 +527,36 @@ void test('protected artifact reads derive tenant scope from an authenticated ac }); assert.equal(auditSeals.statusCode, 200); assert.deepEqual(auditSeals.json(), []); + + const usage = await app.inject({ + method: 'GET', + url: '/v1/entitlements/usage', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(usage.statusCode, 200); + assert.deepEqual(usage.json(), { entries: [], reservations: [] }); + + const missingSnapshot = await app.inject({ + method: 'GET', + url: '/v1/entitlements/snapshots/80000000-0000-4000-8000-000000000099', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(missingSnapshot.statusCode, 200); + assert.deepEqual(missingSnapshot.json(), { + accepted: false, + code: 'ENTITLEMENT_NOT_FOUND', + }); + + const invalidSnapshot = await app.inject({ + method: 'GET', + url: '/v1/entitlements/snapshots/not-an-id', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(invalidSnapshot.statusCode, 200); + assert.deepEqual(invalidSnapshot.json(), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); }, ); }); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index bc618ea1..ce3ab996 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -97,6 +97,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/grants', '/v1/devices/{deviceId}/key', '/v1/devices/{deviceId}/revoke', + '/v1/entitlements/snapshots/{snapshotId}', + '/v1/entitlements/usage', '/v1/organizations/{organizationId}/devices', '/v1/reference-entities', '/v1/reference-entities/merge', From 39dc6e0ae0acdda159bff8091342daf5e61c284d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:21:28 +0700 Subject: [PATCH 29/32] feat(iam): expose current session identity --- services/api/openapi/v1.json | 80 +++++++++++++++++++ .../iam/api/authentication.controller.ts | 25 +++++- .../features/iam/api/current-session.dto.ts | 22 +++++ services/api/test/http-contract.test.ts | 13 +++ services/api/test/openapi.test.ts | 1 + 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 services/api/src/features/iam/api/current-session.dto.ts diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 50010fa7..907560e2 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -352,6 +352,76 @@ "tags": ["system"] } }, + "/v1/auth/me": { + "get": { + "operationId": "AuthenticationController.me", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CurrentSessionDto" } } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Read the redacted authenticated session identity", + "tags": ["auth"] + } + }, "/v1/auth/sign-in": { "post": { "operationId": "AuthenticationController.signIn", @@ -3810,6 +3880,16 @@ "required": ["clientPlatform", "clientVersion"], "additionalProperties": false }, + "CurrentSessionDto": { + "type": "object", + "properties": { + "userId": { "type": "string", "format": "uuid" }, + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "authorizationEpoch": { "type": "number", "minimum": 1 } + }, + "required": ["userId", "organizationId", "authorizationEpoch"] + }, "SignInDto": { "type": "object", "properties": { diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index d222efd3..0cc189af 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { Body, Controller, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Inject, Optional, Post, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -29,6 +29,11 @@ import { SignInDto } from './sign-in.dto.js'; import { SessionRefreshDto } from './session-refresh.dto.js'; import { SessionRefreshResponseDto } from './session-refresh-response.dto.js'; import { SessionSignOutDto } from './session-sign-out.dto.js'; +import { CurrentSessionDto } from './current-session.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiTags('auth') @@ -40,8 +45,26 @@ export class AuthenticationController { @Optional() @Inject(SESSION_LIFECYCLE_PORT) private readonly sessions?: SessionLifecyclePortV1, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext?: RequestTenantContextPortV1, ) {} + @Get('me') + @ApiOperation({ summary: 'Read the redacted authenticated session identity' }) + @ApiOkResponse({ type: CurrentSessionDto }) + async me(@Req() request: FastifyRequest): Promise { + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); + return { + userId: context.actorId, + organizationId: context.tenantScope.organizationId, + ...(context.tenantScope.scopeType === 'organization' + ? {} + : { workspaceId: context.tenantScope.workspaceId }), + authorizationEpoch: context.authorizationEpoch, + }; + } + @Post('sign-in') @HttpCode(200) @ApiOperation({ summary: 'Sign in and issue a short-lived session' }) diff --git a/services/api/src/features/iam/api/current-session.dto.ts b/services/api/src/features/iam/api/current-session.dto.ts new file mode 100644 index 00000000..ba82a6ad --- /dev/null +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +/** IAM-009: the redacted identity projection used to hydrate an authenticated client. */ +export class CurrentSessionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + userId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + organizationId!: string; + + @ApiProperty({ format: 'uuid', required: false }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @ApiProperty({ minimum: 1 }) + authorizationEpoch!: number; + +} diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index e225c763..37adf389 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -512,6 +512,19 @@ void test('protected artifact reads derive tenant scope from an authenticated ac assert.equal(authenticated.statusCode, 200); assert.deepEqual(authenticated.json(), []); + const currentSession = await app.inject({ + method: 'GET', + url: '/v1/auth/me', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assert.equal(currentSession.statusCode, 200); + assert.deepEqual(currentSession.json(), { + userId: principal.userId, + organizationId: principal.organizationId, + workspaceId: principal.workspaceId, + authorizationEpoch: principal.securityEpoch, + }); + const auditEvents = await app.inject({ method: 'GET', url: '/v1/audit/events', diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index ce3ab996..7dedbec0 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -68,6 +68,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', '/v1/audit/events', '/v1/audit/seals', + '/v1/auth/me', '/v1/auth/mfa/factors', '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', From 559124f9bdcf5f6223cc5e2cf6a0dcbcd7e1ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:25:09 +0700 Subject: [PATCH 30/32] fix(iam): preserve MFA state in session context --- services/api/openapi/v1.json | 5 +++-- .../api/src/features/iam/api/authentication.controller.ts | 1 + services/api/src/features/iam/api/current-session.dto.ts | 5 ++++- services/api/src/features/iam/application/tenant-context.ts | 5 +++++ .../api/src/platform/http/session-tenant-context.adapter.ts | 1 + services/api/test/http-contract.test.ts | 1 + .../api/test/platform/http/session-tenant-context.test.ts | 1 + 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 907560e2..69c9e6eb 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -3886,9 +3886,10 @@ "userId": { "type": "string", "format": "uuid" }, "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, - "authorizationEpoch": { "type": "number", "minimum": 1 } + "authorizationEpoch": { "type": "number", "minimum": 1 }, + "mfaRequired": { "type": "boolean" } }, - "required": ["userId", "organizationId", "authorizationEpoch"] + "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] }, "SignInDto": { "type": "object", diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 0cc189af..598d6040 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -62,6 +62,7 @@ export class AuthenticationController { ? {} : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, + mfaRequired: context.mfaRequired ?? false, }; } diff --git a/services/api/src/features/iam/api/current-session.dto.ts b/services/api/src/features/iam/api/current-session.dto.ts index ba82a6ad..bf333dd3 100644 --- a/services/api/src/features/iam/api/current-session.dto.ts +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsUUID } from 'class-validator'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; /** IAM-009: the redacted identity projection used to hydrate an authenticated client. */ export class CurrentSessionDto { @@ -19,4 +19,7 @@ export class CurrentSessionDto { @ApiProperty({ minimum: 1 }) authorizationEpoch!: number; + @ApiProperty() + @IsBoolean() + mfaRequired!: boolean; } diff --git a/services/api/src/features/iam/application/tenant-context.ts b/services/api/src/features/iam/application/tenant-context.ts index 1fde89a7..fac5078d 100644 --- a/services/api/src/features/iam/application/tenant-context.ts +++ b/services/api/src/features/iam/application/tenant-context.ts @@ -11,6 +11,7 @@ export interface IamTenantContextV1 { readonly correlationId: StableIdentifierV1; readonly idempotencyKey: string; readonly authorizationEpoch: number; + readonly mfaRequired?: boolean; readonly expectedRevision?: number; } @@ -35,6 +36,7 @@ export function createIamTenantContextV1(input: { readonly correlationId: unknown; readonly idempotencyKey: unknown; readonly authorizationEpoch: unknown; + readonly mfaRequired?: unknown; readonly expectedRevision?: unknown; }): IamContextResultV1 { const tenantScope = parseTenantScopeV1(input.tenantScope); @@ -55,6 +57,8 @@ export function createIamTenantContextV1(input: { input.authorizationEpoch < 1 ) return rejected('INVALID_EPOCH'); + if (input.mfaRequired !== undefined && typeof input.mfaRequired !== 'boolean') + return rejected('INVALID_TEXT'); if ( input.expectedRevision !== undefined && (typeof input.expectedRevision !== 'number' || @@ -70,6 +74,7 @@ export function createIamTenantContextV1(input: { correlationId: correlationId.value, idempotencyKey: input.idempotencyKey, authorizationEpoch: input.authorizationEpoch, + ...(input.mfaRequired === undefined ? {} : { mfaRequired: input.mfaRequired }), ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), }), }); diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 9e642e7c..18133fbb 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -95,6 +95,7 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP correlationId: correlationId(input), idempotencyKey: idempotencyKey(input), authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, }); if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); return context.value; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 37adf389..d15d2a2e 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -523,6 +523,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac organizationId: principal.organizationId, workspaceId: principal.workspaceId, authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, }); const auditEvents = await app.inject({ diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 327a5b53..5c1da222 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -45,6 +45,7 @@ test('derives a workspace tenant context from a bearer session and never accepts correlationId, idempotencyKey: 'mutation-001', authorizationEpoch: principal.securityEpoch, + mfaRequired: principal.mfaRequired, }); }); From fa3795f4b6d4fbb90e54838b5bac9934ee00eb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:35:33 +0700 Subject: [PATCH 31/32] fix(ci): satisfy strict TypeScript lint gates --- .../prisma-audit-repository.adapter.ts | 1 - .../adapter/prisma-iam-repository.adapter.ts | 1 - .../src/features/iam/api/mfa.controller.ts | 2 +- .../foundation-module-composition.test.ts | 2 +- .../iam/prisma-credential-lookup.test.ts | 1 + .../iam/prisma-iam-repository.test.ts | 1 + ...isma-identity-bootstrap-repository.test.ts | 1 + .../iam/prisma-mfa-repository.test.ts | 1 + .../iam/prisma-session-lifecycle.test.ts | 1 + .../test/features/iam/session-cookies.test.ts | 6 ++--- services/api/test/http-contract.test.ts | 24 +++++++++++++------ .../platform/http/csrf-protection.test.ts | 8 +++---- .../http/session-tenant-context.test.ts | 14 +++++------ 13 files changed, 38 insertions(+), 25 deletions(-) diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 37b23f33..8edd422b 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -5,7 +5,6 @@ import { type AuditActorTypeV1, type AuditEventV1, type AuditSealV1, - type AuditSummaryV1, type AuditDigestPortV1, } from '@databreeze/domain/audit/v1'; import { diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index d44da532..c1797371 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -2,7 +2,6 @@ import { tenantScopeContainsV1, tenantScopesEqualV1, parseStableIdentifierV1, - parseTenantScopeV1, type StableIdentifierV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index 5e015517..a5240275 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpCode, Inject, Optional, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, HttpCode, Inject, Optional, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { MFA_SERVICE, MfaService } from '../application/mfa.service.js'; diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 83fa1c74..58ae1aa7 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -96,7 +96,7 @@ void test('[IAM-005] configured session persistence uses the Prisma lifecycle ad void test('[IAM-009] a session access-token lookup composes one live tenant-context adapter across feature modules', () => { const sessions = { - findPrincipalByAccessToken: async () => undefined, + findPrincipalByAccessToken: () => Promise.resolve(undefined), } as never; const registered = AppModule.register({ sessions }); const iam = registered.imports?.find( diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index 3b134c15..d54b8c14 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index c8f9fbe0..03349d8b 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts index a1a2cbd3..e1ad5f92 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 29094f6c..8ef6758b 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index 3a3cd095..da1f710a 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles intentionally mirror async client signatures. */ import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 2e6345a3..2563ea30 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -12,7 +12,7 @@ import { const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; -test('serializes bounded session cookies with explicit browser security attributes', () => { +void test('serializes bounded session cookies with explicit browser security attributes', () => { assert.equal( serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, { httpOnly: true, maxAgeSeconds: 2_592_000 }), `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, @@ -23,7 +23,7 @@ test('serializes bounded session cookies with explicit browser security attribut ); }); -test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { +void test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), refreshToken); assert.equal( readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), @@ -37,7 +37,7 @@ test('reads one exact cookie value and fails closed for ambiguity or malformed i assert.equal(readCookieValueV1(undefined, REFRESH_COOKIE_NAME_V1), undefined); }); -test('creates deletion cookies without weakening the original security attributes', () => { +void test('creates deletion cookies without weakening the original security attributes', () => { assert.equal( clearCookieV1(REFRESH_COOKIE_NAME_V1, { httpOnly: true }), `${REFRESH_COOKIE_NAME_V1}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`, diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index d15d2a2e..833e0b5b 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -58,6 +58,11 @@ function assertProblem(response: InjectResponse, status: number, code: string): ); } +function parsedBody(response: { readonly body: string }): TValue { + const parsed: unknown = JSON.parse(response.body); + return parsed as TValue; +} + void test('reports ready only through the injectable readiness port and minimizes failed-check details', async () => { await withApp({ readinessPort: { check: () => Promise.resolve(true) } }, async (app) => { const response = await app.inject({ method: 'GET', url: '/health/ready' }); @@ -401,7 +406,8 @@ void test('refresh rotates Web cookies without returning the refresh token and p payload: { clientPlatform: 'desktop', refreshToken: 'desktop-refresh-token' }, }); assert.equal(native.statusCode, 200); - assert.equal(native.json().refreshToken, refreshed.refreshToken); + const nativeBody = parsedBody<{ readonly refreshToken?: unknown }>(native); + assert.equal(nativeBody['refreshToken'], refreshed.refreshToken); assert.equal(native.headers['set-cookie'], undefined); assert.deepEqual(presented, ['current-refresh-token']); }, @@ -496,8 +502,8 @@ void test('protected artifact reads derive tenant scope from an authenticated ac refresh: () => Promise.reject(new Error('not used')), revoke: () => Promise.resolve(true), findPrincipal: () => Promise.resolve(principal), - findPrincipalByAccessToken: async (token) => - token === 'access-token-for-context-1' ? principal : undefined, + findPrincipalByAccessToken: (token) => + Promise.resolve(token === 'access-token-for-context-1' ? principal : undefined), }, }, async (app) => { @@ -593,7 +599,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assert.equal(contextResult.accepted, true); if (!contextResult.accepted) return; - const requestTenantContext = { resolve: async () => contextResult.value }; + const requestTenantContext = { resolve: () => Promise.resolve(contextResult.value) }; await withApp({ mfaService, requestTenantContext }, async (app) => { const enrolled = await app.inject({ method: 'POST', @@ -606,8 +612,11 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }, }); assert.equal(enrolled.statusCode, 200); - assert.equal(enrolled.json().factors[0].status, 'PENDING'); - assert.equal(enrolled.json().factors[0].secretReference, undefined); + const enrolledBody = parsedBody<{ + readonly factors: readonly [{ readonly status: string; readonly secretReference?: unknown }]; + }>(enrolled); + assert.equal(enrolledBody.factors[0].status, 'PENDING'); + assert.equal(enrolledBody.factors[0].secretReference, undefined); const verified = await app.inject({ method: 'POST', @@ -615,7 +624,8 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con payload: { at: '2026-01-01T00:01:00.000Z' }, }); assert.equal(verified.statusCode, 200); - assert.equal(verified.json().factors[0].status, 'ACTIVE'); + const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>(verified); + assert.equal(verifiedBody.factors[0].status, 'ACTIVE'); const invalid = await app.inject({ method: 'POST', diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index cd0c9735..a8c914f1 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -7,7 +7,7 @@ const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const allowedOrigins = ['https://app.databreeze.example']; -test('allows safe methods and non-cookie clients without a CSRF token', () => { +void test('allows safe methods and non-cookie clients without a CSRF token', () => { assert.deepEqual( evaluateCsrfRequestV1( { method: 'GET', headers: {} }, @@ -24,7 +24,7 @@ test('allows safe methods and non-cookie clients without a CSRF token', () => { ); }); -test('requires a valid double-submit token for cookie-authenticated mutations', () => { +void test('requires a valid double-submit token for cookie-authenticated mutations', () => { const base = { method: 'POST', headers: { @@ -67,7 +67,7 @@ test('requires a valid double-submit token for cookie-authenticated mutations', ); }); -test('rejects hostile, ambiguous, or missing browser origin signals', () => { +void test('rejects hostile, ambiguous, or missing browser origin signals', () => { const headers = { cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, 'x-csrf-token': token, @@ -106,7 +106,7 @@ test('rejects hostile, ambiguous, or missing browser origin signals', () => { ); }); -test('fails closed for duplicate cookies and duplicate token headers', () => { +void test('fails closed for duplicate cookies and duplicate token headers', () => { assert.deepEqual( evaluateCsrfRequestV1( { diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 5c1da222..34b0a886 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -12,12 +12,12 @@ const principal = { }; const correlationId = '00000000-0000-4000-8000-000000000010'; -test('derives a workspace tenant context from a bearer session and never accepts client scope fields', async () => { +void test('derives a workspace tenant context from a bearer session and never accepts client scope fields', async () => { const seen: string[] = []; const adapter = new SessionRequestTenantContextAdapter({ - findPrincipalByAccessToken: async (token) => { + findPrincipalByAccessToken: (token) => { seen.push(String(token)); - return principal; + return Promise.resolve(principal); }, }); @@ -49,9 +49,9 @@ test('derives a workspace tenant context from a bearer session and never accepts }); }); -test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { +void test('rejects missing, ambiguous, malformed, and unknown bearer credentials', async () => { const adapter = new SessionRequestTenantContextAdapter({ - findPrincipalByAccessToken: async () => undefined, + findPrincipalByAccessToken: () => Promise.resolve(undefined), }); for (const request of [ { headers: {} }, @@ -66,9 +66,9 @@ test('rejects missing, ambiguous, malformed, and unknown bearer credentials', as } }); -test('uses the request id for read-only calls and rejects unsafe principal state', async () => { +void test('uses the request id for read-only calls and rejects unsafe principal state', async () => { const adapter = new SessionRequestTenantContextAdapter({ - findPrincipalByAccessToken: async () => ({ ...principal, securityEpoch: 0 }), + findPrincipalByAccessToken: () => Promise.resolve({ ...principal, securityEpoch: 0 }), }); await assert.rejects( adapter.resolve({ id: 'request-read-001', headers: { authorization: 'Bearer token' } }), From 238ce5277166b47fdd9a12c6c983af983f1f06eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 23:44:03 +0700 Subject: [PATCH 32/32] fix(ci): normalize repository formatting --- packages/domain/src/pkce/v1.ts | 17 +++-- .../prisma-audit-repository.adapter.ts | 3 +- .../prisma-entitlement-repository.adapter.ts | 65 +++++++++++++------ .../bua/api/entitlement.controller.ts | 11 ++-- .../in-memory-session-lifecycle.adapter.ts | 4 +- .../prisma-credential-lookup.adapter.ts | 11 ++-- .../adapter/prisma-iam-repository.adapter.ts | 16 +++-- ...a-identity-bootstrap-repository.adapter.ts | 32 ++++++--- .../adapter/prisma-mfa-repository.adapter.ts | 42 +++++++++--- .../prisma-session-lifecycle.adapter.ts | 43 ++++++------ .../iam/api/authentication.controller.ts | 14 +++- .../src/features/iam/api/mfa.controller.ts | 6 +- services/api/src/features/iam/api/mfa.dto.ts | 10 ++- services/api/src/features/iam/iam.module.ts | 24 +++++-- .../api/src/platform/http/csrf-protection.ts | 33 ++++++++-- .../platform/http/problem-details.filter.ts | 4 +- .../http/session-tenant-context.adapter.ts | 7 +- .../bua/prisma-entitlement-repository.test.ts | 33 ++++++++-- .../foundation-module-composition.test.ts | 5 +- .../iam/prisma-credential-lookup.test.ts | 4 +- .../iam/prisma-iam-repository.test.ts | 23 +++++-- ...isma-identity-bootstrap-repository.test.ts | 49 ++++++++++---- .../iam/prisma-mfa-repository.test.ts | 25 +++++-- .../iam/prisma-session-lifecycle.test.ts | 27 +++++--- .../test/features/iam/session-cookies.test.ts | 20 ++++-- services/api/test/http-contract.test.ts | 22 +++++-- .../platform/http/csrf-protection.test.ts | 26 ++++---- 27 files changed, 402 insertions(+), 174 deletions(-) diff --git a/packages/domain/src/pkce/v1.ts b/packages/domain/src/pkce/v1.ts index a274a316..cb6e8385 100644 --- a/packages/domain/src/pkce/v1.ts +++ b/packages/domain/src/pkce/v1.ts @@ -38,14 +38,19 @@ export function createPkceChallengeV1( verifierInput: unknown, hashPort: PkceHashPortV1, ): PkceResultV1 { - if (!validVerifier(verifierInput)) return Object.freeze({ accepted: false, code: 'INVALID_VERIFIER' }); + if (!validVerifier(verifierInput)) + return Object.freeze({ accepted: false, code: 'INVALID_VERIFIER' }); try { const challenge = hashPort.sha256Base64Url(verifierInput); if (!/^[A-Za-z0-9_-]{43}$/u.test(challenge)) return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); return Object.freeze({ accepted: true, - value: Object.freeze({ schemaVersion: PKCE_SCHEMA_VERSION_V1, method: 'S256' as const, challenge }), + value: Object.freeze({ + schemaVersion: PKCE_SCHEMA_VERSION_V1, + method: 'S256' as const, + challenge, + }), }); } catch { return Object.freeze({ accepted: false, code: 'CRYPTO_UNAVAILABLE' }); @@ -57,14 +62,18 @@ export function verifyPkceChallengeV1( challengeInput: unknown, hashPort: PkceHashPortV1, ): boolean { - if (typeof challengeInput !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(challengeInput)) return false; + if (typeof challengeInput !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(challengeInput)) + return false; const created = createPkceChallengeV1(verifierInput, hashPort); return created.accepted && equalStrings(created.value.challenge, challengeInput); } export function isAllowedRedirectUriV1(input: unknown): boolean { if (typeof input !== 'string' || input.length > 200) return false; - if (input === 'com.databreeze.desktop:/oauth2redirect' || input === 'com.databreeze.android:/oauth2redirect') + if ( + input === 'com.databreeze.desktop:/oauth2redirect' || + input === 'com.databreeze.android:/oauth2redirect' + ) return true; const match = /^http:\/\/127\.0\.0\.1:(\d{1,5})\/callback$/u.exec(input); if (!match) return false; diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 8edd422b..202c5b25 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -146,7 +146,8 @@ function positiveInteger(input: unknown): number | undefined { function persistedEvent(row: AuditEventDatabaseRowV1): AuditEventV1 { const eventId = parseStableIdentifierV1(row.id); const organizationId = parseStableIdentifierV1(row.organizationId); - const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); const projectId = row.projectId === null ? undefined : parseStableIdentifierV1(row.projectId); const actorId = parseStableIdentifierV1(row.actorId); const entityId = parseStableIdentifierV1(row.entityId); diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index 2af9dfcd..13d5019f 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -240,10 +240,12 @@ function persistedPlan(row: EntitlementPlanDatabaseRowV1): EntitlementPlanV1 { function persistedSnapshot(row: EntitlementSnapshotDatabaseRowV1): EntitlementSnapshotV1 { const snapshotId = parseStableIdentifierV1(row.id); const organizationId = parseStableIdentifierV1(row.organizationId); - const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); const scope = persistedScope({ ...row, projectId: null }); const effectiveAt = parseStrictUtcTimestampV1(row.effectiveAt.toISOString()); - const expiresAt = row.expiresAt === null ? undefined : parseStrictUtcTimestampV1(row.expiresAt.toISOString()); + const expiresAt = + row.expiresAt === null ? undefined : parseStrictUtcTimestampV1(row.expiresAt.toISOString()); const features = parseFeatures(row.features); const quotas = parseQuotas(row.quotas); if ( @@ -282,8 +284,10 @@ function persistedSnapshot(row: EntitlementSnapshotDatabaseRowV1): EntitlementSn function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1 { const entryId = parseStableIdentifierV1(row.id); const organizationId = parseStableIdentifierV1(row.organizationId); - const workspaceId = row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); - const reservationId = row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId); + const workspaceId = + row.workspaceId === null ? undefined : parseStableIdentifierV1(row.workspaceId); + const reservationId = + row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId); const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); const scope = persistedScope({ ...row, projectId: null }); if ( @@ -353,14 +357,22 @@ function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapsho return { ...databaseScope( snapshot.workspaceId - ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization', organizationId: snapshot.organizationId }, ), id: snapshot.snapshotId, schemaVersion: snapshot.schemaVersion, scopeKey: scopeKey( snapshot.workspaceId - ? { scopeType: 'workspace', organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization', organizationId: snapshot.organizationId }, ), planCode: snapshot.planCode, @@ -392,9 +404,7 @@ function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV }; } -function reservationCreateData( - reservation: UsageReservationV1, -): UsageReservationCreateDataV1 { +function reservationCreateData(reservation: UsageReservationV1): UsageReservationCreateDataV1 { return { ...databaseScope(reservation.tenantScope), id: reservation.reservationId, @@ -437,7 +447,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV await this.client.entitlementPlanRecord.create({ data: planCreateData(plan) }); } - public async findPlan(planCode: EntitlementPlanV1['planCode']): Promise { + public async findPlan( + planCode: EntitlementPlanV1['planCode'], + ): Promise { const row = await this.client.entitlementPlanRecord.findUnique({ where: { planCode } }); return row === null ? undefined : persistedPlan(row); } @@ -447,7 +459,11 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV snapshot: EntitlementSnapshotV1, ): Promise { const scope = snapshot.workspaceId - ? { scopeType: 'workspace' as const, organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + ? { + scopeType: 'workspace' as const, + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; if (!tenantScopeContainsV1(context.tenantScope, scope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); @@ -466,11 +482,17 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, snapshotId: EntitlementSnapshotV1['snapshotId'], ): Promise { - const row = await this.client.entitlementSnapshotRecord.findUnique({ where: { id: snapshotId } }); + const row = await this.client.entitlementSnapshotRecord.findUnique({ + where: { id: snapshotId }, + }); if (row === null) return undefined; const snapshot = persistedSnapshot(row); const scope = snapshot.workspaceId - ? { scopeType: 'workspace' as const, organizationId: snapshot.organizationId, workspaceId: snapshot.workspaceId } + ? { + scopeType: 'workspace' as const, + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } : { scopeType: 'organization' as const, organizationId: snapshot.organizationId }; return visible(context.tenantScope, scope) ? snapshot : undefined; } @@ -489,12 +511,16 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV return Object.freeze({ entries: Object.freeze( entryRows - .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) .map(persistedEntry), ), reservations: Object.freeze( reservationRows - .filter((row) => visible(context.tenantScope, persistedScope({ ...row, projectId: null }))) + .filter((row) => + visible(context.tenantScope, persistedScope({ ...row, projectId: null })), + ) .map(persistedReservation), ), }); @@ -524,7 +550,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV where: { id: reservation.reservationId }, }); if (existing === null) { - await this.client.usageReservationRecord.create({ data: reservationCreateData(reservation) }); + await this.client.usageReservationRecord.create({ + data: reservationCreateData(reservation), + }); continue; } const current = persistedReservation(existing); @@ -578,10 +606,7 @@ export class PrismaEntitlementRepositoryAdapter implements EntitlementRepository return new PrismaEntitlementTransactionAdapter(this.client).listUsageState(context); } - public persistUsageState( - context: IamTenantContextV1, - state: UsageLedgerStateV1, - ): Promise { + public persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise { return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state); } } diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 82b3e5bc..0813c5ea 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,10 +1,7 @@ import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; -import type { - EntitlementSnapshotV1, - UsageLedgerStateV1, -} from '@databreeze/domain/entitlements/v1'; +import type { EntitlementSnapshotV1, UsageLedgerStateV1 } from '@databreeze/domain/entitlements/v1'; import { ENTITLEMENT_REPOSITORY_PORT, @@ -33,7 +30,11 @@ export class EntitlementController { async snapshot( @Req() request: unknown, @Param('snapshotId') snapshotIdInput: string, - ): Promise { + ): Promise< + | EntitlementSnapshotV1 + | EntitlementNotFoundV1 + | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } + > { const context = await this.requestContext.resolve(request); const parsed = parseStableIdentifierV1(snapshotIdInput); if (!parsed.accepted) return { accepted: false, code: 'INVALID_IDENTIFIER' }; diff --git a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts index e25db51b..d70f8378 100644 --- a/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-session-lifecycle.adapter.ts @@ -185,7 +185,9 @@ export class InMemorySessionLifecycleAdapter implements SessionLifecyclePortV1 { return true; } - public async findPrincipal(sessionIdInput: unknown): Promise { + public async findPrincipal( + sessionIdInput: unknown, + ): Promise { await Promise.resolve(); if (typeof sessionIdInput !== 'string') return undefined; const session = this.sessions.get(sessionIdInput); diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index 1492db36..4830cfc5 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -46,9 +46,7 @@ export interface MfaFactorDatabaseRowV1 { } interface UniqueDelegateV1 { - findUnique(input: { - readonly where: Readonly>; - }): Promise; + findUnique(input: { readonly where: Readonly> }): Promise; } interface WorkspaceLookupDelegateV1 extends UniqueDelegateV1 { @@ -87,8 +85,7 @@ function activeMembership( row: MembershipIdentityDatabaseRowV1, userId: string, ): ActiveMembershipV1 | undefined { - if (row.principalId !== userId || row.status !== 'ACTIVE') - return undefined; + if (row.principalId !== userId || row.status !== 'ACTIVE') return undefined; const organizationId = stableId(row.organizationId); if (!organizationId) return undefined; if (row.scopeType === 'WORKSPACE') { @@ -163,7 +160,9 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspaceId = workspace ? stableId(workspace.id) : undefined; } if (!workspaceId) return undefined; - const workspace = await this.client.workspaceIdentity.findUnique({ where: { id: workspaceId } }); + const workspace = await this.client.workspaceIdentity.findUnique({ + where: { id: workspaceId }, + }); if ( !organization || organization.id !== selected.organizationId || diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index c1797371..ccf18823 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -30,7 +30,9 @@ export interface IamMembershipDatabaseRowV1 { } interface IamMembershipDelegateV1 { - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; findMany(input: { readonly where: Readonly>; }): Promise; @@ -99,7 +101,8 @@ function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseR principalId: membership.principalId, scopeType: membership.scope.scopeType.toUpperCase(), organizationId: membership.scope.organizationId, - workspaceId: membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, + workspaceId: + membership.scope.scopeType === 'organization' ? null : membership.scope.workspaceId, projectId: membership.scope.scopeType === 'project' ? membership.scope.projectId : null, roleId: membership.roleId, status: membership.status, @@ -131,7 +134,9 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { ); } - public async listMemberships(context: IamTenantContextV1): Promise { + public async listMemberships( + context: IamTenantContextV1, + ): Promise { const rows = await this.client.membershipIdentity.findMany({ where: {} }); return rows .map(membershipFromRow) @@ -157,7 +162,10 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { const existing = membershipFromRow(existingRow); if (context.expectedRevision !== existing.revision) throw new Error('IAM_REVISION_CONFLICT'); if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); - if (existing.principalId !== membership.principalId || !tenantScopesEqualV1(existing.scope, membership.scope)) + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) + ) throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); await this.client.membershipIdentity.update({ where: { id: membership.id }, diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index eeaf1ded..f30ecced 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -5,7 +5,10 @@ import { type PersonalOrganizationBootstrapV1, type UserIdentityV1, } from '@databreeze/domain/identity/v1'; -import { parseStableIdentifierV1, parseStrictUtcTimestampV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { IdentityBootstrapRepositoryPortV1, @@ -70,13 +73,13 @@ interface IdentityDelegateV1 { } interface ListDelegateV1 { - findMany(input: { - readonly where: Readonly>; - }): Promise; + findMany(input: { readonly where: Readonly> }): Promise; } interface UserDelegateV1 { - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; } interface MembershipDelegateV1 extends IdentityDelegateV1 { @@ -122,7 +125,10 @@ function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { return created.value; } -function membershipMatches(row: MembershipIdentityDatabaseRowV1, expected: MembershipIdentityV1): boolean { +function membershipMatches( + row: MembershipIdentityDatabaseRowV1, + expected: MembershipIdentityV1, +): boolean { return ( row.id === expected.id && row.principalType === expected.principalType && @@ -172,7 +178,9 @@ function bootstrapRowsMatch( class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} - public async findByUserId(userId: PersonalOrganizationBootstrapV1['user']['id']): Promise { + public async findByUserId( + userId: PersonalOrganizationBootstrapV1['user']['id'], + ): Promise { const userRow = await this.client.userIdentity.findUnique({ where: { id: userId } }); if (!userRow) return undefined; const user = userFromRow(userRow); @@ -190,8 +198,11 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran if (!membershipRow) return undefined; const organizationId = stableId(membershipRow.organizationId); if (!organizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); - const organization = await this.client.organizationIdentity.findUnique({ where: { id: organizationId } }); - if (!organization || !organization.personal) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + const organization = await this.client.organizationIdentity.findUnique({ + where: { id: organizationId }, + }); + if (!organization || !organization.personal) + throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); const workspaceRows = await this.client.workspaceIdentity.findMany({ where: { organizationId, status: 'ACTIVE' }, }); @@ -278,7 +289,8 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran ): Promise { const existing = await delegate.findUnique({ where: { id: expected.id } }); if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(expected)) throw new Error('IAM_BOOTSTRAP_CONFLICT'); + if (JSON.stringify(existing) !== JSON.stringify(expected)) + throw new Error('IAM_BOOTSTRAP_CONFLICT'); return; } await delegate.create({ data: expected }); diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts index 3f570176..199897cc 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -39,8 +39,12 @@ export interface MfaRecoveryCodeDatabaseRowV1 { } interface MfaFactorDelegateV1 { - findMany(input: { readonly where: Readonly> }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; create(input: { readonly data: MfaFactorDatabaseRowV1 }): Promise; update(input: { readonly where: { readonly id: string }; @@ -49,9 +53,15 @@ interface MfaFactorDelegateV1 { } interface MfaRecoveryCodeDelegateV1 { - findMany(input: { readonly where: Readonly> }): Promise; - findUnique(input: { readonly where: { readonly id: string } }): Promise; - create(input: { readonly data: MfaRecoveryCodeDatabaseRowV1 }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + create(input: { + readonly data: MfaRecoveryCodeDatabaseRowV1; + }): Promise; update(input: { readonly where: { readonly id: string }; readonly data: Partial; @@ -165,20 +175,32 @@ function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); - if (existing.factors.some((factor) => !next.factors.some((candidate) => candidate.id === factor.id))) + if ( + existing.factors.some((factor) => !next.factors.some((candidate) => candidate.id === factor.id)) + ) return false; - if (existing.recoveryCodes.some((code) => !next.recoveryCodes.some((candidate) => candidate.id === code.id))) + if ( + existing.recoveryCodes.some( + (code) => !next.recoveryCodes.some((candidate) => candidate.id === code.id), + ) + ) return false; for (const factor of next.factors) { const prior = existingFactors.get(factor.id); - if (prior && (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference)) return false; - if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) return false; + if ( + prior && + (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) + ) + return false; + if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + return false; if (!prior && factor.revision !== 1) return false; } for (const code of next.recoveryCodes) { const prior = existingCodes.get(code.id); if (prior && (prior.userId !== code.userId || prior.digest !== code.digest)) return false; - if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) return false; + if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) + return false; if (!prior && code.revision !== 1) return false; } return true; diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 257fda7b..e5de3a6c 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -88,9 +88,7 @@ export interface SessionMfaFactorDatabaseRowV1 { } interface SessionDelegateV1 { - create(input: { - readonly data: SessionRecordDatabaseRowV1; - }): Promise; + create(input: { readonly data: SessionRecordDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly id: string }; }): Promise; @@ -101,9 +99,7 @@ interface SessionDelegateV1 { } interface RefreshTokenDelegateV1 { - create(input: { - readonly data: RefreshTokenDatabaseRowV1; - }): Promise; + create(input: { readonly data: RefreshTokenDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly tokenDigest: string }; }): Promise; @@ -117,9 +113,7 @@ interface RefreshTokenDelegateV1 { } interface AccessTokenDelegateV1 { - create(input: { - readonly data: AccessTokenDatabaseRowV1; - }): Promise; + create(input: { readonly data: AccessTokenDatabaseRowV1 }): Promise; findUnique(input: { readonly where: { readonly tokenDigest: string }; }): Promise; @@ -130,15 +124,11 @@ interface AccessTokenDelegateV1 { } interface UniqueDelegateV1 { - findUnique(input: { - readonly where: Readonly>; - }): Promise; + findUnique(input: { readonly where: Readonly> }): Promise; } interface ListDelegateV1 { - findMany(input: { - readonly where: Readonly>; - }): Promise; + findMany(input: { readonly where: Readonly> }): Promise; } export interface SessionLifecycleDatabaseClientV1 { @@ -218,7 +208,12 @@ function tokenFromRow(row: RefreshTokenDatabaseRowV1): { const expiresAt = timestamp(row.expiresAt); if (!expiresAt || row.tokenDigest.length < 32 || row.tokenDigest.length > 128) throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); - if (row.status !== 'ACTIVE' && row.status !== 'USED' && row.status !== 'REVOKED' && row.status !== 'EXPIRED') + if ( + row.status !== 'ACTIVE' && + row.status !== 'USED' && + row.status !== 'REVOKED' && + row.status !== 'EXPIRED' + ) throw new Error('IAM_PERSISTED_REFRESH_TOKEN_INVALID'); return { id, sessionId, familyId, expiresAt, status: row.status }; } @@ -379,7 +374,10 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const inactivityExpiresAt = addSeconds(now, INACTIVITY_SECONDS_V1, session.absoluteExpiresAt); await transaction.sessionRecord.update({ where: { id: session.sessionId }, - data: { accessExpiresAt: new Date(accessExpiresAt), inactivityExpiresAt: new Date(inactivityExpiresAt) }, + data: { + accessExpiresAt: new Date(accessExpiresAt), + inactivityExpiresAt: new Date(inactivityExpiresAt), + }, }); const nextRefreshToken = tokenFor(rotated.nextTokenId); const nextAccessTokenId = stableIdentifier(randomUUID()); @@ -450,19 +448,24 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { const row = await this.client.accessTokenRecord.findUnique({ where: { tokenDigest: digestToken(accessTokenInput) }, }); - if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) return undefined; + if (!row || row.status !== 'ACTIVE' || row.expiresAt.getTime() <= this.clock().getTime()) + return undefined; return this.findPrincipal(row.sessionId); } catch { return undefined; } } - public async findPrincipal(sessionIdInput: unknown): Promise { + public async findPrincipal( + sessionIdInput: unknown, + ): Promise { if (typeof sessionIdInput !== 'string') return undefined; const parsed = parseStableIdentifierV1(sessionIdInput); if (!parsed.accepted) return undefined; try { - const sessionRow = await this.client.sessionRecord.findUnique({ where: { id: parsed.value } }); + const sessionRow = await this.client.sessionRecord.findUnique({ + where: { id: parsed.value }, + }); if (!sessionRow) return undefined; const session = sessionFromRow(sessionRow); const now = Date.parse(this.clock().toISOString()); diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 598d6040..594f5d44 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -15,7 +15,10 @@ import { type AuthenticationUseCaseV1, } from '../application/authentication.port.js'; import { AuthenticationProblemError } from '../application/authentication-problem.error.js'; -import { SESSION_LIFECYCLE_PORT, type SessionLifecyclePortV1 } from '../application/session-lifecycle.port.js'; +import { + SESSION_LIFECYCLE_PORT, + type SessionLifecyclePortV1, +} from '../application/session-lifecycle.port.js'; import { SessionProblemError } from '../application/session-problem.error.js'; import { CSRF_COOKIE_NAME_V1, @@ -98,7 +101,9 @@ export class AuthenticationController { organizationId: result.value.principal.organizationId, workspaceId: result.value.principal.workspaceId, accessToken: result.value.session.accessToken, - ...(input.clientPlatform === 'web' ? {} : { refreshToken: result.value.session.refreshToken }), + ...(input.clientPlatform === 'web' + ? {} + : { refreshToken: result.value.session.refreshToken }), accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, @@ -122,7 +127,10 @@ export class AuthenticationController { input.clientPlatform === 'web' ? readCookieValueV1(request.headers.cookie, REFRESH_COOKIE_NAME_V1) : input.refreshToken; - if (refreshToken === undefined || (input.clientPlatform === 'web' && input.refreshToken !== undefined)) { + if ( + refreshToken === undefined || + (input.clientPlatform === 'web' && input.refreshToken !== undefined) + ) { throw new SessionProblemError('SESSION_INVALID'); } const result = await this.sessions.refresh(refreshToken, input.clientPlatform); diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index a5240275..df7e017d 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -7,11 +7,7 @@ import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; -import { - EnrollMfaFactorDto, - RedeemMfaRecoveryCodeDto, - VerifyMfaFactorDto, -} from './mfa.dto.js'; +import { EnrollMfaFactorDto, RedeemMfaRecoveryCodeDto, VerifyMfaFactorDto } from './mfa.dto.js'; @ApiTags('auth') @ApiBearerAuth() diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts index d0b57489..db58a085 100644 --- a/services/api/src/features/iam/api/mfa.dto.ts +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + IsIn, + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; export class EnrollMfaFactorDto { @ApiProperty({ format: 'uuid' }) diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 8a5fabfa..6bf6257e 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -17,9 +17,15 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT, type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; -import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1 } from './application/mfa-repository.port.js'; +import { + MFA_REPOSITORY_PORT, + type MfaRepositoryPortV1, +} from './application/mfa-repository.port.js'; import { MFA_SERVICE, MfaService } from './application/mfa.service.js'; -import { IAM_REPOSITORY_PORT, type IamRepositoryPortV1 } from './application/iam-repository.port.js'; +import { + IAM_REPOSITORY_PORT, + type IamRepositoryPortV1, +} from './application/iam-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -34,8 +40,14 @@ import { PrismaIdentityBootstrapRepositoryAdapter, type IdentityBootstrapDatabaseClientV1, } from './adapter/prisma-identity-bootstrap-repository.adapter.js'; -import { PrismaMfaRepositoryAdapter, type MfaDatabaseClientV1 } from './adapter/prisma-mfa-repository.adapter.js'; -import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1 } from './adapter/prisma-iam-repository.adapter.js'; +import { + PrismaMfaRepositoryAdapter, + type MfaDatabaseClientV1, +} from './adapter/prisma-mfa-repository.adapter.js'; +import { + PrismaIamRepositoryAdapter, + type IamDatabaseClientV1, +} from './adapter/prisma-iam-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -70,7 +82,9 @@ export interface IamModuleOptions { readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; - readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean }; + readonly recoveryCodeMatcher?: { + matches(presentedDigest: string, storedDigest: string): boolean; + }; readonly iamRepository?: IamRepositoryPortV1; readonly iamDatabase?: IamDatabaseClientV1; readonly deviceIdentityService?: DeviceIdentityService; diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index d16bba3c..af209493 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -16,27 +16,42 @@ export interface CsrfProtectionOptionsV1 { export type CsrfRequestResultV1 = | { readonly accepted: true } - | { readonly accepted: false; readonly code: 'CSRF_REQUIRED' | 'CSRF_INVALID' | 'ORIGIN_INVALID' }; + | { + readonly accepted: false; + readonly code: 'CSRF_REQUIRED' | 'CSRF_INVALID' | 'ORIGIN_INVALID'; + }; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); -const COOKIE_AUTH_NAMES = new Set(['databreeze_access', 'databreeze_refresh', 'databreeze_session']); +const COOKIE_AUTH_NAMES = new Set([ + 'databreeze_access', + 'databreeze_refresh', + 'databreeze_session', +]); const CSRF_COOKIE_NAME = 'databreeze_csrf'; function oneHeader( headers: CsrfRequestV1['headers'], name: string, -): { readonly present: false } | { readonly present: true; readonly value: string } | { readonly present: true; readonly ambiguous: true } { +): + | { readonly present: false } + | { readonly present: true; readonly value: string } + | { readonly present: true; readonly ambiguous: true } { const matching = Object.entries(headers) .filter(([key]) => key.toLowerCase() === name) .map(([, value]) => value) .filter((value): value is string | readonly string[] => value !== undefined); - if (matching.length !== 1) return matching.length === 0 ? { present: false } : { present: true, ambiguous: true }; + if (matching.length !== 1) + return matching.length === 0 ? { present: false } : { present: true, ambiguous: true }; const value = matching[0]; if (typeof value !== 'string') return { present: true, ambiguous: true }; return { present: true, value }; } -function parseCookies(raw: string): { readonly values: ReadonlyMap; readonly duplicateNames: ReadonlySet; readonly malformed: boolean } { +function parseCookies(raw: string): { + readonly values: ReadonlyMap; + readonly duplicateNames: ReadonlySet; + readonly malformed: boolean; +} { const values = new Map(); const duplicateNames = new Set(); let malformed = false; @@ -67,7 +82,10 @@ function hasCookieAuth(cookies: ReturnType): boolean { return false; } -function originAccepted(headers: CsrfRequestV1['headers'], options: CsrfProtectionOptionsV1): boolean { +function originAccepted( + headers: CsrfRequestV1['headers'], + options: CsrfProtectionOptionsV1, +): boolean { const origin = oneHeader(headers, 'origin'); if (origin.present && 'ambiguous' in origin) return false; if (origin.present) return options.allowedOrigins.includes(origin.value); @@ -91,7 +109,8 @@ export function evaluateCsrfRequestV1( const cookie = oneHeader(request.headers, 'cookie'); if (!cookie.present) return Object.freeze({ accepted: true as const }); - if ('ambiguous' in cookie) return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); + if ('ambiguous' in cookie) + return Object.freeze({ accepted: false as const, code: 'CSRF_INVALID' as const }); const cookies = parseCookies(cookie.value); if (!hasCookieAuth(cookies)) return Object.freeze({ accepted: true as const }); diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 1fc4353a..75a9262b 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -60,9 +60,7 @@ function describe(error: unknown, correlationId: string): ProblemInput { return { code: invalidContext ? 'CONTEXT_INVALID' : 'AUTHENTICATION_FAILED', correlationId, - messageKey: invalidContext - ? 'api.error.context_invalid' - : 'api.error.authentication_failed', + messageKey: invalidContext ? 'api.error.context_invalid' : 'api.error.authentication_failed', retryable: false, status: invalidContext ? HttpStatus.BAD_REQUEST : HttpStatus.UNAUTHORIZED, }; diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 18133fbb..c7d2f4ef 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -23,9 +23,7 @@ interface RequestLikeV1 { } export interface SessionPrincipalLookupV1 { - findPrincipalByAccessToken( - accessToken: unknown, - ): Promise; + findPrincipalByAccessToken(accessToken: unknown): Promise; } function requestLike(input: unknown): RequestLikeV1 | undefined { @@ -84,7 +82,8 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP } catch { throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); } - if (principal === undefined) throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); + if (principal === undefined) + throw new RequestTenantContextProblemError('AUTHENTICATION_FAILED'); const context = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index 122cb3ea..de4527ec 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -79,9 +79,15 @@ function delegate>(rows: TRow[]) { rows.push(persisted); return Promise.resolve(persisted); }, - findUnique({ where }: { readonly where: { readonly id?: string; readonly planCode?: string } }) { + findUnique({ + where, + }: { + readonly where: { readonly id?: string; readonly planCode?: string }; + }) { const key = where.id ?? where.planCode; - return Promise.resolve(rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null); + return Promise.resolve( + rows.find((row) => row['id'] === key || row['planCode'] === key) ?? null, + ); }, findMany({ where, @@ -97,7 +103,8 @@ function delegate>(rows: TRow[]) { return Promise.resolve( [...filtered].sort((left, right) => { if (!field) return 0; - const comparison = left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; + const comparison = + left[field]! < right[field]! ? -1 : left[field]! > right[field]! ? 1 : 0; return direction === 'desc' ? -comparison : comparison; }), ); @@ -167,12 +174,21 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi await repository.savePlan(plan()); await repository.saveSnapshot(context(workspaceId, 'seed-1'), snapshot()); const service = new EntitlementAdmissionService(repository); - const result = await service.admit(context(workspaceId, 'admit-1'), admissionInput('admit-1', '1')); + const result = await service.admit( + context(workspaceId, 'admit-1'), + admissionInput('admit-1', '1'), + ); assert.equal(result.accepted, true); assert.equal((await repository.listUsageState(context(workspaceId, 'read'))).entries.length, 1); - assert.equal((await repository.listUsageState(context(siblingWorkspaceId, 'sibling'))).entries.length, 0); assert.equal( - await repository.findSnapshot(context(siblingWorkspaceId, 'snapshot-sibling'), snapshot().snapshotId), + (await repository.listUsageState(context(siblingWorkspaceId, 'sibling'))).entries.length, + 0, + ); + assert.equal( + await repository.findSnapshot( + context(siblingWorkspaceId, 'snapshot-sibling'), + snapshot().snapshotId, + ), undefined, ); }); @@ -181,7 +197,10 @@ void test('[BUA-012] Prisma entitlement adapter applies reservation status revis const repository = new PrismaEntitlementRepositoryAdapter(client()); await repository.saveSnapshot(context(workspaceId, 'seed-2'), snapshot()); const service = new EntitlementAdmissionService(repository); - const admitted = await service.admit(context(workspaceId, 'admit-2'), admissionInput('admit-2', '2')); + const admitted = await service.admit( + context(workspaceId, 'admit-2'), + admissionInput('admit-2', '2'), + ); assert.equal(admitted.accepted, true); if (!admitted.accepted) return; const finalized = await service.finalize(context(workspaceId, 'finish-2'), { diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 58ae1aa7..d644d1bb 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -101,7 +101,10 @@ void test('[IAM-009] a session access-token lookup composes one live tenant-cont const registered = AppModule.register({ sessions }); const iam = registered.imports?.find( (candidate) => - typeof candidate === 'object' && candidate !== null && 'module' in candidate && candidate.module === IamModule, + typeof candidate === 'object' && + candidate !== null && + 'module' in candidate && + candidate.module === IamModule, ); assert.ok(iam && typeof iam === 'object' && 'providers' in iam); if (!iam || typeof iam !== 'object' || !('providers' in iam)) return; diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index d54b8c14..2636c30a 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -81,7 +81,9 @@ void test('[IAM-001, IAM-002, IAM-009] credential lookup returns only an active, void test('[IAM-001, IAM-009] lookup fails closed when persisted tenancy is inactive or malformed', async () => { const inactive = new PrismaCredentialLookupAdapter( database({ - organizationIdentity: { findUnique: async () => ({ id: organizationId, status: 'SUSPENDED' }) }, + organizationIdentity: { + findUnique: async () => ({ id: organizationId, status: 'SUSPENDED' }), + }, }), ); assert.equal(await inactive.findCredential('user@example.com'), undefined); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 03349d8b..935ed118 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseStableIdentifierV1, type StableIdentifierV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; import { PrismaIamRepositoryAdapter, @@ -37,7 +41,12 @@ function context(scope: TenantScopeV1, expectedRevision?: number) { return result.value; } -function row(idValue: string, scope: 'WORKSPACE' | 'ORGANIZATION', workspace: string | null, roleId: string): IamMembershipDatabaseRowV1 { +function row( + idValue: string, + scope: 'WORKSPACE' | 'ORGANIZATION', + workspace: string | null, + roleId: string, +): IamMembershipDatabaseRowV1 { return { id: idValue, principalType: 'USER', @@ -61,10 +70,13 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { const memberships = new Map(rows.map((value) => [value.id, value])); const client = { membershipIdentity: { - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => memberships.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + memberships.get(where.id) ?? null, findMany: async ({ where }: { readonly where: Readonly> }) => [...memberships.values()].filter((candidate) => - Object.entries(where).every(([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, + ), ), create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { memberships.set(data.id, data); @@ -108,7 +120,8 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; assert.equal((await repository.listMemberships(context(workspaceScope))).length, 2); assert.equal( - (await repository.listMemberships(context({ scopeType: 'organization', organizationId }))).length, + (await repository.listMemberships(context({ scopeType: 'organization', organizationId }))) + .length, 3, ); assert.equal( diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts index e1ad5f92..4a187435 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -42,7 +42,18 @@ function createDatabase(): { readonly memberships: Map; } { const users = new Map([ - [userId, { id: userId, email: 'an@example.com', displayName: 'Nguyen An', locale: 'vi-VN', status: 'ACTIVE', securityEpoch: 1, createdAt }], + [ + userId, + { + id: userId, + email: 'an@example.com', + displayName: 'Nguyen An', + locale: 'vi-VN', + status: 'ACTIVE', + securityEpoch: 1, + createdAt, + }, + ], ]); const organizations = new Map(); const workspaces = new Map(); @@ -50,24 +61,29 @@ function createDatabase(): { const memberships = new Map(); const client = { userIdentity: { - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => users.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + users.get(where.id) ?? null, }, organizationIdentity: { create: async ({ data }: { readonly data: OrganizationIdentityDatabaseRowV1 }) => { organizations.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => organizations.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + organizations.get(where.id) ?? null, }, workspaceIdentity: { create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { workspaces.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => workspaces.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + workspaces.get(where.id) ?? null, findMany: async ({ where }: { readonly where: Readonly> }) => [...workspaces.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof WorkspaceIdentityDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof WorkspaceIdentityDatabaseRowV1] === value, + ), ), }, projectIdentity: { @@ -75,10 +91,13 @@ function createDatabase(): { projects.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => projects.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + projects.get(where.id) ?? null, findMany: async ({ where }: { readonly where: Readonly> }) => [...projects.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof ProjectIdentityDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof ProjectIdentityDatabaseRowV1] === value, + ), ), }, membershipIdentity: { @@ -86,13 +105,18 @@ function createDatabase(): { memberships.set(data.id, data); return data; }, - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => memberships.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + memberships.get(where.id) ?? null, findMany: async ({ where }: { readonly where: Readonly> }) => [...memberships.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof MembershipIdentityDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof MembershipIdentityDatabaseRowV1] === value, + ), ), }, - $transaction: async (work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise) => { + $transaction: async ( + work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, + ) => { const before = { organizations: new Map(organizations), workspaces: new Map(workspaces), @@ -142,7 +166,10 @@ void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy i await adapter.save(validated.value); await assert.doesNotReject(() => adapter.save(validated.value)); await assert.rejects( - adapter.save({ ...validated.value, organization: { ...validated.value.organization, name: 'Changed' } }), + adapter.save({ + ...validated.value, + organization: { ...validated.value.organization, name: 'Changed' }, + }), /IAM_BOOTSTRAP_CONFLICT/, ); }); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 8ef6758b..05c5a0c6 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createMfaFactorV1, createRecoveryCodeV1, transitionMfaFactorV1 } from '@databreeze/domain/mfa/v1'; +import { + createMfaFactorV1, + createRecoveryCodeV1, + transitionMfaFactorV1, +} from '@databreeze/domain/mfa/v1'; import type { MfaStateV1 } from '@databreeze/domain/mfa/v1'; import { @@ -28,9 +32,12 @@ function createDatabase(): { mfaFactor: { findMany: async ({ where }: { readonly where: Readonly> }) => [...factors.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof MfaFactorDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof MfaFactorDatabaseRowV1] === value, + ), ), - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => factors.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + factors.get(where.id) ?? null, create: async ({ data }: { readonly data: MfaFactorDatabaseRowV1 }) => { factors.set(data.id, data); return data; @@ -52,9 +59,12 @@ function createDatabase(): { mfaRecoveryCode: { findMany: async ({ where }: { readonly where: Readonly> }) => [...recoveryCodes.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof MfaRecoveryCodeDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof MfaRecoveryCodeDatabaseRowV1] === value, + ), ), - findUnique: async ({ where }: { readonly where: { readonly id: string } }) => recoveryCodes.get(where.id) ?? null, + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + recoveryCodes.get(where.id) ?? null, create: async ({ data }: { readonly data: MfaRecoveryCodeDatabaseRowV1 }) => { recoveryCodes.set(data.id, data); return data; @@ -150,7 +160,10 @@ void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactio const input = state(); const factor = input.factors[0]; if (!factor) throw new Error('fixture missing factor'); - await assert.rejects(adapter.saveState('00000000-0000-4000-8000-000000000099', input), /MFA_SCOPE_MISMATCH/); + await assert.rejects( + adapter.saveState('00000000-0000-4000-8000-000000000099', input), + /MFA_SCOPE_MISMATCH/, + ); await assert.rejects( adapter.withTransaction(async (transaction) => { await transaction.saveState(factor.userId, input); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index da1f710a..f4eee384 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -59,13 +59,11 @@ function createDatabase(): { }, findUnique: async ({ where }: { readonly where: { readonly tokenDigest: string } }) => [...refreshTokens.values()].find((row) => row.tokenDigest === where.tokenDigest) ?? null, - findMany: async ({ - where, - }: { - readonly where: Readonly>; - }) => + findMany: async ({ where }: { readonly where: Readonly> }) => [...refreshTokens.values()].filter((row) => - Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value), + Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ), ), updateMany: async ({ where, @@ -76,7 +74,11 @@ function createDatabase(): { }) => { let count = 0; for (const [id, row] of refreshTokens) { - if (!Object.entries(where).every(([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value)) + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof RefreshTokenDatabaseRowV1] === value, + ) + ) continue; refreshTokens.set(id, { ...row, ...data }); count += 1; @@ -100,7 +102,11 @@ function createDatabase(): { }) => { let count = 0; for (const [id, row] of accessTokens) { - if (!Object.entries(where).every(([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value)) + if ( + !Object.entries(where).every( + ([key, value]) => row[key as keyof AccessTokenDatabaseRowV1] === value, + ) + ) continue; accessTokens.set(id, { ...row, ...data }); count += 1; @@ -133,8 +139,9 @@ function createDatabase(): { mfaFactor: { findMany: async () => [{ id: '00000000-0000-4000-8000-000000000005' }], }, - $transaction: async (work: (transaction: SessionLifecycleDatabaseClientV1) => Promise) => - work(client), + $transaction: async ( + work: (transaction: SessionLifecycleDatabaseClientV1) => Promise, + ) => work(client), } as unknown as SessionLifecycleDatabaseClientV1; return { client, sessions, refreshTokens, accessTokens }; } diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 2563ea30..8b686e8f 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -14,7 +14,10 @@ const refreshToken = `00000000-0000-4000-8000-000000000001.${token}`; void test('serializes bounded session cookies with explicit browser security attributes', () => { assert.equal( - serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, { httpOnly: true, maxAgeSeconds: 2_592_000 }), + serializeCookieV1(REFRESH_COOKIE_NAME_V1, refreshToken, { + httpOnly: true, + maxAgeSeconds: 2_592_000, + }), `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax`, ); assert.equal( @@ -24,13 +27,22 @@ void test('serializes bounded session cookies with explicit browser security att }); void test('reads one exact cookie value and fails closed for ambiguity or malformed input', () => { - assert.equal(readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), refreshToken); assert.equal( - readCookieValueV1(`other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), + readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, REFRESH_COOKIE_NAME_V1), refreshToken, ); assert.equal( - readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${REFRESH_COOKIE_NAME_V1}=other`, REFRESH_COOKIE_NAME_V1), + readCookieValueV1( + `other=value; ${REFRESH_COOKIE_NAME_V1}=${refreshToken}`, + REFRESH_COOKIE_NAME_V1, + ), + refreshToken, + ); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${REFRESH_COOKIE_NAME_V1}=other`, + REFRESH_COOKIE_NAME_V1, + ), undefined, ); assert.equal(readCookieValueV1('broken-cookie', REFRESH_COOKIE_NAME_V1), undefined); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 833e0b5b..c2e39d03 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -315,7 +315,10 @@ void test('sign-in returns a session DTO and maps authentication failures withou const setCookies = response.headers['set-cookie']; assert.ok(Array.isArray(setCookies)); assert.equal(setCookies.length, 2); - assert.match(setCookies[0] ?? '', /^databreeze_refresh=refresh-token; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match( + setCookies[0] ?? '', + /^databreeze_refresh=refresh-token; .*HttpOnly; Secure; SameSite=Lax$/, + ); assert.match(setCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); assertResponseIdentifiers(response); }, @@ -397,7 +400,10 @@ void test('refresh rotates Web cookies without returning the refresh token and p const webCookies = web.headers['set-cookie']; assert.ok(Array.isArray(webCookies)); assert.equal(webCookies.length, 2); - assert.match(webCookies[0] ?? '', /^databreeze_refresh=next-refresh-token; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match( + webCookies[0] ?? '', + /^databreeze_refresh=next-refresh-token; .*HttpOnly; Secure; SameSite=Lax$/, + ); assert.match(webCookies[1] ?? '', /^databreeze_csrf=[A-Za-z0-9_-]+; .*Secure; SameSite=Lax$/); const native = await app.inject({ @@ -417,7 +423,8 @@ void test('refresh rotates Web cookies without returning the refresh token and p { sessions: { issue: () => Promise.reject(new Error('not used')), - refresh: () => Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), + refresh: () => + Promise.resolve({ accepted: false as const, code: 'REUSE_DETECTED' as const }), revoke: () => Promise.resolve(true), findPrincipal: () => Promise.resolve(undefined), }, @@ -466,7 +473,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async assert.equal(web.body, ''); const webCookies = web.headers['set-cookie']; assert.ok(Array.isArray(webCookies)); - assert.match(webCookies[0] ?? '', /^databreeze_refresh=; Max-Age=0; .*HttpOnly; Secure; SameSite=Lax$/); + assert.match( + webCookies[0] ?? '', + /^databreeze_refresh=; Max-Age=0; .*HttpOnly; Secure; SameSite=Lax$/, + ); assert.match(webCookies[1] ?? '', /^databreeze_csrf=; Max-Age=0; .*Secure; SameSite=Lax$/); const native = await app.inject({ @@ -624,7 +634,9 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con payload: { at: '2026-01-01T00:01:00.000Z' }, }); assert.equal(verified.statusCode, 200); - const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>(verified); + const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( + verified, + ); assert.equal(verifiedBody.factors[0].status, 'ACTIVE'); const invalid = await app.inject({ diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index a8c914f1..ebf3cd85 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -8,13 +8,9 @@ const token = 'QmFzZTY0dXJsVG9rZW5fMDEyMzQ1Njc4OWFiY2RlZg'; const allowedOrigins = ['https://app.databreeze.example']; void test('allows safe methods and non-cookie clients without a CSRF token', () => { - assert.deepEqual( - evaluateCsrfRequestV1( - { method: 'GET', headers: {} }, - { allowedOrigins }, - ), - { accepted: true }, - ); + assert.deepEqual(evaluateCsrfRequestV1({ method: 'GET', headers: {} }, { allowedOrigins }), { + accepted: true, + }); assert.deepEqual( evaluateCsrfRequestV1( { method: 'POST', headers: { authorization: 'Bearer access-token' } }, @@ -82,18 +78,18 @@ void test('rejects hostile, ambiguous, or missing browser origin signals', () => ); assert.deepEqual( evaluateCsrfRequestV1( - { method: 'POST', headers: { ...headers, origin: ['https://app.databreeze.example', 'https://evil.example'] } }, - { allowedOrigins }, - ), - { accepted: false, code: 'ORIGIN_INVALID' }, - ); - assert.deepEqual( - evaluateCsrfRequestV1( - { method: 'POST', headers }, + { + method: 'POST', + headers: { ...headers, origin: ['https://app.databreeze.example', 'https://evil.example'] }, + }, { allowedOrigins }, ), { accepted: false, code: 'ORIGIN_INVALID' }, ); + assert.deepEqual(evaluateCsrfRequestV1({ method: 'POST', headers }, { allowedOrigins }), { + accepted: false, + code: 'ORIGIN_INVALID', + }); assert.deepEqual( evaluateCsrfRequestV1( {