diff --git a/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt b/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt index b941ef80..09b739bc 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt @@ -1,5 +1,8 @@ package com.databreeze.android.telemetry +import java.time.Instant +import java.time.format.DateTimeParseException + /** Cross-runtime names and safe record helpers shared with @databreeze/telemetry/v1. */ object TelemetryContract { const val SchemaVersion = 1 @@ -143,9 +146,14 @@ object TelemetryContract { correlation.spanId, correlation.traceFlags, ) + val normalizedTimestamp = try { + Instant.parse(timestamp).toString() + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("invalid telemetry timestamp") + } return TelemetryRecord( SchemaVersion, - timestamp, + normalizedTimestamp, level, event, component, diff --git a/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt index d948ca2e..074399ea 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt @@ -99,4 +99,27 @@ class TelemetryContractTest { assertTrue(!headerError.message.orEmpty().contains("provider header cause")) assertTrue(headerError.cause == null) } + + @Test + fun recordRequiresAndNormalizesAnAbsoluteTimestamp() { + val normalized = TelemetryContract.createRecord( + "info", + "sync.completed", + "android", + CorrelationContext(correlationId), + timestamp = "2026-01-01T07:00:00+07:00", + ) + assertEquals("2026-01-01T00:00:00Z", normalized.timestamp) + + val error = assertThrows(IllegalArgumentException::class.java) { + TelemetryContract.createRecord( + "info", + "sync.completed", + "android", + CorrelationContext(correlationId), + timestamp = "tomorrow in a provider timezone", + ) + } + assertEquals("invalid telemetry timestamp", error.message) + } } diff --git a/docs/operations/coderabbit-pr-35-disposition.md b/docs/operations/coderabbit-pr-35-disposition.md new file mode 100644 index 00000000..d7c0fb22 --- /dev/null +++ b/docs/operations/coderabbit-pr-35-disposition.md @@ -0,0 +1,65 @@ +# CodeRabbit disposition for promotion PR 35 + +Promotion PR [#35](https://github.com/DatabreezeService/databreeze-platform/pull/35) +received exactly one automatic CodeRabbit review +(`9880ed80-ef8e-4545-8823-97499ec88913`) for its promotion range. CodeRabbit +was not invoked again. The review's ten actionable inline comments, two +outside-diff claims, and review-body suggestions were reproduced against the +current `dev`-bound fix branch before disposition. + +## Actionable inline comments + +| ID | Claim | Disposition | Evidence | +|---|---|---|---| +| CR35-01 | The platform-program branch/PR budget still described the old 30–70 policy. | Accepted and fixed. | `ed1e130` aligns the normal 30–50, exceptional-under-79, and 280 hard-stop language. | +| CR35-02 | The Luna handoff runbook repeated stale 30–70/99/280 limits. | Accepted and fixed. | `ed1e130` aligns the runbook with the same branch and promotion limits. | +| CR35-03 | Audit event pagination must preserve scope key, chain sequence, and deterministic event identity. | Accepted and fixed. | `f381341`, `66b3548`, and `68632db`; both adapters use the canonical ordering and forged-cursor regressions pass. | +| CR35-04 | An invalid audit chain must not be reported as a retryable outage. | Accepted and fixed. | `2406de7` and `00e688c`; integrity failures map to a non-retryable 500 problem without the raw chain marker. | +| CR35-05 | Membership writes could treat a cross-organization identifier collision as a generic uniqueness error. | Accepted and fixed. | `28268b1` and `cf159c7`; identity lookup is organization-independent before the scope guard and P2002 races map to `IAM_REVISION_CONFLICT`. | +| CR35-06 | Bootstrap could silently discard malformed non-null membership timestamps. | Accepted and fixed. | `f93d44e` and `f8f7747`; invalid `startsAt`/`expiresAt` values are rejected and covered. | +| CR35-07 | MFA revision conflicts need an explicit HTTP conflict response. | Accepted and fixed. | `1c4ef10` and `3a7bcae`; `IAM_MFA_REVISION_CONFLICT` maps to HTTP 409. | +| CR35-08 | Request-context resolution was inside the sign-out catch and could hide authentication failures. | Accepted and fixed. | `3197edd` and `b513053`; the HTTP contract preserves `AUTHENTICATION_FAILED`. | +| CR35-09 | Device problem-message keys were absent from the bilingual catalogs. | Accepted and fixed. | `dd5ad9a` and `6e318ff`; all five device keys now exist in Vietnamese and English and are required by the catalog test. | +| CR35-10 | The migration test should exercise existing-session migration behavior. | Rejected as an unsafe backfill request; the safe migration contract was strengthened. | The migration explicitly has no legacy-data migration because tenant scope cannot be inferred safely. `366d787` resolves the migration by stable name and asserts that no unsafe backfill is present. Existing sessions must be recreated under the locked no-legacy-migration assumption. | + +## Outside-diff claims + +| ID | Claim | Disposition | Evidence | +|---|---|---|---| +| CR35-OD-01 | `GET /v1/entitlements/usage` should publish a success response schema. | Accepted and fixed. | `1add34b`; the generated OpenAPI now declares the usage page shape. | +| CR35-OD-02 | `GET /v1/entitlements/snapshots/{snapshotId}` should publish a success response schema. | Accepted and fixed. | `1add34b`; the generated OpenAPI now declares the snapshot success shape. | + +## Review-body suggestions + +The following suggestions were also considered. Accepted suggestions are +implemented in the cited commits; suggestions that conflict with the locked +architecture are explicitly rejected rather than implemented speculatively. + +| Suggestion | Disposition | +|---|---| +| Do not cite reconciliation evidence for planned traceability rows. | Accepted: `ca2df3d` separates planned entries from reconciled evidence. | +| Resolve migrations by stable name rather than an inventory index. | Accepted: `366d787`. | +| Centralize the BUA reservation transition invariant. | Accepted: `bbe0723` and `67790d2`; both adapters call one application policy and a direct policy test protects it. | +| Add forged audit-cursor coverage. | Accepted: `68632db`. | +| Add an audit-seal descendant scheduler and alerting loop. | Rejected for this slice: the current AUD contract has no active-scope scheduler port or alert ownership; inventing one would bypass the ordered audit/sealing plan. Deferred to the AUD sealing task. | +| Add a page response schema to the audit controller. | Accepted: `89002da`; generated OpenAPI includes the bounded page envelope. | +| Share the audit page-offset validator and maximum. | Accepted: `e640373`. | +| Document the audit ordering contract. | Accepted: `66b3548` and `89002da`; the port and API docs state the chain ordering. | +| Remove unused transaction-level audit list methods. | Accepted: `66b3548`; the transaction port no longer exposes unrelated enumeration methods. | +| Share the canonical tenant scope-key helper. | Accepted: `f381341` and `3412961`. | +| Flatten inherited BUA usage reads and avoid one query per scope. | Accepted: `fa42a5f`; one `scopeKey IN (...)` query is issued per record family. | +| Add query-count/performance regression coverage for inherited usage. | Accepted: `e9f0f90`. | +| Share the IAM session-revocation lifecycle. | Accepted: `e33ad17` and `9888494`; repeated revocation preserves the original timestamp. | +| Add safe malformed-membership diagnostics. | Accepted: `663fdbc`, `aab57cb`, and `f7fa8d3`; malformed rows are skipped, diagnostics are best-effort, and the fixture remains visible to the scoped query. | +| Push IAM membership visibility into the database query and preserve scoped reads. | Accepted: `56df707` and `0e13e95`; the adapter emits organization/workspace/project predicates and tests inspect them. | +| Share membership-authority selection across adapters. | Accepted: `f54976d` and `d8a2f13`. | +| Test two personal organizations and reject ambiguity. | Accepted: `f93d44e` and `f8f7747`. | +| Batch bootstrap organization lookup. | Accepted: `f93d44e` and `f8f7747`; candidates are selected with one bounded `findMany`. | +| Export cookie parser limits for fixtures and test exact boundaries. | Accepted: `25f9017` and `3a94fb5`. | +| Backfill existing sessions in the scope-binding migration. | Rejected: inferring organization/workspace from a legacy session is unsafe and contradicts the repository's explicit no-legacy-data assumption. `366d787` tests that the migration preserves this guard. | +| Change audit ordering to created-at/ID ordering. | Rejected: chain pagination must follow the persisted per-scope sequence and deterministic event ID so cursor pages reconstruct the verified chain. | + +The accepted changes are collected on +`fix/coderabbit-pr-35-reconciliation` and will enter `dev` through its focused +fix PR. Promotion PR 35 remains a single-review, immutable review packet; no +second CodeRabbit run will be requested for it. diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index df6dc4b7..ce052afc 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -50,9 +50,9 @@ docs/plans/requirement-traceability.json records all 611 IDs with requested trac - `main` contains stable releases. `dev` is the integration branch. - New capabilities use `feat/`; corrections use `fix/`; operational and documentation work use conventional prefixes when more accurate. - Commit one coherent tested unit at a time. Do not combine unrelated applications or domains merely to reduce commit count. -- Feature/fix pull requests target `dev`, normally contain 30–70 commits, and must remain under 100 commits. Run local and hosted checks, but do not invoke CodeRabbit on these integration pull requests. Focused review-gate fixes are the only intentional small-PR exception. +- Feature/fix pull requests target `dev`, normally contain 30–50 commits; an exceptional completed-task boundary may reach 79 but must remain below 100 commits. Run local and hosted checks, but do not invoke CodeRabbit on these integration pull requests. Focused review-gate fixes are the only intentional small-PR exception. - Merge a green integration pull request while preserving its atomic rollback commits, then immediately open a separate `dev` → `main` promotion pull request for that completed batch. -- Invoke CodeRabbit exactly once on the otherwise-ready promotion pull request and request a full review. Do not invoke it if the promotion exceeds 280 changed files; split or revert the batch first. +- Invoke CodeRabbit exactly once on the otherwise-ready promotion pull request and request a full review. Keep the packet at or below 260 changed files; 280 is the hard review-stop threshold and must never be crossed before splitting or reverting the batch. - Reproduce every CodeRabbit claim against the reviewed commit. Fix valid findings in focused commits, document rejected findings with evidence, rerun all affected gates, and never request a second CodeRabbit review on that pull request. - Do not merge a promotion when the single review was skipped, timed out, has unresolved valid findings, or required hosted checks are not green. diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index ab799071..4f7f9aba 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -91,15 +91,15 @@ 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. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. Split before the promotion diff reaches 280 changed files; the packet target is 260. +12. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. Keep the promotion packet at or below 260 changed files; 280 is the hard review-stop threshold, so split before crossing it. 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 -1. Branch from current `origin/dev` using `feat/` or `fix/`. Keep atomic commits; preferred PR size is 30–70 commits and hard maximum is 99. Cut earlier only for a coherent boundary, a mandatory promotion-gate fix, or a safety constraint. +1. Branch from current `origin/dev` using `feat/` or `fix/`. Keep atomic commits; preferred PR size is 30–50 commits and an exceptional completed-task boundary may reach 79, always remaining below CodeRabbit's 100-commit limit. Cut earlier only for a coherent boundary, a mandatory promotion-gate fix, or a safety constraint. 2. Before a feature PR, ensure it targets `dev`, has no unrelated commits, and passes local gates. Open it with requirement/task/evidence/rollback notes. CodeRabbit must not be invoked on this PR. 3. Wait for hosted checks. Diagnose failures; do not merge red or missing required checks. Merge with history that preserves the atomic rollback units, normally `--no-ff`/merge commit rather than squash. -4. Immediately compare `dev` and `main`. If `dev` contains the reviewed batch and no incompatible promotion is open, create `dev` → `main`. If the diff exceeds 280 changed files, do not invoke CodeRabbit: split/revert the feature batch or request user direction first. +4. Immediately compare `dev` and `main`. If `dev` contains the reviewed batch and no incompatible promotion is open, create `dev` → `main`. Keep the promotion packet at or below 260 changed files; if the diff would cross the hard 280-file review stop, split/revert the feature batch or request user direction before invoking CodeRabbit. 5. When the promotion PR is otherwise ready, invoke CodeRabbit exactly once with a full review request. Record the invocation URL/time. Do not invoke it on the feature PR and do not ask twice on the promotion PR. 6. Wait for the review. For each comment, reproduce the claimed behavior against the exact PR commit, classify it as valid/invalid/uncertain, and save the evidence. Use `superpowers:receiving-code-review` or the repository's CodeRabbit review skill when available. 7. Fix valid findings in focused `fix/*` commits or a focused promotion branch that is safely merged back through `dev`; ensure the promotion diff remains exactly `dev` → `main`. Document rejected comments with concise technical evidence. Do not request a second CodeRabbit pass. @@ -133,7 +133,7 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: | Windows/Android signing key missing or compromised | Release signing/verification gate fails | Halt release, rotate/revoke via runbook, rebuild from provenance, and never ship an unsigned stable artifact | | CodeRabbit comment conflicts with specs/tests | Reproduction disproves claim | Document rejection with paths/tests; do not change code merely to satisfy the comment | | CodeRabbit uncovers a systemic issue late in promotion | Reproduction shows issue spans prior commits | Block promotion, create focused fix tasks, preserve the one review record, and obtain user direction if a fresh PR/review is necessary | -| Commit or changed-file budget would be exceeded | Preflight count reaches threshold | Cut a coherent PR before the hard limit; if over 280 files before promotion review, split/revert the batch before invoking CodeRabbit | +| Commit or changed-file budget would be exceeded | Preflight count reaches threshold | Cut a coherent PR before the hard limit; keep the packet at or below 260 files and split/revert before crossing the 280-file review stop | | User sends stop/override instruction | New message replaces or pauses active scope | Reach a safe boundary, preserve work, record exact state, and stop; do not continue autonomously | ## End-of-session handoff record @@ -175,7 +175,7 @@ Live verified checkpoint: branch [BRANCH], HEAD [HEAD], origin/dev [DEV], origin 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. +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–50 commits, exceptional ceiling 79, and keep the promotion packet at or below 260 changed files without crossing the 280-file review stop. Keep PostgreSQL authoritative, Redis ephemeral, tenant scope explicit, originals/versions immutable, Hybrid default, Local content/path out of cloud, workers without database credentials, Desktop/sidecar allowlisted, Android background payloads content-free, Vietnamese and English complete, and external providers replaceable. Stop and record state for destructive migration risk, unknown overlapping changes, privacy/security fail-open behavior, missing production/signing authority, or a spec conflict. End with the exact handoff record from the runbook. ``` diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index 0934124d..bb04f2d5 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -1041,7 +1041,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1116,7 +1118,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1141,7 +1145,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1166,7 +1172,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1216,7 +1224,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1241,7 +1251,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1266,7 +1278,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1291,7 +1305,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1466,7 +1482,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1541,7 +1559,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1566,7 +1586,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1591,7 +1613,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1641,7 +1665,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1666,7 +1692,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1716,7 +1744,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1741,7 +1771,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1766,7 +1798,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1791,7 +1825,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1816,7 +1852,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9244,7 +9282,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9337,7 +9377,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9399,7 +9441,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9461,7 +9505,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9492,7 +9538,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index 8210c396..ba3a4ce6 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -2,6 +2,7 @@ import { parseStableIdentifierV1, parseStrictUtcTimestampV1, parseTenantScopeV1, + tenantScopeKeyV1, type StableIdentifierV1, type StrictUtcTimestampV1, type TenantScopeV1, @@ -157,13 +158,6 @@ function positiveInteger(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; } -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 canonicalSummary(summary: AuditSummaryV1): string { return JSON.stringify( Object.fromEntries( @@ -244,7 +238,8 @@ export function appendAuditEventV1( const existing = state.events.find( (event) => - event.idempotencyKey === idempotencyKey && scopeKey(event.tenantScope) === scopeKey(scope), + event.idempotencyKey === idempotencyKey && + tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope), ); if (existing) { return existing.eventId === eventId @@ -253,7 +248,7 @@ export function appendAuditEventV1( } const scopedEvents = state.events.filter( - (event) => scopeKey(event.tenantScope) === scopeKey(scope), + (event) => tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope), ); const previous = scopedEvents.at(-1); const sequence = (previous?.sequence ?? 0) + 1; @@ -291,7 +286,7 @@ export function verifyAuditChainV1( ): AuditResultV1 { const byScope = new Map(); for (const event of events) { - const key = scopeKey(event.tenantScope); + const key = tenantScopeKeyV1(event.tenantScope); const list = byScope.get(key) ?? []; list.push(event); byScope.set(key, list); @@ -332,7 +327,7 @@ export function createAuditSealV1( if (!scope) return rejected('INVALID_SCOPE'); if (!sealedAt) return rejected('INVALID_TIMESTAMP'); const scopedEvents = events - .filter((event) => scopeKey(event.tenantScope) === scopeKey(scope)) + .filter((event) => tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope)) .sort((left, right) => left.sequence - right.sequence); if (scopedEvents.length === 0) return rejected('INVALID_SEQUENCE'); const chain = verifyAuditChainV1(scopedEvents, digestPort); diff --git a/packages/domain/src/tenant-scope/v1.ts b/packages/domain/src/tenant-scope/v1.ts index b8353cea..15dc8c25 100644 --- a/packages/domain/src/tenant-scope/v1.ts +++ b/packages/domain/src/tenant-scope/v1.ts @@ -164,6 +164,14 @@ export function tenantScopeContainsV1(container: TenantScopeV1, candidate: Tenan return candidate.scopeType === 'project' && container.projectId === candidate.projectId; } +/** Stable storage and cursor key for a fully qualified tenant scope. */ +export function tenantScopeKeyV1(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}`; +} + export function narrowTenantScopeV1( current: TenantScopeV1, candidate: TenantScopeV1, diff --git a/packages/domain/test/tenant-scope-v1.test.mjs b/packages/domain/test/tenant-scope-v1.test.mjs index 9604c06b..8c8c5654 100644 --- a/packages/domain/test/tenant-scope-v1.test.mjs +++ b/packages/domain/test/tenant-scope-v1.test.mjs @@ -156,6 +156,20 @@ test('[IAM-019] equality and containment require complete matching ancestry', as assert.equal(api.tenantScopeContainsV1(projA, wsA), false); }); +test('[IAM-019] tenant scope keys preserve complete ancestry', async () => { + const api = await loadTenantScope(); + assert.ok(api); + const orgA = expectAccepted(api.parseTenantScopeV1(organizationA)); + const wsA = expectAccepted(api.parseTenantScopeV1(workspaceA)); + const projA = expectAccepted(api.parseTenantScopeV1(projectA)); + assert.equal(api.tenantScopeKeyV1(orgA), `organization:${ids.organizationA}`); + assert.equal(api.tenantScopeKeyV1(wsA), `workspace:${ids.organizationA}:${ids.workspaceA}`); + assert.equal( + api.tenantScopeKeyV1(projA), + `project:${ids.organizationA}:${ids.workspaceA}:${ids.projectA}`, + ); +}); + test('[IAM-019] narrowing permits descendants but never parents or siblings', async () => { const api = await loadTenantScope(); assert.ok(api); diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 89e268bf..550bb866 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -126,6 +126,11 @@ const vietnameseCatalogV1 = { ), 'error.sourceOffline': entry('Thiết bị chứa dữ liệu gốc hiện đang ngoại tuyến.'), 'error.sessionExpired': entry('Phiên làm việc đã hết hạn. Hãy đăng nhập lại để tiếp tục.'), + 'api.error.device_unavailable': entry('Thiết bị hiện không khả dụng.'), + 'api.error.device_not_found': entry('Không tìm thấy thiết bị.'), + 'api.error.device_request_rejected': entry('Thiết bị đã từ chối yêu cầu.'), + 'api.error.device_revision_conflict': entry('Thiết bị đã thay đổi. Hãy tải lại và thử lại.'), + 'api.error.device_scope_denied': entry('Bạn không có quyền truy cập thiết bị này.'), 'retry.now': entry('Thử lại ngay'), 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), @@ -266,6 +271,11 @@ const englishCatalogV1: MessageCatalogV1 = { ), 'error.sourceOffline': entry('The device containing the original data is offline.'), 'error.sessionExpired': entry('Your session expired. Sign in again to continue.'), + 'api.error.device_unavailable': entry('The device is temporarily unavailable.'), + 'api.error.device_not_found': entry('The device was not found.'), + 'api.error.device_request_rejected': entry('The device rejected the request.'), + 'api.error.device_revision_conflict': entry('The device changed. Reload and try again.'), + 'api.error.device_scope_denied': entry('You do not have access to this device.'), 'retry.now': entry('Try again now'), 'retry.later': entry('Try again later. Your entered data has been preserved.'), 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index 444f3872..10a601c9 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -97,6 +97,11 @@ const REQUIRED_KEYS = Object.freeze([ 'error.networkUnavailable', 'error.sourceOffline', 'error.sessionExpired', + 'api.error.device_unavailable', + 'api.error.device_not_found', + 'api.error.device_request_rejected', + 'api.error.device_revision_conflict', + 'api.error.device_scope_denied', 'retry.now', 'retry.later', 'retry.afterSeconds.one', diff --git a/packages/telemetry/src/v1.ts b/packages/telemetry/src/v1.ts index e74ea2e2..a6e70786 100644 --- a/packages/telemetry/src/v1.ts +++ b/packages/telemetry/src/v1.ts @@ -361,9 +361,15 @@ export function createStructuredLoggerV1(options: StructuredLoggerOptionsV1) { if (!levelSet.has(level)) throw new Error('Invalid telemetry level'); if (!eventPattern.test(event)) throw new Error('Invalid telemetry event'); const normalized = createCorrelationContextV1(correlation); + let timestamp: string; + try { + timestamp = clock().toISOString(); + } catch { + timestamp = new Date().toISOString(); + } const record: TelemetryRecordV1 = { schemaVersion: TELEMETRY_SCHEMA_VERSION_V1, - timestamp: clock().toISOString(), + timestamp, level, event, component: options.component, @@ -375,7 +381,11 @@ export function createStructuredLoggerV1(options: StructuredLoggerOptionsV1) { record.spanId = normalized.spanId; if (normalized.traceFlags !== undefined) record.traceFlags = normalized.traceFlags; } - sink(record); + try { + sink(record); + } catch { + // Exporters are best-effort adapters and cannot become product authority. + } return record; }, }; diff --git a/packages/telemetry/test/telemetry-v1.test.mjs b/packages/telemetry/test/telemetry-v1.test.mjs index d4a53d02..cc6e832d 100644 --- a/packages/telemetry/test/telemetry-v1.test.mjs +++ b/packages/telemetry/test/telemetry-v1.test.mjs @@ -233,3 +233,38 @@ test('structured logger carries normalized trace context into the record', () => assert.equal(record.spanId, '0123456789abcdef'); assert.equal(record.traceFlags, '00'); }); + +test('structured logger isolates exporter outages from product workflows', () => { + const logger = createStructuredLoggerV1({ + component: 'api', + clock: () => new Date('2026-01-01T00:00:00.000Z'), + sink() { + throw new Error('provider cause with customer source value'); + }, + }); + + const record = logger.emit( + 'warn', + 'telemetry.export_failed', + createCorrelationContextV1({ correlationId }), + { outcome: 'degraded', payload: 'must not be serialized' }, + ); + + assert.equal(record.event, 'telemetry.export_failed'); + assert.deepEqual(record.attributes, { outcome: 'degraded' }); + assert.doesNotMatch(JSON.stringify(record), /provider cause|customer source|must not/u); +}); + +test('structured logger uses a safe fallback when a clock adapter fails', () => { + const logger = createStructuredLoggerV1({ + component: 'engine', + clock() { + throw new Error('provider clock cause'); + }, + sink: () => undefined, + }); + + const record = logger.emit('info', 'processor.started', { correlationId }, {}); + assert.match(record.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u); + assert.doesNotMatch(JSON.stringify(record), /provider clock cause/u); +}); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index f790caa8..51f66718 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6772,6 +6772,22 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6865,6 +6881,22 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6945,6 +6977,22 @@ } ], "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, + "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 snapshot identifier is invalid.", "headers": { @@ -7021,6 +7069,38 @@ } ], "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["entries", "reservations"], + "properties": { + "entries": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "reservations": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } + }, + "additionalProperties": false + } + } + }, + "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": { diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 94533370..3449764e 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -1,4 +1,5 @@ import { + tenantScopeKeyV1, tenantScopeContainsV1, type AuditEventV1, type AuditSealV1, @@ -11,10 +12,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; -import { - createAuditPageCursorV1, - parseAuditPageCursorV1, -} from '../application/audit-page-cursor.js'; +import { createAuditPageCursorV1, auditPageOffsetV1 } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -39,19 +37,6 @@ function cloneSeal(seal: AuditSealV1): AuditSealV1 { return Object.freeze({ ...seal, tenantScope: Object.freeze({ ...seal.tenantScope }) }); } -function pageOffset( - input: AuditPageInputV1, - kind: 'events' | 'seals', - scope: TenantScopeV1, -): number { - if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) - throw new Error('AUD_PAGE_LIMIT_INVALID'); - if (input.cursor === undefined) return 0; - const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); - if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); - return parsed.offset; -} - /** In-memory adapter with PostgreSQL-equivalent append-only and scope checks. */ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { private events = new Map(); @@ -100,14 +85,17 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { input: AuditPageInputV1, ): Promise> { await Promise.resolve(); - const offset = pageOffset(input, 'events', context.tenantScope); + const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const visible = [...this.events.values()] .filter((event) => visibleInScope(context.tenantScope, event.tenantScope)) - .sort((left, right) => - left.occurredAt === right.occurredAt - ? left.eventId.localeCompare(right.eventId) - : left.occurredAt.localeCompare(right.occurredAt), - ); + .sort((left, right) => { + const scopeOrder = tenantScopeKeyV1(left.tenantScope).localeCompare( + tenantScopeKeyV1(right.tenantScope), + ); + return ( + scopeOrder || left.sequence - right.sequence || left.eventId.localeCompare(right.eventId) + ); + }); const items = visible.slice(offset, offset + input.limit).map(cloneEvent); return Object.freeze({ items: Object.freeze(items), @@ -167,14 +155,19 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { input: AuditPageInputV1, ): Promise> { await Promise.resolve(); - const offset = pageOffset(input, 'seals', context.tenantScope); + const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const visible = [...this.seals.values()] .filter((seal) => visibleInScope(context.tenantScope, seal.tenantScope)) - .sort((left, right) => - left.sealedAt === right.sealedAt - ? left.rootDigest.localeCompare(right.rootDigest) - : left.sealedAt.localeCompare(right.sealedAt), - ); + .sort((left, right) => { + const scopeOrder = tenantScopeKeyV1(left.tenantScope).localeCompare( + tenantScopeKeyV1(right.tenantScope), + ); + return ( + scopeOrder || + left.lastSequence - right.lastSequence || + left.rootDigest.localeCompare(right.rootDigest) + ); + }); const items = visible.slice(offset, offset + input.limit).map(cloneSeal); return Object.freeze({ items: Object.freeze(items), @@ -205,10 +198,8 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { try { return await work({ appendEvent: this.appendEvent.bind(this), - listEvents: this.listEvents.bind(this), listEventsForScope: this.listEventsForScope.bind(this), saveSeal: this.saveSeal.bind(this), - listSeals: this.listSeals.bind(this), }); } catch (error) { this.events = beforeEvents; 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 2a2834ee..c0517b7f 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 @@ -13,6 +13,7 @@ import { parseStrictUtcTimestampV1, parseTenantScopeV1, tenantScopeContainsV1, + tenantScopeKeyV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; import { randomUUID } from 'node:crypto'; @@ -24,10 +25,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; -import { - createAuditPageCursorV1, - parseAuditPageCursorV1, -} from '../application/audit-page-cursor.js'; +import { createAuditPageCursorV1, auditPageOffsetV1 } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; export interface AuditEventDatabaseRowV1 { @@ -126,13 +124,6 @@ function databaseScope(scope: TenantScopeV1) { } 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; @@ -248,7 +239,7 @@ function eventCreateData(event: AuditEventV1): AuditEventCreateDataV1 { id: event.eventId, schemaVersion: event.schemaVersion, action: event.action, - scopeKey: scopeKey(event.tenantScope), + scopeKey: tenantScopeKeyV1(event.tenantScope), actorType: event.actor.actorType, actorId: event.actor.actorId, entityType: event.entityType, @@ -270,7 +261,7 @@ function sealCreateData(seal: AuditSealV1): AuditSealCreateDataV1 { ...databaseScope(seal.tenantScope), id: randomUUID(), schemaVersion: seal.schemaVersion, - scopeKey: scopeKey(seal.tenantScope), + scopeKey: tenantScopeKeyV1(seal.tenantScope), firstSequence: seal.firstSequence, lastSequence: seal.lastSequence, eventCount: seal.eventCount, @@ -302,19 +293,6 @@ function visibilityWhere(scope: TenantScopeV1): Readonly }; } -function pageOffset( - input: AuditPageInputV1, - kind: 'events' | 'seals', - scope: TenantScopeV1, -): number { - if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) - throw new Error('AUD_PAGE_LIMIT_INVALID'); - if (input.cursor === undefined) return 0; - const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); - if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); - return parsed.offset; -} - class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { public constructor( private readonly client: AuditDatabaseClientV1, @@ -338,7 +316,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!sameAuditEventV1(current, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return current; } - const eventScopeKey = scopeKey(event.tenantScope); + const eventScopeKey = tenantScopeKeyV1(event.tenantScope); const [duplicate, latest] = await Promise.all([ this.client.auditEventRecord.findFirst({ where: { scopeKey: eventScopeKey, idempotencyKey: event.idempotencyKey }, @@ -379,7 +357,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!tenantScopeContainsV1(context.tenantScope, scope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const rows = await this.client.auditEventRecord.findMany({ - where: { scopeKey: scopeKey(scope) }, + where: { scopeKey: tenantScopeKeyV1(scope) }, orderBy: { sequence: 'asc' }, }); const events = rows.map(persistedEvent); @@ -393,7 +371,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const existing = await this.client.auditSealRecord.findFirst({ where: { - scopeKey: scopeKey(seal.tenantScope), + scopeKey: tenantScopeKeyV1(seal.tenantScope), firstSequence: seal.firstSequence, lastSequence: seal.lastSequence, }, @@ -442,10 +420,10 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise> { - const offset = pageOffset(input, 'events', context.tenantScope); + const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const rows = await this.client.auditEventRecord.findMany({ where: visibilityWhere(context.tenantScope), - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ scopeKey: 'asc' }, { sequence: 'asc' }, { id: 'asc' }], skip: offset, take: input.limit + 1, }); @@ -476,10 +454,10 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise> { - const offset = pageOffset(input, 'seals', context.tenantScope); + const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const rows = await this.client.auditSealRecord.findMany({ where: visibilityWhere(context.tenantScope), - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ scopeKey: 'asc' }, { lastSequence: 'asc' }, { id: 'asc' }], skip: offset, take: input.limit + 1, }); diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 96cc053d..fcc62655 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -17,17 +17,30 @@ import { type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; import { AuditProblemError } from '../application/audit-problem.error.js'; -import { parseAuditPageCursorV1 } from '../application/audit-page-cursor.js'; +import { + AUDIT_PAGE_LIMIT_MAX_V1, + parseAuditPageCursorV1, +} from '../application/audit-page-cursor.js'; import { InputValidationException } from '../../../platform/http/input-validation.exception.js'; function pageLimit(input: string | undefined): number { const value = input === undefined ? 50 : Number(input); - if (!Number.isSafeInteger(value) || value < 1 || value > 100) { + if (!Number.isSafeInteger(value) || value < 1 || value > AUDIT_PAGE_LIMIT_MAX_V1) { throw new InputValidationException([{ field: 'limit', code: 'INVALID_PAGE_LIMIT' }]); } return value; } +const AUDIT_PAGE_RESPONSE_SCHEMA = { + type: 'object', + required: ['items'] as string[], + properties: { + items: { type: 'array', items: { type: 'object', additionalProperties: true } }, + nextCursor: { type: 'string', maxLength: 512 }, + }, + additionalProperties: false, +}; + @ApiTags('audit') @ApiBearerAuth() @Controller('v1/audit') @@ -39,7 +52,7 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) - @ApiOkResponse() + @ApiOkResponse({ schema: AUDIT_PAGE_RESPONSE_SCHEMA }) @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 }) @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 }) @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) @@ -60,14 +73,17 @@ export class AuditController { limit, ...(cursor === undefined ? {} : { cursor }), }); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'AUD_CHAIN_INVALID') { + throw new AuditProblemError('AUDIT_INTEGRITY_INVALID'); + } throw new AuditProblemError('AUDIT_UNAVAILABLE'); } } @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) - @ApiOkResponse() + @ApiOkResponse({ schema: AUDIT_PAGE_RESPONSE_SCHEMA }) @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 }) @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 }) @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) @@ -88,7 +104,10 @@ export class AuditController { limit, ...(cursor === undefined ? {} : { cursor }), }); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'AUD_CHAIN_INVALID') { + throw new AuditProblemError('AUDIT_INTEGRITY_INVALID'); + } throw new AuditProblemError('AUDIT_UNAVAILABLE'); } } diff --git a/services/api/src/features/aud/application/audit-page-cursor.ts b/services/api/src/features/aud/application/audit-page-cursor.ts index 271a6127..4977150f 100644 --- a/services/api/src/features/aud/application/audit-page-cursor.ts +++ b/services/api/src/features/aud/application/audit-page-cursor.ts @@ -1,6 +1,8 @@ -import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeKeyV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { AuditPageInputV1 } from './audit-repository.port.js'; export type AuditPageKindV1 = 'events' | 'seals'; +export const AUDIT_PAGE_LIMIT_MAX_V1 = 100 as const; export type AuditPageCursorResultV1 = | { readonly accepted: true; readonly offset: number } @@ -8,13 +10,6 @@ export type AuditPageCursorResultV1 = const MAX_CURSOR_LENGTH_V1 = 512; -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 rejected(): AuditPageCursorResultV1 { return Object.freeze({ accepted: false, code: 'INVALID_CURSOR' }); } @@ -26,7 +21,7 @@ export function createAuditPageCursorV1( ): string { if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('AUD_CURSOR_OFFSET_INVALID'); return Buffer.from( - JSON.stringify({ version: 1, kind, scope: scopeKey(scope), offset }), + JSON.stringify({ version: 1, kind, scope: tenantScopeKeyV1(scope), offset }), 'utf8', ).toString('base64url'); } @@ -52,7 +47,7 @@ export function parseAuditPageCursorV1( Object.keys(record).sort().join(',') !== 'kind,offset,scope,version' || record['version'] !== 1 || record['kind'] !== kind || - record['scope'] !== scopeKey(scope) || + record['scope'] !== tenantScopeKeyV1(scope) || !Number.isSafeInteger(record['offset']) || (record['offset'] as number) < 0 ) @@ -62,3 +57,20 @@ export function parseAuditPageCursorV1( return rejected(); } } + +export function auditPageOffsetV1( + input: AuditPageInputV1, + kind: AuditPageKindV1, + scope: TenantScopeV1, +): number { + if ( + !Number.isSafeInteger(input.limit) || + input.limit < 1 || + input.limit > AUDIT_PAGE_LIMIT_MAX_V1 + ) + throw new Error('AUD_PAGE_LIMIT_INVALID'); + if (input.cursor === undefined) return 0; + const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); + if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); + return parsed.offset; +} diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts index 3dc235a8..61b181f7 100644 --- a/services/api/src/features/aud/application/audit-problem.error.ts +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -1,5 +1,5 @@ export class AuditProblemError extends Error { - public constructor(readonly code: 'AUDIT_UNAVAILABLE') { + public constructor(readonly code: 'AUDIT_UNAVAILABLE' | 'AUDIT_INTEGRITY_INVALID') { super(code); this.name = 'AuditProblemError'; } diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index ac4650f3..2bb92343 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -15,15 +15,19 @@ export interface AuditPageV1 { readonly nextCursor?: string; } +/** + * Paginated events are ordered by tenant scope key, chain sequence, and event id; + * seals use tenant scope key, last sequence, and seal id. The tie-breakers keep + * in-memory and PostgreSQL adapters cursor-compatible. + */ + export interface AuditTransactionPortV1 { appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise; - listEvents(context: IamTenantContextV1): Promise; listEventsForScope( context: IamTenantContextV1, scope: TenantScopeV1, ): Promise; saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise; - listSeals(context: IamTenantContextV1): Promise; } export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index d5364b93..01d7046f 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -19,6 +19,7 @@ import { sameUsageEntryV1, sameUsageReservationExceptStatusV1, sameUsageReservationV1, + validUsageReservationTransitionV1, } from '../application/entitlement-equality.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { @@ -73,17 +74,6 @@ function cloneState(state: UsageLedgerStateV1): UsageLedgerStateV1 { }); } -function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return sameUsageReservationExceptStatusV1(left, right); -} - -function validReservationTransition( - current: UsageReservationV1, - next: UsageReservationV1, -): boolean { - return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); -} - /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ export class InMemoryEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { private plans = new Map(); @@ -140,11 +130,17 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito async persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise { await Promise.resolve(); + if ( + new Set(state.entries.map((entry) => entry.entryId)).size !== state.entries.length || + new Set(state.reservations.map((reservation) => reservation.reservationId)).size !== + state.reservations.length + ) + throw new Error('BUA_USAGE_STATE_CONFLICT'); for (const entry of state.entries) { const existing = this.entries.get(entry.entryId); if (existing) { if (!sameUsageEntryV1(existing, entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); - continue; + if (visibleInScope(context.tenantScope, entry.tenantScope)) continue; } if (!scopeAllowsMutation(context, entry.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); @@ -162,17 +158,27 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito } for (const reservation of state.reservations) { const existing = this.reservations.get(reservation.reservationId); + if (existing) { + if (sameUsageReservationV1(existing, reservation)) { + if (visibleInScope(context.tenantScope, reservation.tenantScope)) continue; + } else if ( + !sameUsageReservationExceptStatusV1(existing, reservation) || + existing.revision + 1 !== reservation.revision || + !validUsageReservationTransitionV1(existing, reservation) + ) { + throw new Error('BUA_RESERVATION_CONFLICT'); + } + } + if (!scopeAllowsMutation(context, reservation.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); if (!existing) { - if (!scopeAllowsMutation(context, reservation.tenantScope)) - throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); continue; } - if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || - !sameReservationExceptStatus(existing, reservation) || - !validReservationTransition(existing, reservation) + !sameUsageReservationExceptStatusV1(existing, reservation) || + !validUsageReservationTransitionV1(existing, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); 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 e46a1882..73974301 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 @@ -14,6 +14,7 @@ import { parseStrictUtcTimestampV1, parseTenantScopeV1, tenantScopeContainsV1, + tenantScopeKeyV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -28,6 +29,7 @@ import { sameUsageEntryV1, sameUsageReservationExceptStatusV1, sameUsageReservationV1, + validUsageReservationTransitionV1, } from '../application/entitlement-equality.js'; const planCodes = new Set(['free', 'development', 'admin_granted']); @@ -446,24 +448,17 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { if (scope.scopeType === 'organization') return undefined; const inherited = [ - `organization:${scope.organizationId}`, - `workspace:${scope.organizationId}:${scope.workspaceId}`, + tenantScopeKeyV1({ scopeType: 'organization', organizationId: scope.organizationId }), + tenantScopeKeyV1({ + scopeType: 'workspace', + organizationId: scope.organizationId, + workspaceId: scope.workspaceId, + }), ]; - if (scope.scopeType === 'project') inherited.push(scopeKey(scope)); + if (scope.scopeType === 'project') inherited.push(tenantScopeKeyV1(scope)); return Object.freeze(inherited); } -function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return sameUsageReservationExceptStatusV1(left, right); -} - -function validReservationTransition( - current: UsageReservationV1, - next: UsageReservationV1, -): boolean { - return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); -} - class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { public constructor(private readonly client: EntitlementDatabaseClientV1) {} @@ -543,30 +538,20 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV public async listUsageState(context: IamTenantContextV1): Promise { const scopeKeys = inheritedUsageScopeKeys(context.tenantScope); - const entryQueries = (scopeKeys ?? [undefined]).map((key) => + const where = + scopeKeys === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: { in: scopeKeys } }; + const [entryRows, reservationRows] = await Promise.all([ this.client.usageLedgerEntryRecord.findMany({ - where: - key === undefined - ? { organizationId: context.tenantScope.organizationId } - : { scopeKey: key }, + where, orderBy: { sequence: 'asc' }, }), - ); - const reservationQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageReservationRecord.findMany({ - where: - key === undefined - ? { organizationId: context.tenantScope.organizationId } - : { scopeKey: key }, + where, orderBy: { createdAt: 'asc' }, }), - ); - const [entryGroups, reservationGroups] = await Promise.all([ - Promise.all(entryQueries), - Promise.all(reservationQueries), ]); - const entryRows = entryGroups.flat(); - const reservationRows = reservationGroups.flat(); return Object.freeze({ entries: Object.freeze( entryRows @@ -585,6 +570,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, state: UsageLedgerStateV1, ): Promise { + if ( + new Set(state.entries.map((entry) => entry.entryId)).size !== state.entries.length || + new Set(state.reservations.map((reservation) => reservation.reservationId)).size !== + state.reservations.length + ) + throw new Error('BUA_USAGE_STATE_CONFLICT'); for (const entry of state.entries) { if (!tenantScopeContainsV1(context.tenantScope, entry.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); @@ -621,9 +612,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV const current = persistedReservation(existing); if (sameUsageReservationV1(current, reservation)) continue; if ( - !sameReservationExceptStatus(current, reservation) || + !sameUsageReservationExceptStatusV1(current, reservation) || reservation.revision !== current.revision + 1 || - !validReservationTransition(current, reservation) + !validUsageReservationTransitionV1(current, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 0b242308..82ee4876 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -3,6 +3,7 @@ import { ApiBadRequestResponse, ApiBearerAuth, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, ApiTags, @@ -20,6 +21,21 @@ import { } from '../../../platform/http/request-tenant-context.port.js'; import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; +const ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: true, +}; + +const USAGE_LEDGER_RESPONSE_SCHEMA = { + type: 'object', + required: ['entries', 'reservations'] as string[], + properties: { + entries: { type: 'array', items: { type: 'object', additionalProperties: true } }, + reservations: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, + additionalProperties: false, +}; + @ApiTags('entitlements') @ApiBearerAuth() @Controller('v1/entitlements') @@ -33,6 +49,7 @@ export class EntitlementController { @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + @ApiOkResponse({ schema: ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA }) @ApiBadRequestResponse({ description: 'The snapshot identifier is invalid.' }) @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) @ApiServiceUnavailableResponse({ description: 'Entitlement persistence is unavailable.' }) @@ -55,6 +72,7 @@ export class EntitlementController { @Get('usage') @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + @ApiOkResponse({ schema: USAGE_LEDGER_RESPONSE_SCHEMA }) @ApiServiceUnavailableResponse({ description: 'Usage persistence is unavailable.' }) async usage(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); diff --git a/services/api/src/features/bua/application/entitlement-equality.ts b/services/api/src/features/bua/application/entitlement-equality.ts index d77e472e..775b8d9a 100644 --- a/services/api/src/features/bua/application/entitlement-equality.ts +++ b/services/api/src/features/bua/application/entitlement-equality.ts @@ -114,3 +114,10 @@ export function sameUsageReservationExceptStatusV1( left.createdAt === right.createdAt ); } + +export function validUsageReservationTransitionV1( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index 3e5c0650..a0ae8ca5 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -10,17 +10,12 @@ import type { IamTransactionPortV1, } from '../application/iam-repository.port.js'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): boolean { return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } -function scopeSpecificity(scope: TenantScopeV1): number { - if (scope.scopeType === 'project') return 3; - if (scope.scopeType === 'workspace') return 2; - return 1; -} - function cloneMemberships(source: readonly IamMembershipRecordV1[]): IamMembershipRecordV1[] { return source.map((membership) => Object.freeze({ ...membership, scope: { ...membership.scope } }), @@ -41,18 +36,7 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { principalId: StableIdentifierV1, ): Promise { await Promise.resolve(); - return this.memberships - .filter( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - tenantScopeContainsV1(membership.scope, context.tenantScope), - ) - .sort( - (left, right) => - scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || - left.id.localeCompare(right.id), - )[0]; + return selectAuthoritativeMembership(this.memberships, context, principalId); } async listMemberships(context: IamTenantContextV1): Promise { diff --git a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts index 5ca1c46e..d67720e3 100644 --- a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts @@ -14,6 +14,11 @@ function cloneState(state: MfaStateV1): MfaStateV1 { } function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + if ( + new Set(next.factors.map((factor) => factor.id)).size !== next.factors.length || + new Set(next.recoveryCodes.map((code) => code.id)).size !== next.recoveryCodes.length + ) + return false; const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); if ( 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 87a75667..48590c17 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 @@ -13,6 +13,7 @@ import type { IamTransactionPortV1, } from '../application/iam-repository.port.js'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; export interface IamMembershipDatabaseRowV1 { readonly id: string; @@ -100,10 +101,16 @@ function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecord function membershipFromRowOrSkip( row: IamMembershipDatabaseRowV1, + onMalformedMembershipRow?: (membershipId: string) => void, ): IamMembershipRecordV1 | undefined { try { return membershipFromRow(row); } catch { + try { + onMalformedMembershipRow?.(row.id); + } catch { + // Diagnostics are best-effort and must not change fail-closed authority selection. + } return undefined; } } @@ -130,14 +137,47 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } -function scopeSpecificity(scope: TenantScopeV1): number { - if (scope.scopeType === 'project') return 3; - if (scope.scopeType === 'workspace') return 2; - return 1; +function membershipVisibilityWhere(context: IamTenantContextV1): Readonly> { + const scope = context.tenantScope; + if (scope.scopeType === 'organization') return { organizationId: scope.organizationId }; + if (scope.scopeType === 'workspace') { + return { + organizationId: scope.organizationId, + OR: [ + { scopeType: 'ORGANIZATION' }, + { scopeType: 'WORKSPACE', workspaceId: scope.workspaceId }, + { scopeType: 'PROJECT', workspaceId: scope.workspaceId }, + ], + }; + } + return { + organizationId: scope.organizationId, + OR: [ + { scopeType: 'ORGANIZATION' }, + { scopeType: 'WORKSPACE', workspaceId: scope.workspaceId }, + { scopeType: 'PROJECT', projectId: scope.projectId }, + ], + }; +} + +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + +interface IamRepositoryDiagnosticsV1 { + readonly onMalformedMembershipRow?: (membershipId: string) => void; } class PrismaIamTransactionAdapter implements IamTransactionPortV1 { - public constructor(private readonly client: IamTransactionDatabaseClientV1) {} + public constructor( + private readonly client: IamTransactionDatabaseClientV1, + private readonly diagnostics: IamRepositoryDiagnosticsV1 = {}, + ) {} public async findMembership( context: IamTenantContextV1, @@ -151,31 +191,24 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }, orderBy: { id: 'asc' }, }); - return rows - .map(membershipFromRowOrSkip) - .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) - .filter( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - tenantScopeContainsV1(membership.scope, context.tenantScope), - ) - .sort( - (left, right) => - scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || - left.id.localeCompare(right.id), - )[0]; + return selectAuthoritativeMembership( + rows + .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined), + context, + principalId, + ); } public async listMemberships( context: IamTenantContextV1, ): Promise { const rows = await this.client.membershipIdentity.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: membershipVisibilityWhere(context), orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRowOrSkip) + .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); } @@ -188,15 +221,20 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { 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.findFirst({ - where: { - id: membership.id, - organizationId: context.tenantScope.organizationId, - }, + const existingById = await this.client.membershipIdentity.findFirst({ + where: { id: membership.id }, }); + if (existingById !== null && existingById.organizationId !== context.tenantScope.organizationId) + throw new Error('IAM_REVISION_CONFLICT'); + const existingRow = existingById; if (!existingRow) { if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); - await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + try { + await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + } catch (error) { + if (isUniqueConstraintViolation(error)) throw new Error('IAM_REVISION_CONFLICT'); + throw error; + } return; } const existing = membershipFromRow(existingRow); @@ -220,19 +258,28 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { } export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { - public constructor(private readonly client: IamDatabaseClientV1) {} + public constructor( + private readonly client: IamDatabaseClientV1, + private readonly diagnostics: IamRepositoryDiagnosticsV1 = {}, + ) {} public findMembership(context: IamTenantContextV1, principalId: StableIdentifierV1) { - return new PrismaIamTransactionAdapter(this.client).findMembership(context, principalId); + return new PrismaIamTransactionAdapter(this.client, this.diagnostics).findMembership( + context, + principalId, + ); } public listMemberships(context: IamTenantContextV1) { - return new PrismaIamTransactionAdapter(this.client).listMemberships(context); + return new PrismaIamTransactionAdapter(this.client, this.diagnostics).listMemberships(context); } public saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1) { return this.client.$transaction((transaction) => - new PrismaIamTransactionAdapter(transaction).saveMembership(context, membership), + new PrismaIamTransactionAdapter(transaction, this.diagnostics).saveMembership( + context, + membership, + ), ); } @@ -241,7 +288,7 @@ export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { work: (transaction: IamTransactionPortV1) => Promise, ): Promise { return this.client.$transaction((transaction) => - work(new PrismaIamTransactionAdapter(transaction)), + work(new PrismaIamTransactionAdapter(transaction, this.diagnostics)), ); } } 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 86c1515d..9bf9437a 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 @@ -104,7 +104,8 @@ function ownedFieldsMatch(existing: TRow, expected: TRow): export interface IdentityBootstrapDatabaseClientV1 { readonly userIdentity: UserDelegateV1; - readonly organizationIdentity: IdentityDelegateV1; + readonly organizationIdentity: IdentityDelegateV1 & + ListDelegateV1; readonly workspaceIdentity: IdentityDelegateV1 & ListDelegateV1; readonly projectIdentity: IdentityDelegateV1 & @@ -122,8 +123,12 @@ function stableId(input: unknown): StableIdentifierV1 | undefined { function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { if (!input) return undefined; - const parsed = parseStrictUtcTimestampV1(input.toISOString()); - return parsed.accepted ? parsed.value : undefined; + try { + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; + } catch { + return undefined; + } } function safeText(input: unknown, maxLength: number): string | undefined { @@ -198,6 +203,13 @@ function bootstrapFromRows( project.status !== 'ACTIVE' ) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + const startsAt = timestamp(membership.startsAt); + const expiresAt = timestamp(membership.expiresAt); + if ( + (membership.startsAt !== null && membership.startsAt !== undefined && !startsAt) || + (membership.expiresAt !== null && membership.expiresAt !== undefined && !expiresAt) + ) + throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); const parsedMembership = validateMembershipV1({ id: membership.id, principalType: membership.principalType, @@ -205,8 +217,8 @@ function bootstrapFromRows( scope: { scopeType: 'organization', organizationId: membership.organizationId }, roleId: membership.roleId, status: membership.status, - ...(membership.startsAt ? { startsAt: timestamp(membership.startsAt) } : {}), - ...(membership.expiresAt ? { expiresAt: timestamp(membership.expiresAt) } : {}), + ...(startsAt ? { startsAt } : {}), + ...(expiresAt ? { expiresAt } : {}), revision: membership.revision, }); if ( @@ -272,18 +284,26 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran projectId: null, }, }); + const sortedMemberships = [...memberships].sort((left, right) => + left.id.localeCompare(right.id), + ); + const candidateOrganizationIds = sortedMemberships.map((membership) => { + const candidateOrganizationId = stableId(membership.organizationId); + if (!candidateOrganizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return candidateOrganizationId; + }); + const organizations = await this.client.organizationIdentity.findMany({ + where: { id: { in: candidateOrganizationIds }, personal: true }, + }); + const organizationsById = new Map( + organizations.map((organization) => [organization.id, organization]), + ); const personalCandidates: Array<{ readonly membership: MembershipIdentityDatabaseRowV1; readonly organization: OrganizationIdentityDatabaseRowV1; }> = []; - for (const membership of [...memberships].sort((left, right) => - left.id.localeCompare(right.id), - )) { - const candidateOrganizationId = stableId(membership.organizationId); - if (!candidateOrganizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); - const candidate = await this.client.organizationIdentity.findUnique({ - where: { id: candidateOrganizationId }, - }); + for (const membership of sortedMemberships) { + const candidate = organizationsById.get(membership.organizationId); if (candidate?.personal) personalCandidates.push({ membership, organization: candidate }); } if (personalCandidates.length === 0) return undefined; 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 4af5d094..2384a4e3 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 @@ -181,6 +181,11 @@ function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { } function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + if ( + new Set(next.factors.map((factor) => factor.id)).size !== next.factors.length || + new Set(next.recoveryCodes.map((code) => code.id)).size !== next.recoveryCodes.length + ) + return false; const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); if ( 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 a3119754..6c57ace8 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 @@ -238,26 +238,34 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { this.clock = options.clock ?? (() => new Date()); } - private async revokeRefreshFamily( + private async revokeSession( transaction: SessionLifecycleDatabaseClientV1, - sessionId: StableIdentifierV1, - familyId: StableIdentifierV1, + session: Pick, now: Date, ): Promise { await transaction.refreshTokenRecord.updateMany({ - where: { familyId, status: 'ACTIVE' }, + where: { familyId: session.familyId, status: 'ACTIVE' }, data: { status: 'REVOKED' }, }); await transaction.sessionRecord.update({ - where: { id: sessionId }, - data: { status: 'REVOKED', revokedAt: now }, + where: { id: session.id }, + data: { status: 'REVOKED', revokedAt: session.revokedAt ?? now }, }); await transaction.accessTokenRecord.updateMany({ - where: { sessionId, status: 'ACTIVE' }, + where: { sessionId: session.id, status: 'ACTIVE' }, data: { status: 'REVOKED', revokedAt: now }, }); } + private async revokeRefreshFamily( + transaction: SessionLifecycleDatabaseClientV1, + sessionId: StableIdentifierV1, + familyId: StableIdentifierV1, + now: Date, + ): Promise { + await this.revokeSession(transaction, { id: sessionId, familyId }, now); + } + private async expireSession( transaction: SessionLifecycleDatabaseClientV1, sessionId: StableIdentifierV1, @@ -475,18 +483,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { 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' }, - }); - await transaction.accessTokenRecord.updateMany({ - where: { sessionId: session.id, status: 'ACTIVE' }, - data: { status: 'REVOKED', revokedAt: now }, - }); + await this.revokeSession(transaction, session, now); return true; }); } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 08e43d4a..664da70a 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -176,9 +176,9 @@ export class AuthenticationController { @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); try { - if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - const context = await this.requestContext.resolve(request); const principal = await this.sessions.findPrincipal(input.sessionId); if ( !principal || diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index 04256dbf..a4d2a9ab 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -26,7 +26,10 @@ export class MfaController { private async execute(work: () => Promise): Promise { try { return await work(); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'IAM_MFA_REVISION_CONFLICT') { + throw new MfaProblemError('IAM_MFA_REVISION_CONFLICT'); + } throw new MfaProblemError('MFA_UNAVAILABLE'); } } diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index b00aed6f..4e2993a6 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,9 +1,12 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; -const MAX_COOKIE_HEADER_LENGTH_V1 = 8_192; -const MAX_COOKIE_NAME_LENGTH_V1 = 64; -const MAX_COOKIE_VALUE_LENGTH_V1 = 4_096; -const MAX_COOKIE_SEGMENTS_V1 = 64; + +export const COOKIE_LIMITS_V1 = Object.freeze({ + headerLength: 8_192, + nameLength: 64, + valueLength: 4_096, + segments: 64, +} as const); export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; @@ -14,11 +17,11 @@ export interface CookieOptionsV1 { } function validCookieNameV1(name: string): boolean { - return name.length <= MAX_COOKIE_NAME_LENGTH_V1 && COOKIE_NAME_PATTERN_V1.test(name); + return name.length <= COOKIE_LIMITS_V1.nameLength && COOKIE_NAME_PATTERN_V1.test(name); } function validCookieValueV1(value: string): boolean { - return value.length <= MAX_COOKIE_VALUE_LENGTH_V1 && COOKIE_VALUE_PATTERN_V1.test(value); + return value.length <= COOKIE_LIMITS_V1.valueLength && COOKIE_VALUE_PATTERN_V1.test(value); } export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { @@ -58,13 +61,13 @@ export function clearCookieV1(name: string, options: Pick MAX_COOKIE_HEADER_LENGTH_V1 || + rawCookie.length > COOKIE_LIMITS_V1.headerLength || !validCookieNameV1(name) ) { return undefined; } const segments = rawCookie.split(';'); - if (segments.length > MAX_COOKIE_SEGMENTS_V1) return undefined; + if (segments.length > COOKIE_LIMITS_V1.segments) return undefined; let found: string | undefined; for (const segment of segments) { const trimmed = segment.trim(); diff --git a/services/api/src/features/iam/application/membership-authority.ts b/services/api/src/features/iam/application/membership-authority.ts new file mode 100644 index 00000000..21ab54e3 --- /dev/null +++ b/services/api/src/features/iam/application/membership-authority.ts @@ -0,0 +1,30 @@ +import { tenantScopeContainsV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +function scopeSpecificity(scope: IamMembershipRecordV1['scope']): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + +/** Select the deterministic active authority that contains the requested tenant scope. */ +export function selectAuthoritativeMembership( + memberships: readonly IamMembershipRecordV1[], + context: IamTenantContextV1, + principalId: StableIdentifierV1, +): IamMembershipRecordV1 | undefined { + return memberships + .filter( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; +} diff --git a/services/api/src/features/iam/application/mfa-problem.error.ts b/services/api/src/features/iam/application/mfa-problem.error.ts index 723029a5..fd5c6568 100644 --- a/services/api/src/features/iam/application/mfa-problem.error.ts +++ b/services/api/src/features/iam/application/mfa-problem.error.ts @@ -1,4 +1,7 @@ -export type MfaProblemCodeV1 = 'MFA_REQUEST_REJECTED' | 'MFA_UNAVAILABLE'; +export type MfaProblemCodeV1 = + | 'MFA_REQUEST_REJECTED' + | 'MFA_UNAVAILABLE' + | 'IAM_MFA_REVISION_CONFLICT'; export class MfaProblemError extends Error { constructor(readonly code: MfaProblemCodeV1) { diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index f9f5519c..80de25e1 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -89,13 +89,23 @@ export class MfaService { private readonly clock: () => Date = () => new Date(), ) {} + private timestamp(): string | undefined { + try { + return this.clock().toISOString(); + } catch { + return undefined; + } + } + public async enroll(input: { readonly id: unknown; readonly userId: unknown; readonly method: unknown; readonly secretReference: unknown; }): Promise> { - const factor = createMfaFactorV1({ ...input, enrolledAt: this.clock().toISOString() }); + const enrolledAt = this.timestamp(); + if (!enrolledAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); + const factor = createMfaFactorV1({ ...input, enrolledAt }); if (!factor.accepted) return Object.freeze({ accepted: false, code: factor.code }); return this.repository.withTransaction(async (transaction) => { const state = await transaction.findState(factor.value.userId); @@ -128,16 +138,23 @@ export class MfaService { const factor = state.factors.find((item) => item.id === factorId); if (!factor) return invalidState(); if (factor.status !== 'PENDING') return invalidState(); - const verified = await this.factorProofVerifier.verify({ - userId, - factorId, - method: factor.method, - secretReference: factor.secretReference, - proof: factorProof, - }); + let verified = false; + try { + verified = await this.factorProofVerifier.verify({ + userId, + factorId, + method: factor.method, + secretReference: factor.secretReference, + proof: factorProof, + }); + } catch { + verified = false; + } if (!verified) return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const }); - const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString()); + const verifiedAt = this.timestamp(); + if (!verifiedAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', verifiedAt); if (!transitioned.accepted) return Object.freeze({ accepted: false, code: transitioned.code }); const next = Object.freeze({ @@ -157,9 +174,11 @@ export class MfaService { if (!userId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); return this.repository.withTransaction(async (transaction) => { const state = await transaction.findState(userId); + const redeemedAt = this.timestamp(); + if (!redeemedAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); const redeemed = redeemRecoveryCodeV1( state, - { userId, presentedDigest, at: this.clock().toISOString() }, + { userId, presentedDigest, at: redeemedAt }, this.recoveryMatcher, ); if (!redeemed.accepted) return Object.freeze({ accepted: false, code: redeemed.code }); diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index febe07a1..9112e266 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -51,12 +51,21 @@ function describe(error: unknown, correlationId: string): ProblemInput { } if (error instanceof MfaProblemError) { const unavailable = error.code === 'MFA_UNAVAILABLE'; + const revisionConflict = error.code === 'IAM_MFA_REVISION_CONFLICT'; return { code: error.code, correlationId, - messageKey: unavailable ? 'api.error.mfa_unavailable' : 'api.error.mfa_request_rejected', + messageKey: unavailable + ? 'api.error.mfa_unavailable' + : revisionConflict + ? 'api.error.mfa_revision_conflict' + : 'api.error.mfa_request_rejected', retryable: unavailable, - status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + status: unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : revisionConflict + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST, }; } if (error instanceof EntitlementProblemError) { @@ -98,12 +107,15 @@ function describe(error: unknown, correlationId: string): ProblemInput { }; } if (error instanceof AuditProblemError) { + const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { code: error.code, correlationId, - messageKey: 'api.error.audit_unavailable', - retryable: true, - status: HttpStatus.SERVICE_UNAVAILABLE, + messageKey: integrityInvalid + ? 'api.error.audit_integrity_invalid' + : 'api.error.audit_unavailable', + retryable: !integrityInvalid, + status: integrityInvalid ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.SERVICE_UNAVAILABLE, }; } if (error instanceof ArtifactExportProblemError) { diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 20f5f512..2f8de0e7 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -12,15 +12,28 @@ import { createProblem } from './problem-details.js'; export interface RequestContext { readonly correlationId: string; readonly requestId: string; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: string; } const requestContexts = new WeakMap(); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const traceparentPattern = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i; export type CorrelationHeaderResult = | { readonly accepted: true; readonly correlationId: string } | { readonly accepted: false }; +export type TraceparentHeaderResult = + | { + readonly accepted: true; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: string; + } + | { readonly accepted: false }; + export interface RequestContextOptions { readonly csrf?: Partial; } @@ -59,6 +72,33 @@ export function parseCorrelationHeader( return { accepted: true, correlationId: value }; } +/** Parses one W3C traceparent without reflecting malformed or provider values. */ +export function parseTraceparentHeader(values: readonly string[]): TraceparentHeaderResult { + if (values.length === 0) return { accepted: true }; + if (values.length !== 1) return { accepted: false }; + const value = values[0]; + if (value === undefined) return { accepted: false }; + const match = traceparentPattern.exec(value); + if (!match) return { accepted: false }; + const [, version, traceId, spanId, traceFlags] = match; + if ( + !version || + version.toLowerCase() === 'ff' || + !traceId || + traceId === '0'.repeat(32) || + !spanId || + spanId === '0'.repeat(16) || + !traceFlags + ) + return { accepted: false }; + return { + accepted: true, + traceId: traceId.toLowerCase(), + spanId: spanId.toLowerCase(), + traceFlags: traceFlags.toLowerCase(), + }; +} + export function getRequestContext(request: FastifyRequest): RequestContext { const context = requestContexts.get(request); if (context === undefined) throw new Error('Request context is unavailable'); @@ -76,10 +116,15 @@ export function installRequestContext( requestContexts.set(request, context); reply.header('X-Request-Id', context.requestId); const values: string[] = []; + const traceValues: string[] = []; for (let index = 0; index < request.raw.rawHeaders.length; index += 2) { - if (request.raw.rawHeaders[index]?.toLowerCase() === 'x-correlation-id') { + const name = request.raw.rawHeaders[index]?.toLowerCase(); + if (name === 'x-correlation-id') { const value = request.raw.rawHeaders[index + 1]; if (value !== undefined) values.push(value); + } else if (name === 'traceparent') { + const value = request.raw.rawHeaders[index + 1]; + if (value !== undefined) traceValues.push(value); } } const parsed = parseCorrelationHeader(values, requestId); @@ -99,7 +144,34 @@ export function installRequestContext( ); return; } - const acceptedContext = { correlationId: parsed.correlationId, requestId }; + const parsedTrace = parseTraceparentHeader(traceValues); + if (!parsedTrace.accepted) { + reply.header('X-Correlation-Id', requestId); + reply + .code(400) + .type('application/problem+json') + .send( + createProblem({ + code: 'CORRELATION_ID_INVALID', + correlationId: requestId, + messageKey: 'api.error.correlation_id_invalid', + retryable: false, + status: 400, + }), + ); + return; + } + const acceptedContext = { + correlationId: parsed.correlationId, + requestId, + ...(parsedTrace.traceId + ? { + traceId: parsedTrace.traceId, + spanId: parsedTrace.spanId, + traceFlags: parsedTrace.traceFlags, + } + : {}), + }; requestContexts.set(request, acceptedContext); reply.header('X-Correlation-Id', acceptedContext.correlationId); const csrf = evaluateCsrfRequestV1( diff --git a/services/api/test/features/aud/audit-page-cursor.test.ts b/services/api/test/features/aud/audit-page-cursor.test.ts index 846a25c3..19dd1a2e 100644 --- a/services/api/test/features/aud/audit-page-cursor.test.ts +++ b/services/api/test/features/aud/audit-page-cursor.test.ts @@ -49,3 +49,19 @@ void test('[AUD-001] audit page cursors fail closed for malformed or oversized v }); } }); + +void test('[AUD-001] audit page cursors reject forged offsets and unknown fields', () => { + const forge = (payload: Record) => + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); + const scope = `workspace:${organizationId}:${workspaceId}`; + for (const payload of [ + { version: 1, kind: 'events', scope, offset: -1 }, + { version: 1, kind: 'events', scope, offset: 1.5 }, + { version: 1, kind: 'events', scope, offset: 0, extra: 'x' }, + ]) { + assert.deepEqual(parseAuditPageCursorV1(forge(payload), 'events', workspaceScope), { + accepted: false, + code: 'INVALID_CURSOR', + }); + } +}); diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index 9a6ac157..f690a808 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -212,7 +212,6 @@ void test('[AUD-002] Prisma audit transactions do not retain an event when the u 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, diff --git a/services/api/test/features/bua/entitlement-equality.test.ts b/services/api/test/features/bua/entitlement-equality.test.ts new file mode 100644 index 00000000..e35acf38 --- /dev/null +++ b/services/api/test/features/bua/entitlement-equality.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { UsageReservationV1 } from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { validUsageReservationTransitionV1 } from '../../../src/features/bua/application/entitlement-equality.js'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test timestamp'); + return parsed.value; +} + +const active: UsageReservationV1 = Object.freeze({ + reservationId: stable('00000000-0000-4000-8000-000000000001'), + tenantScope: Object.freeze({ + scopeType: 'organization', + organizationId: stable('00000000-0000-4000-8000-000000000002'), + }), + metric: 'job_count', + reservedUnits: 1, + status: 'ACTIVE', + createdAt: timestamp('2026-01-01T00:00:00.000Z'), + revision: 1, +}); + +void test('[BUA-012] reservation transitions share one terminal-state policy', () => { + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'FINALIZED', revision: 2 }), + true, + ); + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'RELEASED', revision: 2 }), + true, + ); + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'ACTIVE', revision: 2 }), + false, + ); + assert.equal( + validUsageReservationTransitionV1( + { ...active, status: 'FINALIZED' }, + { ...active, status: 'RELEASED', revision: 2 }, + ), + false, + ); +}); diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 451fc208..8bb87691 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -145,6 +145,10 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only if (!reserved.accepted) return; await repository.persistUsageState(context(workspaceId), reserved.value.state); assert.equal((await repository.listUsageState(context(workspaceId))).entries.length, 1); + await assert.rejects( + repository.persistUsageState(context(siblingWorkspaceId), reserved.value.state), + /BUA_SCOPE_NARROWING_REQUIRED/u, + ); const activeReservation = reserved.value.state.reservations[0]; if (!activeReservation) throw new Error('fixture reservation missing'); await assert.rejects( @@ -161,6 +165,23 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only }), /BUA_IMMUTABLE_USAGE_ENTRY/, ); + const persistedEntry = reserved.value.state.entries[0]; + if (!persistedEntry) throw new Error('fixture entry missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + ...reserved.value.state, + entries: [persistedEntry, persistedEntry], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); + if (!activeReservation) throw new Error('fixture reservation missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + ...reserved.value.state, + reservations: [activeReservation, activeReservation], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); await assert.rejects( repository.withTransaction(context(workspaceId), async (transaction) => { const second = reserveUsageV1(storedSnapshot, reserved.value.state, { 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 520cea03..ec4fcf58 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -8,6 +8,7 @@ import { } from '@databreeze/domain/entitlements/v1'; import { parseStableIdentifierV1, + tenantScopeKeyV1, type StrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -90,6 +91,7 @@ function delegate>( rows: TRow[], forceRevisionConflict = false, firstQueries?: Array>>, + manyQueries?: Array>>, ) { const matches = (row: TRow, where: Readonly>): boolean => Object.entries(where).every(([key, value]) => { @@ -101,6 +103,14 @@ function delegate>( matches(row, candidate as Readonly>), ); } + if ( + typeof value === 'object' && + value !== null && + 'in' in value && + Array.isArray((value as { readonly in?: unknown }).in) + ) { + return (value as { readonly in: readonly unknown[] }).in.includes(row[key]); + } return row[key] === value; }); return { @@ -130,9 +140,8 @@ function delegate>( readonly where: Readonly>; readonly orderBy?: Readonly>; }) { - const filtered = rows.filter((row) => - Object.entries(where).every(([key, value]) => row[key] === value), - ); + manyQueries?.push(where); + const filtered = rows.filter((row) => matches(row, where)); const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; return Promise.resolve( [...filtered].sort((left, right) => { @@ -175,6 +184,7 @@ function client( options: { readonly forceRevisionConflict?: boolean; readonly firstQueries?: Array>>; + readonly manyQueries?: Array>>; readonly transactionCalls?: { value: number }; } = {}, ): EntitlementDatabaseClientV1 { @@ -185,11 +195,12 @@ function client( const database = { entitlementPlanRecord: delegate(planRows), entitlementSnapshotRecord: delegate(snapshotRows, false, options.firstQueries), - usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries), + usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries, options.manyQueries), usageReservationRecord: delegate( reservationRows, options.forceRevisionConflict, options.firstQueries, + options.manyQueries, ), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, @@ -251,6 +262,35 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi ); }); +void test('[BUA-008, BUA-011] Prisma entitlement adapter rejects duplicate usage identities', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.saveSnapshot(context(workspaceId, 'duplicate-snapshot'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'duplicate-admit'), + admissionInput('duplicate-admit', '1'), + ); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + const entry = admitted.value.state.entries[0]; + const reservation = admitted.value.state.reservations[0]; + if (!entry || !reservation) throw new Error('fixture usage state missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId, 'duplicate-entry'), { + ...admitted.value.state, + entries: [entry, entry], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); + await assert.rejects( + repository.persistUsageState(context(workspaceId, 'duplicate-reservation'), { + ...admitted.value.state, + reservations: [reservation, reservation], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); +}); + void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-scoped usage', async () => { const repository = new PrismaEntitlementRepositoryAdapter(client()); await repository.saveSnapshot(context(workspaceId, 'project-snapshot'), snapshot()); @@ -279,6 +319,39 @@ void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-sco }); }); +void test('[BUA-008, IAM-009] inherited usage reads use one scope-key query per record family', async () => { + const manyQueries: Array>> = []; + const repository = new PrismaEntitlementRepositoryAdapter(client({ manyQueries })); + await repository.saveSnapshot(context(workspaceId, 'batched-snapshot'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit(projectContext('batched-admit'), { + ...admissionInput('batched-admit', '1'), + tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, + }); + assert.equal(admitted.accepted, true); + + manyQueries.length = 0; + await repository.listUsageState(projectContext('batched-read')); + assert.equal(manyQueries.length, 2); + const expectedKeys = [ + tenantScopeKeyV1({ scopeType: 'organization', organizationId: stable(organizationId) }), + tenantScopeKeyV1({ + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }), + tenantScopeKeyV1({ + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }), + ]; + for (const query of manyQueries) { + assert.deepEqual(query['scopeKey'], { in: expectedKeys }); + } +}); + void test('[BUA-008, BUA-011] direct usage persistence executes in one database transaction', async () => { const transactionCalls = { value: 0 }; const repository = new PrismaEntitlementRepositoryAdapter(client({ transactionCalls })); diff --git a/services/api/test/features/iam/membership-authority.test.ts b/services/api/test/features/iam/membership-authority.test.ts new file mode 100644 index 00000000..7309f3ce --- /dev/null +++ b/services/api/test/features/iam/membership-authority.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectAuthoritativeMembership } from '../../../src/features/iam/application/membership-authority.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000201'; +const workspaceId = '00000000-0000-4000-8000-000000000202'; +const projectId = '00000000-0000-4000-8000-000000000203'; +const principalId = '00000000-0000-4000-8000-000000000204'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid membership authority identifier'); + return parsed.value; +} + +function context() { + const parsed = createIamTenantContextV1({ + tenantScope: { + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }, + actorId: stable(principalId), + correlationId: stable('00000000-0000-4000-8000-000000000205'), + idempotencyKey: 'membership-authority-test', + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid membership authority context'); + return parsed.value; +} + +void test('[IAM-003, IAM-014] shared membership authority picks the narrowest active scope', () => { + const selected = selectAuthoritativeMembership( + [ + { + id: stable('00000000-0000-4000-8000-000000000212'), + principalId: stable(principalId), + scope: { scopeType: 'organization', organizationId: stable(organizationId) }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('00000000-0000-4000-8000-000000000211'), + principalId: stable(principalId), + scope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable('00000000-0000-4000-8000-000000000213'), + principalId: stable(principalId), + scope: { + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }, + ], + context(), + stable(principalId), + ); + assert.equal(selected?.roleId, 'operator'); +}); diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index b0574b69..e196387c 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -46,6 +46,47 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); }); +void test('[IAM-013] MFA proof-provider failures become a safe verification result', async () => { + const repository = new InMemoryMfaRepositoryAdapter(); + const service = new MfaService( + repository, + { matches: (presented, stored) => presented === stored }, + { verify: () => Promise.reject(new Error('provider secret details must not escape')) }, + () => new Date(at), + ); + const enrolled = await service.enroll({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'secret-ref:totp:1', + }); + assert.equal(enrolled.accepted, true); + assert.deepEqual(await service.verifyFactor(userId, factorId, '654321'), { + accepted: false, + code: 'FACTOR_PROOF_INVALID', + }); +}); + +void test('[IAM-012, IAM-015] MFA clock-provider failures become a stable timestamp result', async () => { + const service = new MfaService( + new InMemoryMfaRepositoryAdapter(), + { matches: (presented, stored) => presented === stored }, + undefined, + () => { + throw new Error('clock provider details must not escape'); + }, + ); + assert.deepEqual( + await service.enroll({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'secret-ref:totp:1', + }), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); + void test('[IAM-015, IAM-016] recovery code redemption is one-time and does not expose digests', async () => { const repository = new InMemoryMfaRepositoryAdapter(); const code = createRecoveryCodeV1({ id: recoveryId, userId, digest: 'digest-1', createdAt: at }); @@ -143,4 +184,19 @@ void test('[IAM-012, IAM-014] in-memory MFA state rejects removal and invalid ne }), /IAM_MFA_REVISION_CONFLICT/u, ); + + await assert.rejects( + repository.saveState(userId as never, { + factors: [factor.value, factor.value], + recoveryCodes: [code.value], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); + await assert.rejects( + repository.saveState(userId as never, { + factors: [factor.value], + recoveryCodes: [code.value, code.value], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); }); 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 942780ff..7ae9e578 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -71,28 +71,37 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly memberships: Map; readonly forceUpdateConflict: { value: boolean }; readonly firstQueries: ReadonlyArray>>; + readonly manyQueries: ReadonlyArray>>; } { const memberships = new Map(rows.map((value) => [value.id, value])); const forceUpdateConflict = { value: false }; const firstQueries: Array>> = []; + const manyQueries: Array>> = []; + const matches = ( + candidate: IamMembershipDatabaseRowV1, + where: Readonly>, + ): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some( + (alternative) => + typeof alternative === 'object' && + alternative !== null && + matches(candidate, alternative as Readonly>), + ); + } + return candidate[key as keyof IamMembershipDatabaseRowV1] === value; + }); const client = { membershipIdentity: { findFirst: async ({ where }: { readonly where: Readonly> }) => { firstQueries.push(where); - return ( - [...memberships.values()].find((candidate) => - Object.entries(where).every( - ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, - ), - ) ?? null - ); + return [...memberships.values()].find((candidate) => matches(candidate, where)) ?? null; }, - findMany: async ({ where }: { readonly where: Readonly> }) => - [...memberships.values()].filter((candidate) => - Object.entries(where).every( - ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, - ), - ), + findMany: async ({ where }: { readonly where: Readonly> }) => ( + manyQueries.push(where), + [...memberships.values()].filter((candidate) => matches(candidate, where)) + ), create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { memberships.set(data.id, data); return data; @@ -125,11 +134,11 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships, forceUpdateConflict, firstQueries }; + return { client, memberships, forceUpdateConflict, firstQueries, manyQueries }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { - const { client } = createDatabase([ + const { client, manyQueries } = createDatabase([ row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), row(id('11'), 'WORKSPACE', siblingWorkspaceId, 'owner'), row(id('12'), 'ORGANIZATION', null, 'admin'), @@ -146,15 +155,31 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and (await repository.findMembership(context(workspaceScope), principalId))?.id, stable('10'), ); + assert.ok( + manyQueries.some( + (query) => + Array.isArray(query['OR']) && + query['organizationId'] === organizationId && + (query['OR'] as readonly unknown[]).some( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + (candidate as Record)['scopeType'] === 'PROJECT', + ), + ), + ); }); void test('[IAM-009, IAM-019] malformed membership rows fail closed without blocking valid reads', async () => { const valid = row(id('20'), 'WORKSPACE', workspaceId, 'viewer'); const malformed = { ...row(id('21'), 'WORKSPACE', workspaceId, 'viewer'), - workspaceId: 'not-a-workspace-id', + roleId: 'malformed-role', }; - const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client); + const skipped: string[] = []; + const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client, { + onMalformedMembershipRow: (membershipId) => skipped.push(membershipId), + }); assert.deepEqual( ( @@ -164,6 +189,7 @@ void test('[IAM-009, IAM-019] malformed membership rows fail closed without bloc ).map((membership) => membership.id), [valid.id], ); + assert.deepEqual(skipped, [malformed.id]); }); void test('[IAM-003, IAM-014] Prisma membership authority chooses the narrowest containing scope', async () => { @@ -280,5 +306,29 @@ void test('[IAM-009, IAM-019] Prisma membership mutation lookup includes tenant revision: 1, }); - assert.deepEqual(firstQueries, [{ id: stable('23'), organizationId }]); + assert.deepEqual(firstQueries, [{ id: stable('23') }]); +}); + +void test('[IAM-009] Prisma membership writes reject cross-organization identifier collisions', async () => { + const foreignOrganizationId = id('99'); + const foreign = { + ...row(id('24'), 'WORKSPACE', workspaceId, 'viewer'), + organizationId: foreignOrganizationId, + }; + const { client, memberships } = createDatabase([foreign]); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + + await assert.rejects( + repository.saveMembership(context(workspaceScope), { + id: stable('24'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }), + /IAM_REVISION_CONFLICT/u, + ); + assert.equal(memberships.get(id('24'))?.organizationId, foreignOrganizationId); }); 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 f8454d07..e3a039e8 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,6 +42,7 @@ function createDatabase(): { readonly memberships: Map; readonly transactionCalls: { value: number }; readonly transactionWriteCalls: { value: number }; + readonly organizationFindManyCalls: { value: number }; } { const users = new Map([ [ @@ -63,6 +64,7 @@ function createDatabase(): { const memberships = new Map(); const transactionCalls = { value: 0 }; const transactionWriteCalls = { value: 0 }; + const organizationFindManyCalls = { value: 0 }; const client = { userIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -75,6 +77,16 @@ function createDatabase(): { }, findUnique: async ({ where }: { readonly where: { readonly id: string } }) => organizations.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => { + organizationFindManyCalls.value += 1; + const ids = + typeof where['id'] === 'object' && where['id'] !== null && 'in' in where['id'] + ? ((where['id'] as { readonly in?: readonly string[] }).in ?? []) + : []; + return [...organizations.values()].filter( + (row) => ids.includes(row.id) && where['personal'] === row.personal, + ); + }, }, workspaceIdentity: { create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { @@ -183,6 +195,7 @@ function createDatabase(): { memberships, transactionCalls, transactionWriteCalls, + organizationFindManyCalls, }; } @@ -195,6 +208,7 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct memberships, transactionCalls, transactionWriteCalls, + organizationFindManyCalls, } = createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); const validated = bootstrapPersonalOrganizationV1(input); @@ -209,6 +223,7 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct assert.equal(projects.size, 1); assert.equal(memberships.size, 1); assert.deepEqual(await adapter.findByUserId(validated.value.user.id), validated.value); + assert.equal(organizationFindManyCalls.value, 1); }); void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { @@ -273,6 +288,60 @@ void test('[IAM-001, IAM-011] bootstrap lookup selects the personal organization ); }); +void test('[IAM-011] bootstrap lookup rejects two personal organizations', async () => { + const state = createDatabase(); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + const secondOrganizationId = '00000000-0000-4000-8000-000000000008'; + const secondMembershipId = '00000000-0000-4000-8000-000000000009'; + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + await adapter.save(validated.value); + state.organizations.set(secondOrganizationId, { + id: secondOrganizationId, + name: 'Second personal organization', + personal: true, + status: 'ACTIVE', + createdAt, + }); + state.memberships.set(secondMembershipId, { + id: secondMembershipId, + principalType: 'USER', + principalId: userId, + scopeType: 'ORGANIZATION', + organizationId: secondOrganizationId, + workspaceId: null, + projectId: null, + roleId: 'owner', + status: 'ACTIVE', + startsAt: null, + expiresAt: null, + revision: 1, + }); + + await assert.rejects( + adapter.findByUserId(validated.value.user.id), + /IAM_PERSISTED_ORGANIZATION_INVALID/u, + ); +}); + +void test('[IAM-009] bootstrap lookup rejects unparseable membership timestamps', async () => { + const state = createDatabase(); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + await adapter.save(validated.value); + const membership = state.memberships.get(membershipId); + assert.ok(membership); + state.memberships.set(membershipId, { ...membership, expiresAt: new Date('invalid') }); + + await assert.rejects( + adapter.findByUserId(validated.value.user.id), + /IAM_PERSISTED_MEMBERSHIP_INVALID/u, + ); +}); + void test('[IAM-001, IAM-011] bootstrap lookup survives personal workspace and project renames', async () => { const state = createDatabase(); const validated = bootstrapPersonalOrganizationV1(input); 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 daa55d3b..115535be 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -227,6 +227,30 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale rev ); }); +void test('[IAM-012, IAM-014] Prisma MFA persistence rejects duplicate state identities', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + const code = input.recoveryCodes[0]; + if (!factor || !code) throw new Error('fixture missing MFA state'); + + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [factor, factor], + recoveryCodes: [code], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [factor], + recoveryCodes: [code, code], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); +}); + void test('[IAM-012, IAM-016] Prisma MFA persistence rejects a redemption race with compare-and-set', async () => { const { client } = createDatabase(); const adapter = new PrismaMfaRepositoryAdapter(client); 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 b08dd7fc..acad931c 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -242,11 +242,14 @@ void test('[IAM-005] refresh cannot restart an expired inactivity window', async }); void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => { - const { client } = createDatabase(); + const { client, sessions } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client); const session = await adapter.issue(principal, 'web'); assert.equal(await adapter.revoke(session.sessionId), true); + const firstRevokedAt = sessions.get(session.sessionId)?.revokedAt?.getTime(); + assert.equal(typeof firstRevokedAt, 'number'); assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(sessions.get(session.sessionId)?.revokedAt?.getTime(), firstRevokedAt); assert.equal(await adapter.findPrincipal(session.sessionId), undefined); assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); }); diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 9061e8c4..bdedc155 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { CSRF_COOKIE_NAME_V1, + COOKIE_LIMITS_V1, REFRESH_COOKIE_NAME_V1, clearCookieV1, readCookieValueV1, @@ -50,31 +51,83 @@ void test('reads one exact cookie value and fails closed for ambiguity or malfor }); void test('rejects cookie headers and fields beyond parser resource bounds', () => { + const headerPrefix = `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=`; + const secondSegmentPrefix = '; second='; + const secondValueLength = + COOKIE_LIMITS_V1.headerLength - + headerPrefix.length - + COOKIE_LIMITS_V1.valueLength - + secondSegmentPrefix.length; + const headerAtLimit = + headerPrefix + + 'a'.repeat(COOKIE_LIMITS_V1.valueLength) + + secondSegmentPrefix + + 'b'.repeat(secondValueLength); + + assert.equal(headerAtLimit.length, COOKIE_LIMITS_V1.headerLength); + assert.equal(readCookieValueV1(headerAtLimit, REFRESH_COOKIE_NAME_V1), refreshToken); + assert.equal(readCookieValueV1(`${headerAtLimit}x`, REFRESH_COOKIE_NAME_V1), undefined); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from( + { length: COOKIE_LIMITS_V1.segments - 2 }, + (_, index) => `c${index}=v`, + ).join('; ')}; last=v`, + REFRESH_COOKIE_NAME_V1, + ), + refreshToken, + ); + assert.equal( + serializeCookieV1('a'.repeat(COOKIE_LIMITS_V1.nameLength), token, { + httpOnly: true, + maxAgeSeconds: 1, + }).startsWith(`${'a'.repeat(COOKIE_LIMITS_V1.nameLength)}=`), + true, + ); + assert.equal( + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(COOKIE_LIMITS_V1.valueLength), { + httpOnly: true, + maxAgeSeconds: 1, + }).includes(`=${'a'.repeat(COOKIE_LIMITS_V1.valueLength)};`), + true, + ); assert.equal( readCookieValueV1( - `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat(8_192)}`, + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat( + COOKIE_LIMITS_V1.headerLength, + )}`, REFRESH_COOKIE_NAME_V1, ), undefined, ); assert.equal( readCookieValueV1( - `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from( + { length: COOKIE_LIMITS_V1.segments }, + (_, index) => `c${index}=v`, + ).join('; ')}`, REFRESH_COOKIE_NAME_V1, ), undefined, ); assert.equal( - readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(4_097)}`, REFRESH_COOKIE_NAME_V1), + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(COOKIE_LIMITS_V1.valueLength + 1)}`, + REFRESH_COOKIE_NAME_V1, + ), undefined, ); assert.throws( - () => serializeCookieV1('a'.repeat(65), token, { httpOnly: true, maxAgeSeconds: 1 }), + () => + serializeCookieV1('a'.repeat(COOKIE_LIMITS_V1.nameLength + 1), token, { + httpOnly: true, + maxAgeSeconds: 1, + }), /Cookie name or value is invalid/, ); assert.throws( () => - serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(4_097), { + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(COOKIE_LIMITS_V1.valueLength + 1), { httpOnly: true, maxAgeSeconds: 1, }), diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 1b6ef295..a797afa6 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -10,6 +10,7 @@ import { createIamTenantContextV1 } from '../src/features/iam/application/tenant import { InMemoryMfaRepositoryAdapter } from '../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; import { MfaService } from '../src/features/iam/application/mfa.service.js'; import { InMemoryAuditRepositoryAdapter } from '../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; +import { RequestTenantContextProblemError } from '../src/platform/http/request-tenant-context.port.js'; interface InjectResponse { readonly body: string; @@ -124,6 +125,19 @@ void test('rejects malformed and multiple correlation values without reflecting }); }); +void test('rejects malformed traceparent values without reflecting the header', async () => { + await withApp({}, async (app) => { + const leakedMarker = '00-00000000000000000000000000000000-0123456789abcdef-01'; + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + headers: { traceparent: leakedMarker }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, new RegExp(leakedMarker)); + }); +}); + void test('maps unknown routes to safe Problem Details without exposing the path or query', async () => { await withApp({}, async (app) => { const response = await app.inject({ @@ -587,6 +601,34 @@ void test('sign-out revokes idempotently and clears browser credentials', async assertProblem(response, 503, 'SESSION_UNAVAILABLE'); }, ); + + await withApp( + { + requestTenantContext: { + resolve: () => + Promise.reject(new RequestTenantContextProblemError('AUTHENTICATION_FAILED')), + }, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(signOutPrincipal), + findPrincipalByAccessToken: () => Promise.resolve(signOutPrincipal), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { 'idempotency-key': 'sign-out-auth-failure-001' }, + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000010', + }, + }); + assertProblem(response, 401, 'AUTHENTICATION_FAILED'); + }, + ); }); void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { @@ -746,6 +788,43 @@ void test('audit read outages return retryable service-unavailable problems', as ); }); +void test('audit integrity failures are non-retryable and do not look transient', async () => { + const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { + listEventPage: () => Promise.reject(new Error('AUD_CHAIN_INVALID')), + listSealPage: () => Promise.reject(new Error('AUD_CHAIN_INVALID')), + }); + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + } as const; + await withApp( + { + auditRepository, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: () => Promise.resolve(principal), + }, + }, + async (app) => { + for (const url of ['/v1/audit/events', '/v1/audit/seals']) { + const response = await app.inject({ + method: 'GET', + url, + headers: { authorization: 'Bearer audit-access-token-123456789' }, + }); + assertProblem(response, 500, 'AUDIT_INTEGRITY_INVALID'); + assert.doesNotMatch(response.body, /AUD_CHAIN_INVALID/u); + } + }, + ); +}); + 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( @@ -835,4 +914,20 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assertProblem(response, 503, 'MFA_UNAVAILABLE'); }); + + const conflictingMfa = { + enroll: () => Promise.reject(new Error('IAM_MFA_REVISION_CONFLICT')), + } as unknown as MfaService; + await withApp({ mfaService: conflictingMfa, requestTenantContext }, async (app) => { + const response = 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', + }, + }); + assertProblem(response, 409, 'IAM_MFA_REVISION_CONFLICT'); + }); }); diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 1422edc1..ea60efed 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -505,7 +505,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"\s+ON "iae"\."artifact_lineage"\("derived_artifact_version_id"\);/u, ); const sessionScopeMigration = await readFile( - path.join(migrationsDirectory, inventory[33], 'migration.sql'), + path.join(migrationsDirectory, '20260803010000_iam_session_scope_binding', 'migration.sql'), 'utf8', ); for (const statement of [ @@ -519,4 +519,6 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + assert.match(sessionScopeMigration, /no production or legacy data migration/u); + assert.match(sessionScopeMigration, /guessing tenant scope would be unsafe/u); }); diff --git a/services/api/test/request-context.test.ts b/services/api/test/request-context.test.ts index eecae0ed..e2a720d6 100644 --- a/services/api/test/request-context.test.ts +++ b/services/api/test/request-context.test.ts @@ -7,7 +7,9 @@ const suppliedCorrelationId = '123e4567-e89b-42d3-a456-426614174000'; void test('accepts zero or one valid bounded UUID correlation header and rejects ambiguous or unsafe input', async () => { const requestContextModule = await import('../src/platform/http/request-context.js'); const parseCorrelationHeader = requestContextModule.parseCorrelationHeader; + const parseTraceparentHeader = requestContextModule.parseTraceparentHeader; assert.equal(typeof parseCorrelationHeader, 'function'); + assert.equal(typeof parseTraceparentHeader, 'function'); assert.deepEqual(parseCorrelationHeader([], requestId), { accepted: true, @@ -25,4 +27,26 @@ void test('accepts zero or one valid bounded UUID correlation header and rejects ]) { assert.deepEqual(parseCorrelationHeader(values, requestId), { accepted: false }); } + + assert.deepEqual( + parseTraceparentHeader(['00-0123456789abcdef0123456789abcdef-0123456789abcdef-01']), + { + accepted: true, + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: '01', + }, + ); + for (const values of [ + ['not-a-traceparent'], + ['00-00000000000000000000000000000000-0123456789abcdef-01'], + ['00-0123456789abcdef0123456789abcdef-0000000000000000-01'], + ['ff-0123456789abcdef0123456789abcdef-0123456789abcdef-01'], + [ + '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + ], + ]) { + assert.deepEqual(parseTraceparentHeader(values), { accepted: false }); + } }); diff --git a/services/engine/src/databreeze_engine/telemetry.py b/services/engine/src/databreeze_engine/telemetry.py index b8418ab2..71a41afa 100644 --- a/services/engine/src/databreeze_engine/telemetry.py +++ b/services/engine/src/databreeze_engine/telemetry.py @@ -160,7 +160,7 @@ def _validate_key(key: object) -> str: return key -def sanitize_attributes(attributes: dict[str, Any]) -> dict[str, str | int | float | bool]: +def sanitize_attributes(attributes: Mapping[str, Any]) -> dict[str, str | int | float | bool]: """Return only bounded, allowlisted scalar attributes.""" if not isinstance(attributes, Mapping): @@ -336,7 +336,7 @@ def emit_record( "event": event, "component": component, "correlationId": normalized.correlation_id, - "attributes": sanitize_attributes(dict(attributes or {})), + "attributes": sanitize_attributes(attributes if attributes is not None else {}), } if normalized.trace_id and normalized.span_id: record["traceId"] = normalized.trace_id diff --git a/services/engine/tests/test_telemetry.py b/services/engine/tests/test_telemetry.py index 2860e7a9..15577a6b 100644 --- a/services/engine/tests/test_telemetry.py +++ b/services/engine/tests/test_telemetry.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator, Mapping + import pytest from databreeze_engine.telemetry import ( @@ -128,3 +130,28 @@ def test_engine_accepts_mixed_case_header_names() -> None: ) == context ) + + +def test_engine_record_builder_isolates_hostile_attribute_mappings() -> None: + class HostileAttributes(Mapping[str, object]): + def __getitem__(self, key: str) -> object: + raise RuntimeError("provider source value must not escape") + + def __iter__(self) -> Iterator[str]: + raise RuntimeError("provider source value must not escape") + + def __len__(self) -> int: + return 1 + + def items(self): # type: ignore[override] + raise RuntimeError("provider source value must not escape") + + record = emit_record( + "warn", + "processor.degraded", + "engine", + CorrelationContext("00000000-0000-4000-8000-000000000001"), + HostileAttributes(), + ) + assert record["attributes"] == {} + assert "provider source" not in str(record)