diff --git a/.gitattributes b/.gitattributes index bb742aa2..5c49c819 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,6 +11,7 @@ *.yaml text eol=lf *.yml text eol=lf *.toml text eol=lf +*.hcl text eol=lf *.ts text eol=lf *.tsx text eol=lf *.css text eol=lf @@ -23,6 +24,7 @@ *.kt text eol=lf *.kts text eol=lf *.py text eol=lf +*.sh text eol=lf *.ps1 text eol=crlf *.bat text eol=crlf *.cmd text eol=crlf diff --git a/docs/operations/coderabbit-pr-29-disposition.md b/docs/operations/coderabbit-pr-29-disposition.md new file mode 100644 index 00000000..7eec50f6 --- /dev/null +++ b/docs/operations/coderabbit-pr-29-disposition.md @@ -0,0 +1,51 @@ +# CodeRabbit PR 29 Disposition + +Date: 2026-08-03 +Promotion PR: [#29](https://github.com/DatabreezeService/databreeze-platform/pull/29) +Automatic review run: `f61cec20-123e-4694-9265-e71aa976b01b` +Reviewed range: `3ed3d77d..86f25c85` + +CodeRabbit ran once automatically on the promotion PR. No manual rerun was requested. Every inline, outside-diff, and review-body finding was reproduced against the later `dev` state. Valid gaps were fixed on `fix/coderabbit-promotion-29`; findings already addressed by later `dev` commits are recorded rather than duplicated. + +| ID | Finding | Disposition | Evidence | +|---|---|---|---| +| I-01 | Reservation settlement lacked a revision predicate. | Accepted; already fixed on later `dev`. | `216f4a1`, Prisma reservation race test. | +| I-02 | Membership updates could lose a concurrent write. | Accepted; already fixed on later `dev`. | `237ba56`, Prisma membership race test. | +| I-03 | Bootstrap immutability used `JSON.stringify`. | Accepted; already fixed on later `dev`. | `e6800db`, owned-field comparison tests. | +| I-04 | Sign-out did not prove session ownership. | Accepted; already fixed on later `dev`. | `295b911`, cross-user sign-out rejection test. | +| O-01 | API composition did not expose audit and entitlement database options. | Accepted; already fixed on later `dev`. | `4d3f40d`, foundation composition test. | +| M-01 | BUA dropped project scope from usage rows. | Accepted and fixed. Project IDs are persisted, indexed, reconstructed, and included in inherited reads. | `ccbb9d3`, project usage round-trip test, migration `20260803020000_bua_project_usage_scope`. | +| M-02 | Public audit reads were unbounded. | Accepted and fixed. Public event/seal reads now use limits of 1–100 and tenant-bound opaque cursors; event pages verify each immutable digest. | `a5ba31b`, `72064d6`, `bbca81c`, cursor/Prisma/HTTP tests. | +| M-03 | Direct BUA usage persistence was not transactional. | Accepted and fixed. | `a8a5a47`, transaction invocation test. | +| M-04 | Audit append loaded the complete scope history. | Accepted; already fixed on later `dev`. | `ebe73cf`, bounded duplicate/latest lookups. | +| M-05 | Audit reads allegedly verified multiple scopes as one chain. | Rejected as a false positive. The reviewed domain implementation already groups events by canonical scope before verifying each chain. | `packages/domain/src/audit/v1.ts`, multi-scope grouping in `verifyAuditChainV1`. | +| M-06 | Cookie-name validation rejected valid token characters. | Accepted and fixed. | `0615d54`, hyphenated/dotted cookie-name test. | +| M-07 | Production CSRF origins were not configured explicitly. | Accepted; already fixed on later `dev`. | `8ea5ec9`, production-origin configuration test. | +| M-08 | `GET /v1/auth/me` lacked bearer security and a regression guard. | Accepted. The endpoint annotation was already fixed; a contract-wide protected-operation guard was added. | `295b911`, `21eb825`, generated OpenAPI. | +| M-09 | Refresh response declared `refreshToken` as write-only. | Accepted and fixed. | `0483b4b`, generated-schema assertion. | +| M-10 | `sessionDatabase` composition did not create request tenant context. | Accepted and fixed with one shared session adapter instance. | `1bf5650`, foundation composition test. | +| M-11 | MFA factor activation required no factor proof. | Accepted and fixed with a fail-closed proof-verifier port. | `66a9a56`, invalid/valid proof tests. | +| M-12 | IAM membership reads loaded memberships outside the organization. | Accepted; already fixed on later `dev`. | `b7ee10a`, scoped query tests. | +| M-13 | Entitlement endpoints broke the Problem Details convention. | Accepted; already fixed on later `dev`. | `2328dd4`, HTTP problem tests. | +| M-14 | Unsafe-principal test used a malformed bearer token and asserted the wrong path. | Accepted; already fixed on later `dev`. | `44c1fae`, valid-token unsafe-principal test. | +| M-15 | Session authority outages were reported as credential rejection. | Accepted; already fixed on later `dev`. | `7c94a11`, `a62e515`, availability-boundary tests. | +| M-16 | Mutation requests fabricated idempotency keys from request IDs. | Accepted and fixed. Unsafe methods now require an explicit `Idempotency-Key`; read-only methods may use the request ID. | `85d60cc`, adapter and HTTP sign-out tests. | +| M-17 | One malformed membership row could block unrelated reads. | Accepted and fixed. Read paths skip invalid rows while mutation paths remain strict. | `259c92a`, malformed-row isolation test. | +| M-18 | `mfaRequired` should centrally block protected operations. | Rejected as proposed and retained as planned work. The field currently reports enrolled-factor presence, so blocking when true would lock out MFA-enrolled users. Endpoint risk classification and authenticated step-up assertions remain `partial` under Plan 020/IAM-012 and must be implemented as a dedicated vertical slice. | `PrismaSessionLifecycleAdapter.findPrincipal`, `MfaService.requireStepUp`, requirement traceability status. | +| M-19 | Sign-out lacked caller authorization. | Accepted; duplicate of I-04 and already fixed. | `295b911`. | +| M-20 | IAM transaction callbacks incorrectly required root `$transaction`. | Accepted and fixed with a transaction-scoped client type. | `d83eeb9`, compile-time transaction double and repository tests. | +| M-21 | Personal bootstrap chose unstable first matches and display-name markers. | Accepted and fixed. Selection now finds the unique personal organization and deterministically chooses the earliest active workspace/internal project while preserving renamed display values. | `222910a`, multi-organization and rename tests. | +| M-22 | MFA compare-and-set did not enforce the revision in the update predicate. | Accepted; already fixed on later `dev`. | `e668bd4`, stale-revision tests. | +| M-23 | Direct bootstrap save was not transactional. | Accepted; already fixed on later `dev`. | `868c573`, rollback test. | +| M-24 | Organization membership fallback selected an arbitrary workspace. | Accepted; already fixed on later `dev`. | `26ff403`, deterministic workspace selection test. | +| M-25 | Refresh fell back to the presented token when no active family token existed. | Accepted and fixed. Missing or multiple active tokens fail closed and revoke the family. | `cf47989`, missing-active-token test. | +| M-26 | Refresh ignored the session inactivity deadline. | Accepted and fixed. Session, refresh tokens, and access tokens expire atomically at the deadline. | `a5478ae`, inactivity-boundary test. | +| M-27 | MFA lifecycle timestamps were client-controlled. | Accepted and fixed. Enrollment, verification, and recovery timestamps now come from an injected server clock; forged timestamp fields are rejected. | `774d4d5`, application and HTTP tests. | +| M-28 | Response DTO `refreshToken` was marked write-only. | Accepted; duplicate of M-09. | `0483b4b`. | +| M-29 | Documented and machine-enforced commit-budget minimums disagreed. | Accepted; already fixed on later `dev`. | `2c12a91`, orchestration checker and docs. | + +## Release handling + +- PR #29 remains a historical promotion slice. Review fixes are applied to `dev` first, following the repository rule that feature/fix PRs target `dev` without CodeRabbit. +- Main is not considered releasable until every ordered promotion slice, including this fix branch, has landed and passed its one automatic CodeRabbit review. +- The rejected M-18 proposal does not mark IAM-012 complete; the traceability record remains `partial` until the planned step-up authorization slice is implemented and verified. diff --git a/docs/operations/foundation-aws-opentofu-2026-08-03.md b/docs/operations/foundation-aws-opentofu-2026-08-03.md new file mode 100644 index 00000000..0541dc10 --- /dev/null +++ b/docs/operations/foundation-aws-opentofu-2026-08-03.md @@ -0,0 +1,48 @@ +# FND-004 portable AWS validation evidence + +Observed at (UTC): 2026-08-03T08:43:33Z + +Task: `FND-004 — Close portable AWS foundation gaps` + +## Verified boundary + +- OpenTofu is pinned to `1.12.5`; the official container image is + `ghcr.io/opentofu/opentofu:1.12.5`. +- The alpha composition locks the signed `hashicorp/aws` provider at `6.0.0` + and initialization treats the lock file as read-only. +- Official formatting covers every module, environment file, and OpenTofu test. +- Backend-disabled initialization and configuration validation run with provider + data isolated outside the repository. +- A mocked plan exercises the Singapore alpha composition without AWS + credentials or provider API calls. It verifies that NAT, managed data, ECS + services, CloudFront, and GitHub deployment trust remain disabled by default. +- Static tests continue to verify private networking, encryption, recovery, + least-privilege OIDC scope, production image digests, destroy protection, and + the absence of credentials or state backends. + +## Commands and results + +Passed: + +- `node --test tools/repo-cli/test/aws-infrastructure.test.mjs` +- `corepack pnpm infra:check` +- `corepack pnpm infra:validate` +- OpenTofu `fmt -check -recursive` +- OpenTofu `init -backend=false -input=false -lockfile=readonly -no-color` +- OpenTofu `validate -no-color` +- OpenTofu `test -no-color`: one mocked plan passed + +The first provider download ended with `unexpected EOF`; a fresh isolated retry +installed the same locked, signed provider successfully. No source or lock drift +was accepted from that transient failure. + +## Safety and rollback + +No AWS credentials were loaded, no remote state backend was configured, and no +real plan or apply command ran. Provider caches were created under a guarded +temporary directory and removed after validation. + +Rollback is source-only: revert the version pin, provider lock, formatter, +runner, and plan-test commits together, then restore FND-004 to `implemented` in +the execution ledger. Reverting does not change any AWS resource because this +slice created none. diff --git a/docs/operations/foundation-local-infrastructure-2026-08-02.md b/docs/operations/foundation-local-infrastructure-2026-08-02.md index 90cf4adc..39d6d226 100644 --- a/docs/operations/foundation-local-infrastructure-2026-08-02.md +++ b/docs/operations/foundation-local-infrastructure-2026-08-02.md @@ -54,3 +54,37 @@ Environment-gated: Revert the lifecycle commit and retain the prior static Compose checks. No containers, named volumes, host files, or credentials are modified by the repository changes. + +## Live verification closure + +Observed at (UTC): 2026-08-03T08:21:34Z + +Environment: + +- Docker Engine `29.5.3` +- Docker Desktop `4.77.0` +- Docker Compose `5.1.4` +- Windows host with Linux containers + +The live run first reproduced and corrected four startup gaps: a nonexistent +MinIO client tag, a fixed 30-second image-pull timeout, CRLF bytes in the +Linux-mounted bootstrap script, and readiness that ignored a failed +`minio-init` completion job. The corrected lifecycle then passed: + +- `node --test tools/repo-cli/test/local-infrastructure.test.mjs` +- `node tools/repo-cli/src/local-services.mjs check` +- `node tools/repo-cli/src/local-services.mjs start --wait-seconds=600` +- health checks for PostgreSQL, Redis, MinIO, Mailpit, OpenTelemetry, and its + HTTP health companion +- successful `minio-init` completion with both private buckets created +- a PostgreSQL catalog query finding all 19 module-owned schemas +- `restart-check --wait-seconds=600` +- `persistence-check --wait-seconds=600`, including sentinel cleanup +- an intentional duplicate-port preflight that failed closed +- an intentional impossible disk threshold that failed closed +- `stop --wait-seconds=600`, followed by inspection of all four preserved named + volumes and the stopped containers + +FND-003 is verified. Rollback remains source-only: revert the focused lifecycle +commits. The validation left stopped containers and named volumes intact and +did not delete local development data. diff --git a/docs/plans/002-complete-execution-orchestration.md b/docs/plans/002-complete-execution-orchestration.md index 40b8dc2f..e4af716f 100644 --- a/docs/plans/002-complete-execution-orchestration.md +++ b/docs/plans/002-complete-execution-orchestration.md @@ -66,7 +66,7 @@ This plan was reconciled on 2026-08-02 from remote `dev` at `783a4710c0aa2a2808d Merged PRs 1–23 establish substantial engineering, IAM/AUD/BUA, IAE/DSM, JRA, and DSO code. PR 19 delivered the normal 73-commit foundation batch to `dev`; PR 20 promoted it to `main`; PRs 21–23 carried validated promotion-review fixes back through `dev`. Plans 010–050 must therefore start with evidence reconciliation, not blind reimplementation. Plans 060–500 remain unverified and must be treated as planned until their gates pass. -The active execution packet is `B01` in `004-luna-max-execution-plan.md`, starting with `FND-003` on `feat/foundation-identity-completion`. The packet preserves the requested 30–99 commit rule, targets about 70 commits, and carries the implementation forward without opening a documentation-only PR. +The active execution packet is `B01` in `004-luna-max-execution-plan.md`, continuing with `FND-005` after live verification closed `FND-003` and containerized OpenTofu verification closed `FND-004`. The packet preserves the requested 30–50 commit target and exceptional 79-commit ceiling, and carries implementation forward without opening a documentation-only PR. The hashes above are an audit anchor, not a branch lock. Every session must fetch and recompute live state; update the ledger checkpoint only as part of a committed task/PR handoff so session-local observations do not create meaningless dirty files. diff --git a/docs/plans/004-luna-max-execution-plan.md b/docs/plans/004-luna-max-execution-plan.md index 0bb9e8be..6d3e2693 100644 --- a/docs/plans/004-luna-max-execution-plan.md +++ b/docs/plans/004-luna-max-execution-plan.md @@ -64,7 +64,7 @@ Each batch may require multiple normal integration PR slices before its exit gat | Batch | Branch | Tasks | Dependencies | Commit budget | Exit gate | |---|---|---|---|---|---| -| `B01` | `feat/foundation-identity-reconciliation` | `FND-003..007`, all Plan 020 tasks | Verified `FND-001/002` | 30–50 target; exceptional ceiling 79 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | +| `B01` | `feat/foundation-identity-reconciliation` | `FND-005..007`, all Plan 020 tasks | Verified `FND-001..004` | 30–50 target; exceptional ceiling 79 | Foundation external gates recorded; IAM/AUD/BUA obligations reconciled and completed | | `B02` | `feat/artifacts-datasets-completion` | All Plan 030 tasks | `B01` | 30–50 target; exceptional ceiling 79 | Immutable artifact/evidence/dataset foundations verified | | `B03` | `feat/jobs-processing-completion` | All Plan 040 tasks | `B02` | 30–50 target; exceptional ceiling 79 | Signed typed jobs execute locally/cloud with approvals and durable recovery | | `B04` | `feat/devices-sync-completion` | All Plan 050 tasks | `B03` | 30–50 target; exceptional ceiling 79 | Desktop/Android sync, offline, conflict, transfer, and revocation gates pass | @@ -145,10 +145,10 @@ corepack pnpm orchestration:check corepack pnpm requirements:check ``` -Then resume `FND-003`: +Live Docker and containerized OpenTofu verification closed `FND-003` and `FND-004` on 2026-08-03. Resume `FND-005`: -1. Run the Docker-capable checks in `docs/operations/foundation-local-infrastructure-2026-08-02.md` when Docker Desktop/Compose v2 is available. -2. If Docker remains unavailable, preserve `FND-003` as incomplete, finish only credential-independent `FND-004..007` evidence, and record the external gate. Do not claim foundation verification. +1. Reconcile correlation propagation, allowlisted telemetry, and safe diagnostics across TypeScript, Kotlin, and Python against the merged implementation. +2. Preserve any hosted or platform-specific telemetry gap as explicit evidence; do not promote the task solely from static source presence. 3. Reconcile Plans 020–050 against merged code before implementing any missing behavior. For `B01`, complete Plan 020 only after the remaining foundation boundaries are explicit. 4. End every session with the handoff record from `003-luna-handoff-runbook.md`, including exact branch/HEAD, open PRs, checks, task/batch status, rollback points, and safest next command. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 9f334dfd..e1e404dc 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -54,7 +54,7 @@ "post-ga-planned", "blocked" ], - "nextTaskId": "FND-003", + "nextTaskId": "FND-005", "activeBatchId": "B01", "taskState": { "FND-001": { @@ -82,8 +82,8 @@ "note": "Room, WorkManager, Keystore, bilingual resources, generated contracts/tokens, backup/network policy, account isolation, and process-recreation evidence are complete. No product requirement status was promoted." }, "FND-003": { - "status": "in-progress", - "commit": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", + "status": "verified", + "commit": "b3d000086cd786c3cdf9c812f48cf96f0bd7f213", "evidence": [ "infrastructure/local/compose.yml", "infrastructure/local/README.md", @@ -92,18 +92,23 @@ "tools/repo-cli/test/local-infrastructure.test.mjs", "docs/operations/foundation-local-infrastructure-2026-08-02.md" ], - "note": "Static Compose/bootstrap/lifecycle checks, daemon-free config/preflight, bounded local diagnostics/log retention, AWS safety checks, telemetry redaction tests, and infrastructure path-aware CI are integrated and promoted. Live Docker health, port-collision, disk-pressure, and restart-persistence evidence remains pending because the Docker daemon was unavailable in the implementation environment." + "note": "Docker Engine 29.5.3 and Compose 5.1.4 live verification passed on 2026-08-03: all long-running services became healthy, MinIO initialization exited successfully, all 19 module schemas existed, restart and Redis persistence checks passed, collision and disk-pressure probes failed closed, and safe stop preserved containers and named volumes." }, "FND-004": { - "status": "implemented", - "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "status": "verified", + "commit": "c18c7b0a65b5bfae0c5991223bfbee527018cf52", "evidence": [ "infrastructure/aws/README.md", + "infrastructure/aws/.opentofu-version", "infrastructure/aws/environments/alpha/main.tf", + "infrastructure/aws/environments/alpha/.terraform.lock.hcl", + "infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl", + "tools/repo-cli/src/validate-aws-opentofu.mjs", "tools/repo-cli/src/check-aws-infrastructure.mjs", - "tools/repo-cli/test/aws-infrastructure.test.mjs" + "tools/repo-cli/test/aws-infrastructure.test.mjs", + "docs/operations/foundation-aws-opentofu-2026-08-03.md" ], - "note": "Portable AWS modules and static safety checks are promoted. Pinned OpenTofu fmt/init/validate and any reviewed plan/apply evidence remain external gates." + "note": "OpenTofu 1.12.5 official-container formatting, backend-disabled locked initialization, validation, and one credential-free mocked alpha plan passed on 2026-08-03. Static safety tests cover encryption, private networking, OIDC scope, recovery, destroy protection, and production image digests; no AWS credentials, remote state, real plan, or apply were used." }, "FND-005": { "status": "implemented", @@ -150,8 +155,6 @@ "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": [ - "FND-003", - "FND-004", "FND-005", "FND-006", "FND-007", diff --git a/infrastructure/aws/.opentofu-version b/infrastructure/aws/.opentofu-version new file mode 100644 index 00000000..e0a6b34f --- /dev/null +++ b/infrastructure/aws/.opentofu-version @@ -0,0 +1 @@ +1.12.5 diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index aa816b61..7255c9a0 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -20,6 +20,16 @@ repository. ## Validate without applying +The repository pins OpenTofu `1.12.5` in `.opentofu-version`. Use that exact +native CLI release, or the official +`ghcr.io/opentofu/opentofu:1.12.5` container when a host installation is not +available. Do not use a floating container tag for validation evidence. +The alpha composition commits `.terraform.lock.hcl`; initialization uses it +read-only so provider selections cannot drift during a validation run. +`pnpm infra:validate` also executes a mocked plan test for the safe alpha +defaults, exercising plan-time evaluation without AWS credentials or remote +side effects. + ```text pnpm infra:check cd infrastructure/aws/environments/alpha diff --git a/infrastructure/aws/environments/alpha/.terraform.lock.hcl b/infrastructure/aws/environments/alpha/.terraform.lock.hcl new file mode 100644 index 00000000..cf3388ee --- /dev/null +++ b/infrastructure/aws/environments/alpha/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.0.0" + constraints = "6.0.0" + hashes = [ + "h1:1/CeThA/HYnTU2Zm4PEZA4735jHfP7L6LHOUl3+yFwE=", + "h1:6q9f4g92JlbaNWWOcviq2ZXugvrpTs0BKVWvMe9AZks=", + "h1:B10Faphm0kK6BlKlBTkZmaSM/fDlFsuT4QiPwf9+6zA=", + "h1:C+a4crNE3xwGk8Zn0p/4iz2JfuKtiZKNfYBRx3cWI48=", + "h1:F7kc3XB2ssSExi7YIpkAvJsHHYypBXeNZ2LzKPX5ZLg=", + "h1:J4hBxGTSRJAu+jcIbmyBB53KeHTXCgPDNC8eJrdjTIE=", + "h1:Od4Gd1YhRPD3mAsW9JiMwC5tEmkLWbuGac9C8OwZXuY=", + "h1:YUwtc4UmeSvIZetaDrxkAHuGK2pENco7XhfWIiGm67E=", + "h1:idz7G1K9QxXJ+Cz+0tPoJz5ApAatmzH0ktnlKSJGulQ=", + "zh:44c81d55a1844333a50fb36dd51938201fbd4e8a3da71880c7df11bc0eaa251a", + "zh:4d206f13982f539704998c76c2083bdb95f63c9a4d3ac8b4d2d152c4d874efca", + "zh:5e5e6b4cf921abf55c69b7ed33450a98de8bed611082c4272e9bba81a965d81c", + "zh:5fe449164de2f3507bada48e94dc07e192e24b644ebdb431fcd4e09168cab46c", + "zh:66efb8c840cedc830dee28994040d84eb74c353b97e86a33874f22471ec21deb", + "zh:b1e93ddf1557c84ddddba2a67ef908de2ac75414af7d0cc7f9cef86401c36a71", + "zh:b850aa20bdc8d63dca39f7b3f6b313649a28a78f66a9f79ef24f2a9b6c9b2247", + "zh:e58ace0225a8750d82f557bd54225898a784783dae6a378fcb7fffcf7b589315", + "zh:ffcadc0505dd7510f3fb14df5e4b33c2786df8039a902f0fcd2cdca81514b282", + ] +} diff --git a/infrastructure/aws/environments/alpha/main.tf b/infrastructure/aws/environments/alpha/main.tf index 21062cfa..567a3611 100644 --- a/infrastructure/aws/environments/alpha/main.tf +++ b/infrastructure/aws/environments/alpha/main.tf @@ -5,23 +5,23 @@ locals { } module "network" { - source = "../../modules/network" - name = var.name - region = var.aws_region - azs = var.availability_zones - vpc_cidr = var.vpc_cidr - enable_nat_gateway = var.enable_nat_gateway - tags = local.tags -} - -module "security" { - source = "../../modules/security" + source = "../../modules/network" name = var.name region = var.aws_region - github_repository = var.github_repository + azs = var.availability_zones + vpc_cidr = var.vpc_cidr + enable_nat_gateway = var.enable_nat_gateway tags = local.tags } +module "security" { + source = "../../modules/security" + name = var.name + region = var.aws_region + github_repository = var.github_repository + tags = local.tags +} + module "web" { source = "../../modules/web" name = var.name @@ -32,22 +32,22 @@ module "web" { } module "data" { - source = "../../modules/data" - name = var.name - environment = var.environment - private_subnet_ids = module.network.private_subnet_ids - database_security_group_id = module.network.database_security_group_id - cache_security_group_id = module.network.cache_security_group_id - kms_key_arn = module.security.kms_key_arn - enable_database = var.enable_database - database_instance_class = var.database_instance_class - backup_retention_period = var.backup_retention_period - deletion_protection = var.deletion_protection - database_multi_az = var.database_multi_az - redis_num_cache_clusters = var.redis_num_cache_clusters + source = "../../modules/data" + name = var.name + environment = var.environment + private_subnet_ids = module.network.private_subnet_ids + database_security_group_id = module.network.database_security_group_id + cache_security_group_id = module.network.cache_security_group_id + kms_key_arn = module.security.kms_key_arn + enable_database = var.enable_database + database_instance_class = var.database_instance_class + backup_retention_period = var.backup_retention_period + deletion_protection = var.deletion_protection + database_multi_az = var.database_multi_az + redis_num_cache_clusters = var.redis_num_cache_clusters redis_automatic_failover_enabled = var.redis_automatic_failover_enabled - redis_multi_az_enabled = var.redis_multi_az_enabled - tags = local.tags + redis_multi_az_enabled = var.redis_multi_az_enabled + tags = local.tags } module "compute" { diff --git a/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl b/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl new file mode 100644 index 00000000..42f9ee88 --- /dev/null +++ b/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl @@ -0,0 +1,41 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:root" + user_id = "123456789012" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/databreeze-mock" + id = "databreeze-mock" + } + } +} + +run "safe_alpha_defaults_plan" { + command = plan + + assert { + condition = output.region == "ap-southeast-1" + error_message = "The alpha plan must remain in the approved Singapore region." + } + + assert { + condition = !var.enable_nat_gateway && !var.enable_database && !var.enable_ecs_services + error_message = "The credential-free alpha plan must keep recurring-cost services disabled." + } + + assert { + condition = !var.enable_cloudfront && var.github_repository == "" + error_message = "The alpha plan must not create public distribution or deployment trust by default." + } +} diff --git a/infrastructure/aws/environments/alpha/variables.tf b/infrastructure/aws/environments/alpha/variables.tf index 370cf456..fd5ad55f 100644 --- a/infrastructure/aws/environments/alpha/variables.tf +++ b/infrastructure/aws/environments/alpha/variables.tf @@ -17,8 +17,8 @@ variable "name" { } variable "availability_zones" { - type = list(string) - default = ["ap-southeast-1a", "ap-southeast-1b"] + type = list(string) + default = ["ap-southeast-1a", "ap-southeast-1b"] } variable "vpc_cidr" { @@ -38,7 +38,7 @@ variable "enable_cloudfront" { } variable "enable_database" { - type = bool + type = bool description = "Create managed RDS/ElastiCache resources; disabled by default to prevent accidental recurring spend." default = false } diff --git a/infrastructure/aws/modules/compute/main.tf b/infrastructure/aws/modules/compute/main.tf index 7e9c14cb..a45e29f1 100644 --- a/infrastructure/aws/modules/compute/main.tf +++ b/infrastructure/aws/modules/compute/main.tf @@ -74,15 +74,15 @@ resource "aws_iam_role" "task" { locals { api_container = { - name = "api" - image = var.api_image - essential = true + name = "api" + image = var.api_image + essential = true readonlyRootFilesystem = true - privileged = false - user = "10001" - stopTimeout = 30 - cpu = var.api_cpu - memory = var.api_memory + privileged = false + user = "10001" + stopTimeout = 30 + cpu = var.api_cpu + memory = var.api_memory portMappings = [{ containerPort = 3000 hostPort = 3000 @@ -135,13 +135,13 @@ resource "aws_ecs_task_definition" "worker" { execution_role_arn = aws_iam_role.execution.arn task_role_arn = aws_iam_role.task.arn container_definitions = jsonencode([{ - name = "worker" - image = var.worker_image - essential = true + name = "worker" + image = var.worker_image + essential = true readonlyRootFilesystem = true - privileged = false - user = "10001" - stopTimeout = 30 + privileged = false + user = "10001" + stopTimeout = 30 logConfiguration = { logDriver = "awslogs" options = { diff --git a/infrastructure/aws/modules/compute/variables.tf b/infrastructure/aws/modules/compute/variables.tf index e54afd04..d3367240 100644 --- a/infrastructure/aws/modules/compute/variables.tf +++ b/infrastructure/aws/modules/compute/variables.tf @@ -41,13 +41,13 @@ variable "private_egress_enabled" { } variable "api_image" { - type = string - default = "ghcr.io/databreeze/api:dev" + type = string + default = "ghcr.io/databreeze/api:dev" } variable "worker_image" { - type = string - default = "ghcr.io/databreeze/worker:dev" + type = string + default = "ghcr.io/databreeze/worker:dev" } variable "enable_services" { diff --git a/infrastructure/aws/modules/data/main.tf b/infrastructure/aws/modules/data/main.tf index dc731da4..aec67aea 100644 --- a/infrastructure/aws/modules/data/main.tf +++ b/infrastructure/aws/modules/data/main.tf @@ -12,35 +12,35 @@ resource "aws_db_subnet_group" "this" { resource "aws_db_instance" "postgres" { count = var.enable_database ? 1 : 0 - identifier = "databreeze-${var.name}" - engine = "postgres" - engine_version = "17.5" - instance_class = var.database_instance_class - allocated_storage = 20 - max_allocated_storage = 100 - storage_type = "gp3" - storage_encrypted = true - kms_key_id = var.kms_key_arn - db_name = var.database_name - username = var.database_username - port = 5432 - manage_master_user_password = true - master_user_secret_kms_key_id = var.kms_key_arn - db_subnet_group_name = aws_db_subnet_group.this[0].name - vpc_security_group_ids = [var.database_security_group_id] - publicly_accessible = false - multi_az = var.database_multi_az - backup_retention_period = var.backup_retention_period - backup_window = "17:00-17:30" - maintenance_window = "sun:18:00-sun:18:30" - deletion_protection = var.deletion_protection - skip_final_snapshot = false - final_snapshot_identifier = "databreeze-${var.name}-final" - auto_minor_version_upgrade = true - copy_tags_to_snapshot = true + identifier = "databreeze-${var.name}" + engine = "postgres" + engine_version = "17.5" + instance_class = var.database_instance_class + allocated_storage = 20 + max_allocated_storage = 100 + storage_type = "gp3" + storage_encrypted = true + kms_key_id = var.kms_key_arn + db_name = var.database_name + username = var.database_username + port = 5432 + manage_master_user_password = true + master_user_secret_kms_key_id = var.kms_key_arn + db_subnet_group_name = aws_db_subnet_group.this[0].name + vpc_security_group_ids = [var.database_security_group_id] + publicly_accessible = false + multi_az = var.database_multi_az + backup_retention_period = var.backup_retention_period + backup_window = "17:00-17:30" + maintenance_window = "sun:18:00-sun:18:30" + deletion_protection = var.deletion_protection + skip_final_snapshot = false + final_snapshot_identifier = "databreeze-${var.name}-final" + auto_minor_version_upgrade = true + copy_tags_to_snapshot = true performance_insights_enabled = var.environment == "production" performance_insights_kms_key_id = var.environment == "production" ? var.kms_key_arn : null - tags = merge(local.common_tags, { Name = "databreeze-${var.name}" }) + tags = merge(local.common_tags, { Name = "databreeze-${var.name}" }) } resource "aws_elasticache_subnet_group" "this" { diff --git a/infrastructure/aws/modules/data/variables.tf b/infrastructure/aws/modules/data/variables.tf index 64411c18..520040a0 100644 --- a/infrastructure/aws/modules/data/variables.tf +++ b/infrastructure/aws/modules/data/variables.tf @@ -41,13 +41,13 @@ variable "database_instance_class" { } variable "database_name" { - type = string - default = "databreeze" + type = string + default = "databreeze" } variable "database_username" { - type = string - default = "databreeze" + type = string + default = "databreeze" } variable "backup_retention_period" { @@ -57,8 +57,8 @@ variable "backup_retention_period" { } variable "deletion_protection" { - type = bool - default = false + type = bool + default = false } variable "database_multi_az" { @@ -90,6 +90,6 @@ variable "redis_engine_version" { } variable "tags" { - type = map(string) - default = {} + type = map(string) + default = {} } diff --git a/infrastructure/aws/modules/security/main.tf b/infrastructure/aws/modules/security/main.tf index 93312fe6..620c2b09 100644 --- a/infrastructure/aws/modules/security/main.tf +++ b/infrastructure/aws/modules/security/main.tf @@ -22,7 +22,7 @@ data "aws_iam_policy_document" "platform_key" { actions = ["kms:Decrypt", "kms:DescribeKey", "kms:Encrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*"] resources = ["*"] principals { - type = "Service" + type = "Service" identifiers = [ "logs.${var.region}.amazonaws.com", "s3.amazonaws.com", @@ -37,7 +37,7 @@ data "aws_iam_policy_document" "platform_key" { condition { test = "StringLike" variable = "kms:ViaService" - values = [ + values = [ "logs.${var.region}.amazonaws.com", "s3.${var.region}.amazonaws.com", "secretsmanager.${var.region}.amazonaws.com" @@ -141,8 +141,8 @@ resource "aws_iam_role_policy" "github_deploy" { policy = jsonencode({ Version = "2012-10-17" Statement = [{ - Effect = "Allow" - Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"] + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"] Resource = [ "arn:aws:s3:::databreeze-${var.name}-web", "arn:aws:s3:::databreeze-${var.name}-web/*" diff --git a/infrastructure/local/.env.example b/infrastructure/local/.env.example index 4d0f8617..be0cb6dd 100644 --- a/infrastructure/local/.env.example +++ b/infrastructure/local/.env.example @@ -14,7 +14,7 @@ REDIS_IMAGE=docker.io/library/redis:7.4.5-alpine REDIS_PORT=6379 MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-06-13T11-33-47Z -MINIO_MC_IMAGE=quay.io/minio/mc:RELEASE.2025-06-13T11-33-47Z +MINIO_MC_IMAGE=quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z MINIO_ROOT_USER=databreeze MINIO_ROOT_PASSWORD=databreeze-local-change-me MINIO_API_PORT=9000 diff --git a/infrastructure/local/compose.yml b/infrastructure/local/compose.yml index 85fd97de..1eac8831 100644 --- a/infrastructure/local/compose.yml +++ b/infrastructure/local/compose.yml @@ -71,7 +71,7 @@ services: restart: unless-stopped minio-init: - image: ${MINIO_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-06-13T11-33-47Z} + image: ${MINIO_MC_IMAGE:-quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z} init: true depends_on: minio: diff --git a/infrastructure/local/minio/bootstrap-buckets.sh b/infrastructure/local/minio/bootstrap-buckets.sh index c0e649ef..9663adc0 100644 --- a/infrastructure/local/minio/bootstrap-buckets.sh +++ b/infrastructure/local/minio/bootstrap-buckets.sh @@ -1,4 +1,5 @@ #!/bin/sh +# Mounted into a Linux container; repository attributes keep this script LF-only. set -eu : "${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}" diff --git a/package.json b/package.json index fb21c49a..2cc9bbc7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "local:smoke": "node tools/repo-cli/src/local-services-smoke.mjs", "local:services": "node tools/repo-cli/src/local-services.mjs", "infra:check": "node tools/repo-cli/src/check-aws-infrastructure.mjs", + "infra:validate": "node tools/repo-cli/src/validate-aws-opentofu.mjs", "orchestration:check": "node tools/repo-cli/src/check-execution-orchestration.mjs", "repo:bootstrap": "corepack pnpm install --frozen-lockfile", "repo:build": "corepack pnpm build", diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index e15c8580..8210c396 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -304,15 +304,23 @@ export function verifyAuditChainV1( if (!event) return rejected('CHAIN_INVALID'); if (event.sequence !== index + 1 || event.previousDigest !== previousDigest) return rejected('CHAIN_INVALID'); - const { digest, ...withoutDigest } = event; - if (digestPort.digest(canonicalEvent(withoutDigest)) !== digest) - return rejected('CHAIN_INVALID'); - previousDigest = digest; + if (!verifyAuditEventDigestV1(event, digestPort).accepted) return rejected('CHAIN_INVALID'); + previousDigest = event.digest; } } return Object.freeze({ accepted: true, value: true }); } +/** Verify one immutable event when a bounded page does not contain the full scope chain. */ +export function verifyAuditEventDigestV1( + event: AuditEventV1, + digestPort: AuditDigestPortV1, +): AuditResultV1 { + const { digest, ...withoutDigest } = event; + if (digestPort.digest(canonicalEvent(withoutDigest)) !== digest) return rejected('CHAIN_INVALID'); + return Object.freeze({ accepted: true, value: true }); +} + export function createAuditSealV1( events: readonly AuditEventV1[], scopeInput: unknown, diff --git a/packages/domain/src/mfa/v1.ts b/packages/domain/src/mfa/v1.ts index c76bf302..876b6017 100644 --- a/packages/domain/src/mfa/v1.ts +++ b/packages/domain/src/mfa/v1.ts @@ -54,6 +54,7 @@ export type MfaErrorCodeV1 = | 'INVALID_STATE' | 'INVALID_REVISION' | 'FACTOR_NOT_ACTIVE' + | 'FACTOR_PROOF_INVALID' | 'RECOVERY_CODE_INVALID' | 'RECOVERY_CODE_USED' | 'STEP_UP_REQUIRED'; diff --git a/packages/domain/test/audit-v1.test.mjs b/packages/domain/test/audit-v1.test.mjs index 8de3423c..c4f53275 100644 --- a/packages/domain/test/audit-v1.test.mjs +++ b/packages/domain/test/audit-v1.test.mjs @@ -7,6 +7,7 @@ import { createAuditSealV1, sanitizeAuditSummaryV1, verifyAuditChainV1, + verifyAuditEventDigestV1, } from '../dist/audit/v1.js'; const id = (tail) => `00000000-0000-4000-8000-${tail.padStart(12, '0')}`; @@ -94,3 +95,20 @@ test('[AUD-015, AUD-016] seal contains an independently verifiable scoped root', code: 'CHAIN_INVALID', }); }); + +test('[AUD-001] an independently paged event retains verifiable content integrity', () => { + const appended = appendAuditEventV1({ events: [] }, input('30', 'invite-30'), digestPort); + assert.equal(appended.accepted, true); + if (!appended.accepted) return; + assert.deepEqual(verifyAuditEventDigestV1(appended.value.event, digestPort), { + accepted: true, + value: true, + }); + assert.deepEqual( + verifyAuditEventDigestV1( + { ...appended.value.event, summary: { outcome: 'tampered' } }, + digestPort, + ), + { accepted: false, code: 'CHAIN_INVALID' }, + ); +}); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index d41c6328..a5706833 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6747,6 +6747,18 @@ "get": { "operationId": "AuditController.events", "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -6828,6 +6840,18 @@ "get": { "operationId": "AuditController.seals", "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -7353,12 +7377,7 @@ "properties": { "sessionId": { "type": "string", "format": "uuid" }, "accessToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, - "refreshToken": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "writeOnly": true - }, + "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, "accessExpiresAt": { "type": "string", "format": "date-time" } }, "required": ["sessionId", "accessToken", "accessExpiresAt"] @@ -7423,24 +7442,23 @@ "properties": { "id": { "type": "string", "format": "uuid" }, "method": { "type": "string", "enum": ["TOTP", "WEBAUTHN"] }, - "secretReference": { "type": "string", "maxLength": 512, "writeOnly": true }, - "enrolledAt": { "type": "string", "format": "date-time" }, - "revision": { "type": "number", "minimum": 1 } + "secretReference": { "type": "string", "maxLength": 512, "writeOnly": true } }, - "required": ["id", "method", "secretReference", "enrolledAt"] + "required": ["id", "method", "secretReference"] }, "VerifyMfaFactorDto": { "type": "object", - "properties": { "at": { "type": "string", "format": "date-time" } }, - "required": ["at"] + "properties": { + "proof": { "type": "string", "minLength": 1, "maxLength": 4096, "writeOnly": true } + }, + "required": ["proof"] }, "RedeemMfaRecoveryCodeDto": { "type": "object", "properties": { - "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true }, - "at": { "type": "string", "format": "date-time" } + "presentedDigest": { "type": "string", "maxLength": 256, "writeOnly": true } }, - "required": ["presentedDigest", "at"] + "required": ["presentedDigest"] }, "CreateInboxItemDto": { "type": "object", diff --git a/services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql b/services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql new file mode 100644 index 00000000..2eaf0e2c --- /dev/null +++ b/services/api/prisma/migrations/20260803020000_bua_project_usage_scope/migration.sql @@ -0,0 +1,14 @@ +-- BUA-008/IAM-009: preserve exact project ancestry for project-scoped quota usage. +ALTER TABLE "bua"."usage_ledger_entries" + ADD COLUMN "project_id" UUID; + +ALTER TABLE "bua"."usage_reservations" + ADD COLUMN "project_id" UUID; + +DROP INDEX "bua"."usage_ledger_scope_idx"; +CREATE INDEX "usage_ledger_scope_idx" + ON "bua"."usage_ledger_entries"("organization_id", "workspace_id", "project_id", "metric", "sequence"); + +DROP INDEX "bua"."usage_reservations_scope_idx"; +CREATE INDEX "usage_reservations_scope_idx" + ON "bua"."usage_reservations"("organization_id", "workspace_id", "project_id", "status"); diff --git a/services/api/prisma/schema/bua.prisma b/services/api/prisma/schema/bua.prisma index 97d52e68..75d97cdc 100644 --- a/services/api/prisma/schema/bua.prisma +++ b/services/api/prisma/schema/bua.prisma @@ -45,6 +45,7 @@ model UsageLedgerEntryRecord { scopeType String @map("scope_type") @db.VarChar(24) organizationId String @map("organization_id") @db.Uuid workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid metric String @db.VarChar(40) bucket String @db.VarChar(16) deltaUnits BigInt @map("delta_units") @@ -56,7 +57,7 @@ model UsageLedgerEntryRecord { @@unique([scopeKey, metric, sequence], map: "usage_ledger_scope_metric_sequence_key") @@unique([scopeKey, idempotencyKey], map: "usage_ledger_scope_idempotency_key") - @@index([organizationId, workspaceId, metric, sequence], map: "usage_ledger_scope_idx") + @@index([organizationId, workspaceId, projectId, metric, sequence], map: "usage_ledger_scope_idx") @@index([reservationId], map: "usage_ledger_reservation_idx") @@map("usage_ledger_entries") @@schema("bua") @@ -68,6 +69,7 @@ model UsageReservationRecord { scopeType String @map("scope_type") @db.VarChar(24) organizationId String @map("organization_id") @db.Uuid workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid metric String @db.VarChar(40) reservedUnits BigInt @map("reserved_units") status String @db.VarChar(16) @@ -75,7 +77,7 @@ model UsageReservationRecord { revision Int updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) - @@index([organizationId, workspaceId, status], map: "usage_reservations_scope_idx") + @@index([organizationId, workspaceId, projectId, status], map: "usage_reservations_scope_idx") @@index([scopeKey, metric], map: "usage_reservations_metric_idx") @@map("usage_reservations") @@schema("bua") diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index d3b56a14..e07c3da8 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -9,6 +9,7 @@ import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; import { SaModule, type SaModuleOptions } from './features/sa/sa.module.js'; import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; +import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js'; export type AppModuleOptions = SystemModuleOptions & IamModuleOptions & @@ -22,7 +23,11 @@ export type AppModuleOptions = SystemModuleOptions & @Module({}) export class AppModule { static register(options: AppModuleOptions = {}): DynamicModule { - const sessions = options.sessions; + const sessions = + options.sessions ?? + (options.sessionDatabase === undefined + ? undefined + : new PrismaSessionLifecycleAdapter(options.sessionDatabase)); const requestTenantContext = options.requestTenantContext ?? (typeof sessions?.findPrincipalByAccessToken === 'function' @@ -30,8 +35,11 @@ export class AppModule { findPrincipalByAccessToken: sessions.findPrincipalByAccessToken.bind(sessions), }) : undefined); - const composedOptions = - requestTenantContext === undefined ? options : { ...options, requestTenantContext }; + const composedOptions = { + ...options, + ...(sessions === undefined ? {} : { sessions }), + ...(requestTenantContext === undefined ? {} : { requestTenantContext }), + }; return { module: AppModule, imports: [ 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 ed1584fa..94533370 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 @@ -6,9 +6,15 @@ import { } from '@databreeze/domain/v1'; import type { + AuditPageInputV1, + AuditPageV1, AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { + createAuditPageCursorV1, + parseAuditPageCursorV1, +} from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -33,6 +39,19 @@ 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(); @@ -76,6 +95,34 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { .map(cloneEvent); } + async listEventPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise> { + await Promise.resolve(); + const offset = pageOffset(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), + ); + const items = visible.slice(offset, offset + input.limit).map(cloneEvent); + return Object.freeze({ + items: Object.freeze(items), + ...(visible.length > offset + items.length + ? { + nextCursor: createAuditPageCursorV1( + 'events', + context.tenantScope, + offset + items.length, + ), + } + : {}), + }); + } + async listEventsForScope( context: IamTenantContextV1, scope: TenantScopeV1, @@ -115,6 +162,34 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { .map(cloneSeal); } + async listSealPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise> { + await Promise.resolve(); + const offset = pageOffset(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), + ); + const items = visible.slice(offset, offset + input.limit).map(cloneSeal); + return Object.freeze({ + items: Object.freeze(items), + ...(visible.length > offset + items.length + ? { + nextCursor: createAuditPageCursorV1( + 'seals', + context.tenantScope, + offset + items.length, + ), + } + : {}), + }); + } + async withTransaction( context: IamTenantContextV1, work: (transaction: AuditTransactionPortV1) => Promise, 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 5a717d10..2a2834ee 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 @@ -2,6 +2,7 @@ import { AUDIT_ACTIONS_V1, sanitizeAuditSummaryV1, verifyAuditChainV1, + verifyAuditEventDigestV1, type AuditActorTypeV1, type AuditEventV1, type AuditSealV1, @@ -18,9 +19,15 @@ import { randomUUID } from 'node:crypto'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { + AuditPageInputV1, + AuditPageV1, AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; +import { + createAuditPageCursorV1, + parseAuditPageCursorV1, +} from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; export interface AuditEventDatabaseRowV1 { @@ -79,7 +86,11 @@ interface AuditEventDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; - readonly orderBy: { readonly sequence: 'asc' | 'desc' }; + readonly orderBy: + | Readonly> + | readonly Readonly>[]; + readonly skip?: number; + readonly take?: number; }): Promise; } @@ -90,7 +101,11 @@ interface AuditSealDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; - readonly orderBy: { readonly lastSequence: 'asc' | 'desc' }; + readonly orderBy: + | Readonly> + | readonly Readonly>[]; + readonly skip?: number; + readonly take?: number; }): Promise; } @@ -269,6 +284,37 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); } +function visibilityWhere(scope: TenantScopeV1): Readonly> { + if (scope.scopeType === 'organization') return { organizationId: scope.organizationId }; + if (scope.scopeType === 'workspace') { + return { + organizationId: scope.organizationId, + OR: [{ scopeType: 'organization' }, { workspaceId: scope.workspaceId }], + }; + } + return { + organizationId: scope.organizationId, + OR: [ + { scopeType: 'organization' }, + { scopeType: 'workspace', workspaceId: scope.workspaceId }, + { scopeType: 'project', projectId: scope.projectId }, + ], + }; +} + +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, @@ -392,10 +438,67 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { ); } + public async listEventPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise> { + const offset = pageOffset(input, 'events', context.tenantScope); + const rows = await this.client.auditEventRecord.findMany({ + where: visibilityWhere(context.tenantScope), + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + skip: offset, + take: input.limit + 1, + }); + const visibleRows = rows.filter((row) => visible(context.tenantScope, persistedScope(row))); + const pageRows = visibleRows.slice(0, input.limit); + const items = pageRows.map(persistedEvent); + if (items.some((event) => !verifyAuditEventDigestV1(event, this.digestPort).accepted)) + throw new Error('AUD_CHAIN_INVALID'); + return Object.freeze({ + items: Object.freeze(items), + ...(visibleRows.length > pageRows.length + ? { + nextCursor: createAuditPageCursorV1( + 'events', + context.tenantScope, + offset + pageRows.length, + ), + } + : {}), + }); + } + public listEvents(context: IamTenantContextV1): Promise { return new PrismaAuditTransactionAdapter(this.client, this.digestPort).listEvents(context); } + public async listSealPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise> { + const offset = pageOffset(input, 'seals', context.tenantScope); + const rows = await this.client.auditSealRecord.findMany({ + where: visibilityWhere(context.tenantScope), + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + skip: offset, + take: input.limit + 1, + }); + const visibleRows = rows.filter((row) => visible(context.tenantScope, persistedScope(row))); + const pageRows = visibleRows.slice(0, input.limit); + return Object.freeze({ + items: Object.freeze(pageRows.map(persistedSeal)), + ...(visibleRows.length > pageRows.length + ? { + nextCursor: createAuditPageCursorV1( + 'seals', + context.tenantScope, + offset + pageRows.length, + ), + } + : {}), + }); + } + public listEventsForScope( context: IamTenantContextV1, scope: TenantScopeV1, diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 668709c8..96cc053d 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Inject, Req } from '@nestjs/common'; +import { Controller, Get, Inject, Query, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, + ApiQuery, ApiServiceUnavailableResponse, ApiTags, } from '@nestjs/swagger'; @@ -16,6 +17,16 @@ 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 { 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) { + throw new InputValidationException([{ field: 'limit', code: 'INVALID_PAGE_LIMIT' }]); + } + return value; +} @ApiTags('audit') @ApiBearerAuth() @@ -29,11 +40,26 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) @ApiOkResponse() + @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.' }) - async events(@Req() request: unknown): Promise { + async events( + @Req() request: unknown, + @Query('limit') limitInput?: string, + @Query('cursor') cursor?: string, + ): Promise { const context = await this.requestContext.resolve(request); + const limit = pageLimit(limitInput); + if ( + cursor !== undefined && + !parseAuditPageCursorV1(cursor, 'events', context.tenantScope).accepted + ) + throw new InputValidationException([{ field: 'cursor', code: 'INVALID_CURSOR' }]); try { - return await this.repository.listEvents(context); + return await this.repository.listEventPage(context, { + limit, + ...(cursor === undefined ? {} : { cursor }), + }); } catch { throw new AuditProblemError('AUDIT_UNAVAILABLE'); } @@ -42,11 +68,26 @@ export class AuditController { @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) @ApiOkResponse() + @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.' }) - async seals(@Req() request: unknown): Promise { + async seals( + @Req() request: unknown, + @Query('limit') limitInput?: string, + @Query('cursor') cursor?: string, + ): Promise { const context = await this.requestContext.resolve(request); + const limit = pageLimit(limitInput); + if ( + cursor !== undefined && + !parseAuditPageCursorV1(cursor, 'seals', context.tenantScope).accepted + ) + throw new InputValidationException([{ field: 'cursor', code: 'INVALID_CURSOR' }]); try { - return await this.repository.listSeals(context); + return await this.repository.listSealPage(context, { + limit, + ...(cursor === undefined ? {} : { cursor }), + }); } catch { 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 new file mode 100644 index 00000000..271a6127 --- /dev/null +++ b/services/api/src/features/aud/application/audit-page-cursor.ts @@ -0,0 +1,64 @@ +import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +export type AuditPageKindV1 = 'events' | 'seals'; + +export type AuditPageCursorResultV1 = + | { readonly accepted: true; readonly offset: number } + | { readonly accepted: false; readonly code: 'INVALID_CURSOR' }; + +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' }); +} + +export function createAuditPageCursorV1( + kind: AuditPageKindV1, + scope: TenantScopeV1, + offset: number, +): 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 }), + 'utf8', + ).toString('base64url'); +} + +export function parseAuditPageCursorV1( + cursor: unknown, + kind: AuditPageKindV1, + scope: TenantScopeV1, +): AuditPageCursorResultV1 { + if ( + typeof cursor !== 'string' || + cursor.length === 0 || + cursor.length > MAX_CURSOR_LENGTH_V1 || + !/^[A-Za-z0-9_-]+$/u.test(cursor) + ) + return rejected(); + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const parsed = JSON.parse(decoded) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return rejected(); + const record = parsed as Record; + if ( + Object.keys(record).sort().join(',') !== 'kind,offset,scope,version' || + record['version'] !== 1 || + record['kind'] !== kind || + record['scope'] !== scopeKey(scope) || + !Number.isSafeInteger(record['offset']) || + (record['offset'] as number) < 0 + ) + return rejected(); + return Object.freeze({ accepted: true, offset: record['offset'] as number }); + } catch { + return rejected(); + } +} 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 b513ed64..ac4650f3 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -5,6 +5,16 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js export const AUDIT_REPOSITORY_PORT = Symbol('AUDIT_REPOSITORY_PORT'); +export interface AuditPageInputV1 { + readonly limit: number; + readonly cursor?: string; +} + +export interface AuditPageV1 { + readonly items: readonly TItem[]; + readonly nextCursor?: string; +} + export interface AuditTransactionPortV1 { appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise; listEvents(context: IamTenantContextV1): Promise; @@ -17,6 +27,14 @@ export interface AuditTransactionPortV1 { } export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { + listEventPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise>; + listSealPage( + context: IamTenantContextV1, + input: AuditPageInputV1, + ): Promise>; withTransaction( context: IamTenantContextV1, work: (transaction: AuditTransactionPortV1) => Promise, 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 4fa88a19..e46a1882 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 @@ -77,6 +77,7 @@ export interface UsageLedgerEntryDatabaseRowV1 { readonly scopeType: string; readonly organizationId: string; readonly workspaceId: string | null; + readonly projectId: string | null; readonly metric: string; readonly bucket: string; readonly deltaUnits: bigint | number; @@ -93,6 +94,7 @@ export interface UsageReservationDatabaseRowV1 { readonly scopeType: string; readonly organizationId: string; readonly workspaceId: string | null; + readonly projectId: string | null; readonly metric: string; readonly reservedUnits: bigint | number; readonly status: string; @@ -182,6 +184,13 @@ function databaseScope(scope: TenantScopeV1) { } as const; } +function databaseUsageScope(scope: TenantScopeV1) { + return { + ...databaseScope(scope), + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + function scopeKey(scope: TenantScopeV1): string { if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; if (scope.scopeType === 'workspace') @@ -301,7 +310,7 @@ function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1 const reservationId = row.reservationId === null ? undefined : parseStableIdentifierV1(row.reservationId); const occurredAt = parseStrictUtcTimestampV1(row.occurredAt.toISOString()); - const scope = persistedScope({ ...row, projectId: null }); + const scope = persistedScope(row); if ( row.schemaVersion !== 1 || !entryId.accepted || @@ -332,7 +341,7 @@ function persistedEntry(row: UsageLedgerEntryDatabaseRowV1): UsageLedgerEntryV1 function persistedReservation(row: UsageReservationDatabaseRowV1): UsageReservationV1 { const reservationId = parseStableIdentifierV1(row.id); const occurredAt = parseStrictUtcTimestampV1(row.createdAt.toISOString()); - const scope = persistedScope({ ...row, projectId: null }); + const scope = persistedScope(row); if ( !reservationId.accepted || !occurredAt.accepted || @@ -401,7 +410,7 @@ function snapshotCreateData(snapshot: EntitlementSnapshotV1): EntitlementSnapsho function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV1 { return { - ...databaseScope(entry.tenantScope), + ...databaseUsageScope(entry.tenantScope), id: entry.entryId, schemaVersion: entry.schemaVersion, scopeKey: scopeKey(entry.tenantScope), @@ -418,7 +427,7 @@ function entryCreateData(entry: UsageLedgerEntryV1): UsageLedgerEntryCreateDataV function reservationCreateData(reservation: UsageReservationV1): UsageReservationCreateDataV1 { return { - ...databaseScope(reservation.tenantScope), + ...databaseUsageScope(reservation.tenantScope), id: reservation.reservationId, scopeKey: scopeKey(reservation.tenantScope), metric: reservation.metric, @@ -436,10 +445,12 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { if (scope.scopeType === 'organization') return undefined; - return Object.freeze([ + const inherited = [ `organization:${scope.organizationId}`, `workspace:${scope.organizationId}:${scope.workspaceId}`, - ]); + ]; + if (scope.scopeType === 'project') inherited.push(scopeKey(scope)); + return Object.freeze(inherited); } function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { @@ -559,16 +570,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV return Object.freeze({ entries: Object.freeze( entryRows - .filter((row) => - visible(context.tenantScope, persistedScope({ ...row, projectId: null })), - ) + .filter((row) => visible(context.tenantScope, persistedScope(row))) .map(persistedEntry), ), reservations: Object.freeze( reservationRows - .filter((row) => - visible(context.tenantScope, persistedScope({ ...row, projectId: null })), - ) + .filter((row) => visible(context.tenantScope, persistedScope(row))) .map(persistedReservation), ), }); @@ -670,6 +677,8 @@ export class PrismaEntitlementRepositoryAdapter implements EntitlementRepository } public persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise { - return new PrismaEntitlementTransactionAdapter(this.client).persistUsageState(context, state); + return this.client.$transaction((transaction) => + new PrismaEntitlementTransactionAdapter(transaction).persistUsageState(context, state), + ); } } 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 3c906e16..87a75667 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 @@ -44,10 +44,13 @@ interface IamMembershipDelegateV1 { }): Promise<{ readonly count: number }>; } -export interface IamDatabaseClientV1 { +export interface IamTransactionDatabaseClientV1 { readonly membershipIdentity: IamMembershipDelegateV1; +} + +export interface IamDatabaseClientV1 extends IamTransactionDatabaseClientV1 { $transaction( - work: (transaction: IamDatabaseClientV1) => Promise, + work: (transaction: IamTransactionDatabaseClientV1) => Promise, ): Promise; } @@ -95,6 +98,16 @@ function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecord return validated.value; } +function membershipFromRowOrSkip( + row: IamMembershipDatabaseRowV1, +): IamMembershipRecordV1 | undefined { + try { + return membershipFromRow(row); + } catch { + return undefined; + } +} + function membershipRow(membership: MembershipIdentityV1): IamMembershipDatabaseRowV1 { return { id: membership.id, @@ -124,7 +137,7 @@ function scopeSpecificity(scope: TenantScopeV1): number { } class PrismaIamTransactionAdapter implements IamTransactionPortV1 { - public constructor(private readonly client: IamDatabaseClientV1) {} + public constructor(private readonly client: IamTransactionDatabaseClientV1) {} public async findMembership( context: IamTenantContextV1, @@ -139,7 +152,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRow) + .map(membershipFromRowOrSkip) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter( (membership) => membership.principalId === principalId && @@ -161,7 +175,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRow) + .map(membershipFromRowOrSkip) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); } 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 350fdc55..86c1515d 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 @@ -1,13 +1,14 @@ import { - bootstrapPersonalOrganizationV1, createUserIdentityV1, - type MembershipIdentityV1, + validateMembershipV1, type PersonalOrganizationBootstrapV1, type UserIdentityV1, } from '@databreeze/domain/identity/v1'; import { parseStableIdentifierV1, parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; import type { @@ -114,17 +115,32 @@ export interface IdentityBootstrapDatabaseClientV1 { ): Promise; } -function stableId(input: unknown): string | undefined { +function stableId(input: unknown): StableIdentifierV1 | undefined { const parsed = parseStableIdentifierV1(input); return parsed.accepted ? parsed.value : undefined; } -function timestamp(input: Date | null | undefined): string | undefined { +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { if (!input) return undefined; const parsed = parseStrictUtcTimestampV1(input.toISOString()); return parsed.accepted ? parsed.value : undefined; } +function safeText(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function compareCreatedIdentity( + left: { readonly id: string; readonly createdAt: Date }, + right: { readonly id: string; readonly createdAt: Date }, +): number { + const time = left.createdAt.getTime() - right.createdAt.getTime(); + return time === 0 ? left.id.localeCompare(right.id) : time; +} + function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { const created = createUserIdentityV1({ id: row.id, @@ -138,54 +154,103 @@ function userFromRow(row: UserIdentityDatabaseRowV1): UserIdentityV1 { return created.value; } -function membershipMatches( - row: MembershipIdentityDatabaseRowV1, - expected: MembershipIdentityV1, -): boolean { - return ( - row.id === expected.id && - row.principalType === expected.principalType && - row.principalId === expected.principalId && - row.scopeType === 'ORGANIZATION' && - row.organizationId === expected.scope.organizationId && - row.workspaceId === null && - row.projectId === null && - row.roleId === expected.roleId && - row.status === expected.status && - row.revision === expected.revision && - row.startsAt === null && - row.expiresAt === null - ); -} - -function bootstrapRowsMatch( - bootstrap: PersonalOrganizationBootstrapV1, +function bootstrapFromRows( + user: UserIdentityV1, organization: OrganizationIdentityDatabaseRowV1, workspace: WorkspaceIdentityDatabaseRowV1, project: ProjectIdentityDatabaseRowV1, membership: MembershipIdentityDatabaseRowV1, -): boolean { - return ( - organization.id === bootstrap.organization.id && - organization.name === bootstrap.organization.name && - organization.personal === bootstrap.organization.personal && - organization.status === bootstrap.organization.status && - timestamp(organization.createdAt) === bootstrap.organization.createdAt && - workspace.id === bootstrap.workspace.id && - workspace.organizationId === bootstrap.workspace.organizationId && - workspace.name === bootstrap.workspace.name && - workspace.status === bootstrap.workspace.status && - workspace.authorizationEpoch === bootstrap.workspace.authorizationEpoch && - timestamp(workspace.createdAt) === bootstrap.workspace.createdAt && - project.id === bootstrap.project.id && - project.organizationId === bootstrap.project.organizationId && - project.workspaceId === bootstrap.project.workspaceId && - project.kind === bootstrap.project.kind && - project.name === bootstrap.project.name && - project.status === bootstrap.project.status && - timestamp(project.createdAt) === bootstrap.project.createdAt && - membershipMatches(membership, bootstrap.membership) - ); +): PersonalOrganizationBootstrapV1 { + const organizationId = stableId(organization.id); + const workspaceId = stableId(workspace.id); + const projectId = stableId(project.id); + const organizationName = safeText(organization.name, 200); + const workspaceName = safeText(workspace.name, 200); + const projectName = safeText(project.name, 200); + const organizationCreatedAt = timestamp(organization.createdAt); + const workspaceCreatedAt = timestamp(workspace.createdAt); + const projectCreatedAt = timestamp(project.createdAt); + if ( + !organizationId || + !organizationName || + !organizationCreatedAt || + !organization.personal || + organization.status !== 'ACTIVE' + ) + throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + if ( + !workspaceId || + !workspaceName || + !workspaceCreatedAt || + workspace.organizationId !== organizationId || + workspace.status !== 'ACTIVE' || + !Number.isSafeInteger(workspace.authorizationEpoch) || + workspace.authorizationEpoch < 1 + ) + throw new Error('IAM_PERSISTED_WORKSPACE_INVALID'); + if ( + !projectId || + !projectName || + !projectCreatedAt || + project.organizationId !== organizationId || + project.workspaceId !== workspaceId || + project.kind !== 'INTERNAL' || + project.status !== 'ACTIVE' + ) + throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + const parsedMembership = validateMembershipV1({ + id: membership.id, + principalType: membership.principalType, + principalId: membership.principalId, + scope: { scopeType: 'organization', organizationId: membership.organizationId }, + roleId: membership.roleId, + status: membership.status, + ...(membership.startsAt ? { startsAt: timestamp(membership.startsAt) } : {}), + ...(membership.expiresAt ? { expiresAt: timestamp(membership.expiresAt) } : {}), + revision: membership.revision, + }); + if ( + !parsedMembership.accepted || + membership.scopeType !== 'ORGANIZATION' || + membership.workspaceId !== null || + membership.projectId !== null || + parsedMembership.value.principalId !== user.id || + parsedMembership.value.scope.organizationId !== organizationId || + parsedMembership.value.roleId !== 'owner' || + parsedMembership.value.status !== 'ACTIVE' + ) + throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return Object.freeze({ + user, + organization: Object.freeze({ + schemaVersion: 1, + id: organizationId, + name: organizationName, + personal: true, + status: 'ACTIVE', + createdAt: organizationCreatedAt, + }), + workspace: Object.freeze({ + schemaVersion: 1, + id: workspaceId, + organizationId, + name: workspaceName, + status: 'ACTIVE', + authorizationEpoch: workspace.authorizationEpoch, + createdAt: workspaceCreatedAt, + }), + project: Object.freeze({ + schemaVersion: 1, + id: projectId, + organizationId, + workspaceId, + kind: 'INTERNAL', + name: projectName, + status: 'ACTIVE', + createdAt: projectCreatedAt, + }), + membership: parsedMembership.value, + }); } class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { @@ -198,53 +263,46 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran if (!userRow) return undefined; const user = userFromRow(userRow); const memberships = await this.client.membershipIdentity.findMany({ - where: { principalId: user.id, status: 'ACTIVE', scopeType: 'ORGANIZATION' }, - }); - const membershipRow = memberships.find( - (candidate) => - candidate.principalId === user.id && - candidate.scopeType === 'ORGANIZATION' && - candidate.workspaceId === null && - candidate.projectId === null && - candidate.roleId === 'owner', - ); - if (!membershipRow) return undefined; - const organizationId = stableId(membershipRow.organizationId); - if (!organizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); - const organization = await this.client.organizationIdentity.findUnique({ - where: { id: organizationId }, + where: { + principalId: user.id, + status: 'ACTIVE', + scopeType: 'ORGANIZATION', + roleId: 'owner', + workspaceId: null, + projectId: null, + }, }); - if (!organization || !organization.personal) - throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + 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 }, + }); + if (candidate?.personal) personalCandidates.push({ membership, organization: candidate }); + } + if (personalCandidates.length === 0) return undefined; + if (personalCandidates.length !== 1) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + const selected = personalCandidates[0]; + if (!selected) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + const { membership: membershipRow, organization } = selected; + const organizationId = organization.id; const workspaceRows = await this.client.workspaceIdentity.findMany({ where: { organizationId, status: 'ACTIVE' }, }); - const workspace = workspaceRows.find((candidate) => candidate.name === 'Personal workspace'); + const workspace = [...workspaceRows].sort(compareCreatedIdentity)[0]; if (!workspace) throw new Error('IAM_PERSISTED_WORKSPACE_INVALID'); const projectRows = await this.client.projectIdentity.findMany({ - where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE' }, + where: { organizationId, workspaceId: workspace.id, status: 'ACTIVE', kind: 'INTERNAL' }, }); - const project = projectRows.find((candidate) => candidate.kind === 'INTERNAL'); + const project = [...projectRows].sort(compareCreatedIdentity)[0]; if (!project) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); - const canonical = bootstrapPersonalOrganizationV1({ - user: { - id: user.id, - displayName: user.displayName, - locale: user.locale, - securityEpoch: user.securityEpoch, - status: user.status, - createdAt: user.createdAt, - }, - organizationId, - workspaceId: workspace.id, - projectId: project.id, - membershipId: membershipRow.id, - createdAt: organization.createdAt.toISOString(), - }); - if (!canonical.accepted) throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); - if (!bootstrapRowsMatch(canonical.value, organization, workspace, project, membershipRow)) - throw new Error('IAM_PERSISTED_BOOTSTRAP_INVALID'); - return canonical.value; + return bootstrapFromRows(user, organization, workspace, project, membershipRow); } public async save(bootstrap: PersonalOrganizationBootstrapV1): Promise { 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 468cc9e1..a3119754 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 @@ -107,6 +107,7 @@ interface RefreshTokenDelegateV1 { }): Promise; findMany(input: { readonly where: Readonly>; + readonly orderBy?: Readonly>; }): Promise; updateMany(input: { readonly where: Readonly>; @@ -237,6 +238,45 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { this.clock = options.clock ?? (() => new Date()); } + private async revokeRefreshFamily( + transaction: SessionLifecycleDatabaseClientV1, + sessionId: StableIdentifierV1, + familyId: StableIdentifierV1, + now: Date, + ): Promise { + await transaction.refreshTokenRecord.updateMany({ + where: { familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await transaction.sessionRecord.update({ + where: { id: sessionId }, + data: { status: 'REVOKED', revokedAt: now }, + }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt: now }, + }); + } + + private async expireSession( + transaction: SessionLifecycleDatabaseClientV1, + sessionId: StableIdentifierV1, + familyId: StableIdentifierV1, + ): Promise { + await transaction.refreshTokenRecord.updateMany({ + where: { familyId, status: 'ACTIVE' }, + data: { status: 'EXPIRED' }, + }); + await transaction.sessionRecord.update({ + where: { id: sessionId }, + data: { status: 'EXPIRED' }, + }); + await transaction.accessTokenRecord.updateMany({ + where: { sessionId, status: 'ACTIVE' }, + data: { status: 'EXPIRED' }, + }); + } + public async issue( principal: AuthenticatedPrincipalV1, clientPlatform: 'android' | 'desktop' | 'web', @@ -330,32 +370,35 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { }); if (!sessionRow) return { accepted: false, code: 'INVALID_REFRESH_TOKEN' }; const session = sessionFromRow(sessionRow); + if (session.status === 'REVOKED') return { accepted: false, code: 'REVOKED_FAMILY' }; + if (session.status === 'EXPIRED') return { accepted: false, code: 'EXPIRED' }; + if ( + now.getTime() >= Date.parse(session.inactivityExpiresAt) || + now.getTime() >= Date.parse(session.absoluteExpiresAt) + ) { + await this.expireSession(transaction, token.sessionId, token.familyId); + return { accepted: false, code: 'EXPIRED' }; + } const active = await transaction.refreshTokenRecord.findMany({ where: { sessionId: token.sessionId, familyId: token.familyId, status: 'ACTIVE' }, + orderBy: { issuedAt: 'desc' }, }); - const activeToken = active[0] ? tokenFromRow(active[0]) : undefined; + if (active.length !== 1 || !active[0]) { + await this.revokeRefreshFamily(transaction, token.sessionId, token.familyId, now); + return { accepted: false, code: 'REUSE_DETECTED' }; + } + const activeToken = tokenFromRow(active[0]); const rotated = rotateRefreshFamilyV1({ now: now.toISOString(), presentedTokenId: token.id, - activeTokenId: activeToken?.id ?? token.id, + activeTokenId: activeToken.id, nextTokenId: stableIdentifier(randomUUID()), familyStatus: session.status === 'ACTIVE' ? 'ACTIVE' : 'REVOKED', tokenExpiresAt: token.expiresAt, }); if (!rotated.accepted || !rotated.nextTokenId) { if (rotated.code === 'REUSE_DETECTED') { - await transaction.refreshTokenRecord.updateMany({ - where: { familyId: token.familyId, status: 'ACTIVE' }, - data: { status: 'REVOKED' }, - }); - await transaction.sessionRecord.update({ - where: { id: token.sessionId }, - data: { status: 'REVOKED', revokedAt: now }, - }); - await transaction.accessTokenRecord.updateMany({ - where: { sessionId: token.sessionId, status: 'ACTIVE' }, - data: { status: 'REVOKED', revokedAt: now }, - }); + await this.revokeRefreshFamily(transaction, token.sessionId, token.familyId, now); } else if (rotated.code === 'EXPIRED' && token.status === 'ACTIVE') { await transaction.refreshTokenRecord.updateMany({ where: { id: token.id, status: 'ACTIVE' }, diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index e1ae9111..04256dbf 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -59,7 +59,9 @@ export class MfaController { ): Promise { const mfa = this.requireService(); const context = await this.requestContext.resolve(request); - const result = await this.execute(() => mfa.verifyFactor(context.actorId, factorId, input.at)); + const result = await this.execute(() => + mfa.verifyFactor(context.actorId, factorId, input.proof), + ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; } @@ -75,7 +77,7 @@ export class MfaController { const mfa = this.requireService(); const context = await this.requestContext.resolve(request); const result = await this.execute(() => - mfa.redeemRecovery(context.actorId, input.presentedDigest, input.at), + mfa.redeemRecovery(context.actorId, input.presentedDigest), ); if (!result.accepted) throw new MfaProblemError('MFA_REQUEST_REJECTED'); return result.value; diff --git a/services/api/src/features/iam/api/mfa.dto.ts b/services/api/src/features/iam/api/mfa.dto.ts index db58a085..035deb0a 100644 --- a/services/api/src/features/iam/api/mfa.dto.ts +++ b/services/api/src/features/iam/api/mfa.dto.ts @@ -1,13 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { - IsIn, - IsISO8601, - IsOptional, - IsString, - IsUUID, - MaxLength, - MinLength, -} from 'class-validator'; +import { IsIn, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; export class EnrollMfaFactorDto { @ApiProperty({ format: 'uuid' }) @@ -23,20 +15,14 @@ export class EnrollMfaFactorDto { @MinLength(1) @MaxLength(512) secretReference!: string; - - @ApiProperty({ format: 'date-time' }) - @IsISO8601() - enrolledAt!: string; - - @ApiProperty({ minimum: 1, required: false }) - @IsOptional() - revision?: number; } export class VerifyMfaFactorDto { - @ApiProperty({ format: 'date-time' }) - @IsISO8601() - at!: string; + @ApiProperty({ minLength: 1, maxLength: 4096, writeOnly: true }) + @IsString() + @MinLength(1) + @MaxLength(4096) + proof!: string; } export class RedeemMfaRecoveryCodeDto { @@ -45,8 +31,4 @@ export class RedeemMfaRecoveryCodeDto { @MinLength(1) @MaxLength(256) presentedDigest!: string; - - @ApiProperty({ format: 'date-time' }) - @IsISO8601() - at!: string; } diff --git a/services/api/src/features/iam/api/session-refresh-response.dto.ts b/services/api/src/features/iam/api/session-refresh-response.dto.ts index a440e029..205c40b9 100644 --- a/services/api/src/features/iam/api/session-refresh-response.dto.ts +++ b/services/api/src/features/iam/api/session-refresh-response.dto.ts @@ -12,7 +12,7 @@ export class SessionRefreshResponseDto { @MaxLength(4096) accessToken!: string; - @ApiProperty({ minLength: 1, maxLength: 4096, required: false, writeOnly: true }) + @ApiProperty({ minLength: 1, maxLength: 4096, required: false }) @IsOptional() @IsString() @MinLength(1) diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index b7916b47..f9f5519c 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -15,6 +15,22 @@ import type { MfaRepositoryPortV1 } from './mfa-repository.port.js'; export const MFA_SERVICE = Symbol('MFA_SERVICE'); +export interface MfaFactorProofVerifierV1 { + verify(input: { + readonly userId: StableIdentifierV1; + readonly factorId: StableIdentifierV1; + readonly method: MfaStateV1['factors'][number]['method']; + readonly secretReference: string; + readonly proof: string; + }): Promise; +} + +export class UnavailableMfaFactorProofVerifier implements MfaFactorProofVerifierV1 { + public verify(): Promise { + return Promise.resolve(false); + } +} + function invalidState(): MfaResultV1 { return Object.freeze({ accepted: false, code: 'INVALID_STATE' }); } @@ -24,6 +40,12 @@ function stable(input: unknown): StableIdentifierV1 | undefined { return result.accepted ? result.value : undefined; } +function proof(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > 4_096) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + return input; +} + export interface MfaStateViewV1 { readonly factors: readonly Readonly< Pick< @@ -63,12 +85,17 @@ export class MfaService { private readonly recoveryMatcher: { matches(presentedDigest: string, storedDigest: string): boolean; }, + private readonly factorProofVerifier: MfaFactorProofVerifierV1 = new UnavailableMfaFactorProofVerifier(), + private readonly clock: () => Date = () => new Date(), ) {} - public async enroll( - input: Parameters[0], - ): Promise> { - const factor = createMfaFactorV1(input); + 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() }); 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); @@ -89,16 +116,28 @@ export class MfaService { public async verifyFactor( userIdInput: unknown, factorIdInput: unknown, - at: unknown, + proofInput: unknown, ): Promise> { const userId = stable(userIdInput); const factorId = stable(factorIdInput); if (!userId || !factorId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + const factorProof = proof(proofInput); + if (!factorProof) return Object.freeze({ accepted: false, code: 'FACTOR_PROOF_INVALID' }); return this.repository.withTransaction(async (transaction) => { const state = await transaction.findState(userId); const factor = state.factors.find((item) => item.id === factorId); if (!factor) return invalidState(); - const transitioned = transitionMfaFactorV1(factor, 'VERIFY', at); + if (factor.status !== 'PENDING') return invalidState(); + const verified = await this.factorProofVerifier.verify({ + userId, + factorId, + method: factor.method, + secretReference: factor.secretReference, + proof: factorProof, + }); + if (!verified) + return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const }); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString()); if (!transitioned.accepted) return Object.freeze({ accepted: false, code: transitioned.code }); const next = Object.freeze({ @@ -113,7 +152,6 @@ export class MfaService { public async redeemRecovery( userIdInput: unknown, presentedDigest: unknown, - at: unknown, ): Promise> { const userId = stable(userIdInput); if (!userId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); @@ -121,7 +159,7 @@ export class MfaService { const state = await transaction.findState(userId); const redeemed = redeemRecoveryCodeV1( state, - { userId, presentedDigest, at }, + { userId, presentedDigest, at: this.clock().toISOString() }, this.recoveryMatcher, ); if (!redeemed.accepted) return Object.freeze({ accepted: false, code: redeemed.code }); diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index fac825fc..8efcbc89 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -22,7 +22,12 @@ import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1, } from './application/mfa-repository.port.js'; -import { MFA_SERVICE, MfaService } from './application/mfa.service.js'; +import { + MFA_SERVICE, + MfaService, + UnavailableMfaFactorProofVerifier, + type MfaFactorProofVerifierV1, +} from './application/mfa.service.js'; import { IAM_REPOSITORY_PORT, type IamRepositoryPortV1, @@ -83,6 +88,8 @@ export interface IamModuleOptions { readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; + readonly mfaFactorProofVerifier?: MfaFactorProofVerifierV1; + readonly mfaClock?: () => Date; readonly recoveryCodeMatcher?: { matches(presentedDigest: string, storedDigest: string): boolean; }; @@ -150,6 +157,8 @@ export class IamModule { options.recoveryCodeMatcher ?? { matches: constantTimeRecoveryCodeMatchV1, }, + options.mfaFactorProofVerifier ?? new UnavailableMfaFactorProofVerifier(), + options.mfaClock, )); const iamRepository = options.iamRepository ?? diff --git a/services/api/src/platform/http/csrf-protection.ts b/services/api/src/platform/http/csrf-protection.ts index 54b6d38b..9786da39 100644 --- a/services/api/src/platform/http/csrf-protection.ts +++ b/services/api/src/platform/http/csrf-protection.ts @@ -80,7 +80,7 @@ function parseCookies(raw: string): { if ( name.length > MAX_COOKIE_NAME_LENGTH || value.length > MAX_COOKIE_VALUE_LENGTH || - !/^[A-Za-z0-9_]+$/u.test(name) || + !/^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/u.test(name) || value.includes('\r') || value.includes('\n') ) { diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 4fc088aa..a0852f39 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -18,6 +18,7 @@ export class RequestTenantContextProblemError extends Error { } type HeaderValueV1 = string | readonly string[] | undefined; +const SAFE_METHODS_V1 = new Set(['GET', 'HEAD', 'OPTIONS']); interface RequestLikeV1 { readonly id?: unknown; @@ -58,6 +59,9 @@ function correlationId(request: RequestLikeV1): string { function idempotencyKey(request: RequestLikeV1): string { const header = oneHeader(request, 'idempotency-key'); if (header !== undefined) return header; + if (typeof request.method !== 'string' || !SAFE_METHODS_V1.has(request.method.toUpperCase())) { + throw new RequestTenantContextProblemError('CONTEXT_INVALID'); + } if (typeof request.id === 'string' && request.id.length > 0) return request.id; return randomUUID(); } diff --git a/services/api/test/features/aud/audit-page-cursor.test.ts b/services/api/test/features/aud/audit-page-cursor.test.ts new file mode 100644 index 00000000..846a25c3 --- /dev/null +++ b/services/api/test/features/aud/audit-page-cursor.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseTenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + createAuditPageCursorV1, + parseAuditPageCursorV1, +} from '../../../src/features/aud/application/audit-page-cursor.js'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; +const workspaceId = '00000000-0000-4000-8000-000000000002'; +const parsedWorkspaceScope = parseTenantScopeV1({ + scopeType: 'workspace', + organizationId, + workspaceId, +}); +assert.equal(parsedWorkspaceScope.accepted, true); +if (!parsedWorkspaceScope.accepted) throw new Error('invalid workspace scope fixture'); +const workspaceScope = parsedWorkspaceScope.value; + +void test('[AUD-001, IAM-009] audit page cursors bind resource, tenant scope, and offset', () => { + const cursor = createAuditPageCursorV1('events', workspaceScope, 100); + assert.deepEqual(parseAuditPageCursorV1(cursor, 'events', workspaceScope), { + accepted: true, + offset: 100, + }); + assert.deepEqual(parseAuditPageCursorV1(cursor, 'seals', workspaceScope), { + accepted: false, + code: 'INVALID_CURSOR', + }); + const siblingScope = parseTenantScopeV1({ + scopeType: 'workspace', + organizationId, + workspaceId: '00000000-0000-4000-8000-000000000003', + }); + assert.equal(siblingScope.accepted, true); + if (!siblingScope.accepted) return; + assert.deepEqual(parseAuditPageCursorV1(cursor, 'events', siblingScope.value), { + accepted: false, + code: 'INVALID_CURSOR', + }); +}); + +void test('[AUD-001] audit page cursors fail closed for malformed or oversized values', () => { + for (const cursor of ['', 'not/base64', 'e30', 'a'.repeat(513)]) { + assert.deepEqual(parseAuditPageCursorV1(cursor, '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 273ebad2..9a6ac157 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -33,6 +33,18 @@ function delegate>( rows: TRow[], firstQueries: Array>>, ) { + const matches = (row: TRow, where: Readonly>): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + matches(row, candidate as Readonly>), + ); + } + return row[key] === value; + }); return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; @@ -47,9 +59,7 @@ function delegate>( readonly orderBy?: Readonly>; }) { firstQueries.push(where); - const matching = rows.filter((row) => - Object.entries(where).every(([key, value]) => row[key] === value), - ); + const matching = rows.filter((row) => matches(row, where)); const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; if (field) { matching.sort((left, right) => { @@ -63,23 +73,35 @@ function delegate>( findMany({ where, orderBy, + skip = 0, + take, }: { readonly where: Readonly>; - readonly orderBy: Readonly>; + readonly orderBy: + | Readonly> + | readonly Readonly>[]; + readonly skip?: number; + readonly take?: number; }) { - const filtered = rows.filter((row) => - Object.entries(where).every(([key, value]) => row[key] === value), - ); - const [field, direction] = Object.entries(orderBy)[0] ?? []; + const filtered = rows.filter((row) => matches(row, where)); + const ordering: readonly Readonly>[] = Array.isArray(orderBy) + ? (orderBy as readonly Readonly>[]) + : [orderBy as Readonly>]; return Promise.resolve( - [...filtered].sort((left, right) => { - if (!field) return 0; - const leftValue = left[field]; - const rightValue = right[field]; - if (leftValue === rightValue) return 0; - const comparison = leftValue! < rightValue! ? -1 : 1; - return direction === 'desc' ? -comparison : comparison; - }), + [...filtered] + .sort((left, right) => { + for (const order of ordering) { + const [field, direction] = Object.entries(order)[0] ?? []; + if (!field) continue; + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) continue; + const comparison = leftValue! < rightValue! ? -1 : 1; + return direction === 'desc' ? -comparison : comparison; + } + return 0; + }) + .slice(skip, take === undefined ? undefined : skip + take), ); }, }; @@ -140,6 +162,16 @@ void test('[AUD-001, AUD-003, AUD-008, IAM-009] Prisma audit adapter persists an input('00000000-0000-4000-8000-000000000122', 'job.completed'), ); assert.equal(second.accepted, true); + const firstPage = await repository.listEventPage(context(workspaceId, 'page-1'), { limit: 1 }); + assert.equal(firstPage.items.length, 1); + assert.ok(firstPage.nextCursor); + const secondPage = await repository.listEventPage(context(workspaceId, 'page-2'), { + limit: 1, + cursor: firstPage.nextCursor, + }); + assert.equal(secondPage.items.length, 1); + assert.equal(secondPage.nextCursor, undefined); + assert.notEqual(firstPage.items[0]?.eventId, secondPage.items[0]?.eventId); assert.equal((await repository.listEvents(context(workspaceId, 'read'))).length, 2); assert.equal((await repository.listEvents(context(siblingWorkspaceId, 'sibling'))).length, 0); assert.equal((await repository.listEvents(context(organizationId, 'organization'))).length, 0); @@ -154,6 +186,10 @@ void test('[AUD-015, AUD-018] Prisma audit adapter persists and reads immutable ); const sealed = await service.seal(context(workspaceId, 'seal-1'), '2026-01-01T00:01:00.000Z'); assert.equal(sealed.accepted, true); + assert.equal( + (await repository.listSealPage(context(workspaceId, 'seal-page'), { limit: 1 })).items.length, + 1, + ); assert.equal((await repository.listSeals(context(workspaceId, 'read'))).length, 1); assert.equal((await repository.listSeals(context(siblingWorkspaceId, 'sibling'))).length, 0); }); 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 7f689d58..520cea03 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -21,6 +21,7 @@ import { createIamTenantContextV1 } from '../../../src/features/iam/application/ const organizationId = '00000000-0000-4000-8000-000000000201'; const workspaceId = '00000000-0000-4000-8000-000000000202'; const siblingWorkspaceId = '00000000-0000-4000-8000-000000000203'; +const projectId = '00000000-0000-4000-8000-000000000204'; const actorId = '00000000-0000-4000-8000-000000000210'; const correlationId = '00000000-0000-4000-8000-000000000211'; @@ -44,6 +45,19 @@ function context(workspace = workspaceId, idempotencyKey = 'bua') { return result.value; } +function projectContext(idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, + actorId, + correlationId, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid project entitlement context'); + return result.value; +} + function plan(): EntitlementPlanV1 { const result = createPlanV1({ planCode: 'development', @@ -161,6 +175,7 @@ function client( options: { readonly forceRevisionConflict?: boolean; readonly firstQueries?: Array>>; + readonly transactionCalls?: { value: number }; } = {}, ): EntitlementDatabaseClientV1 { const planRows: Record[] = []; @@ -179,6 +194,7 @@ function client( async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { + if (options.transactionCalls) options.transactionCalls.value += 1; return work(database as unknown as EntitlementDatabaseClientV1); }, }; @@ -235,6 +251,46 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi ); }); +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()); + const service = new EntitlementAdmissionService(repository); + const input = admissionInput('project-admit', '1'); + const admitted = await service.admit(projectContext('project-admit'), { + ...input, + tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, + }); + assert.equal(admitted.accepted, true); + + const state = await repository.listUsageState(projectContext('project-read')); + assert.equal(state.entries.length, 1); + assert.equal(state.reservations.length, 1); + assert.deepEqual(state.entries[0]?.tenantScope, { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + }); + assert.deepEqual(state.reservations[0]?.tenantScope, { + scopeType: 'project', + organizationId, + workspaceId, + projectId, + }); +}); + +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 })); + + await repository.persistUsageState(context(workspaceId, 'transactional-usage'), { + entries: [], + reservations: [], + }); + + assert.equal(transactionCalls.value, 1); +}); + void test('[BUA-012] Prisma entitlement adapter applies reservation status revisions and preserves idempotent settlement', async () => { const repository = new PrismaEntitlementRepositoryAdapter(client()); await repository.saveSnapshot(context(workspaceId, 'seed-2'), snapshot()); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 6ea3f0bc..5c346ed7 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -148,6 +148,44 @@ void test('[IAM-009] a session access-token lookup composes one live tenant-cont assert.ok(provider.useValue instanceof SessionRequestTenantContextAdapter); }); +void test('[IAM-005, IAM-009] a configured session database composes the live tenant-context adapter', () => { + const registered = AppModule.register({ sessionDatabase: {} as never }); + const iam = registered.imports?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'module' in candidate && + candidate.module === IamModule, + ); + assert.ok(iam && typeof iam === 'object' && 'providers' in iam); + if (!iam || typeof iam !== 'object' || !('providers' in iam)) return; + const contextProvider = iam.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === REQUEST_TENANT_CONTEXT, + ); + const sessionProvider = iam.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === SESSION_LIFECYCLE_PORT, + ); + assert.ok(contextProvider && 'useValue' in contextProvider); + assert.ok(sessionProvider && 'useValue' in sessionProvider); + if ( + !contextProvider || + !('useValue' in contextProvider) || + !sessionProvider || + !('useValue' in sessionProvider) + ) + return; + assert.ok(contextProvider.useValue instanceof SessionRequestTenantContextAdapter); + assert.ok(sessionProvider.useValue instanceof PrismaSessionLifecycleAdapter); +}); + void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the Prisma adapter', () => { const database = {} as never; const registered = IamModule.register({ identityBootstrapDatabase: database }); diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 97e5213d..4e00f2f0 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -14,23 +14,35 @@ const at = '2026-01-01T00:00:00.000Z'; void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revisioned', async () => { const repository = new InMemoryMfaRepositoryAdapter(); - const service = new MfaService(repository, { - matches: (presented, stored) => presented === stored, - }); + const service = new MfaService( + repository, + { + matches: (presented, stored) => presented === stored, + }, + { + verify: ({ proof }) => Promise.resolve(proof === '654321'), + }, + () => new Date(at), + ); const enrolled = await service.enroll({ id: factorId, userId, method: 'TOTP', secretReference: 'secret-ref:totp:1', - enrolledAt: at, }); assert.equal(enrolled.accepted, true); if (!enrolled.accepted) return; assert.equal(enrolled.value.factors[0]?.status, 'PENDING'); - const verified = await service.verifyFactor(userId, factorId, '2026-01-01T00:01:00.000Z'); + assert.equal(enrolled.value.factors[0]?.enrolledAt, at); + const invalidProof = await service.verifyFactor(userId, factorId, '000000'); + assert.deepEqual(invalidProof, { accepted: false, code: 'FACTOR_PROOF_INVALID' }); + const verified = await service.verifyFactor(userId, factorId, '654321'); assert.equal(verified.accepted, true); - if (verified.accepted) assert.equal(verified.value.factors[0]?.status, 'ACTIVE'); - const secondVerify = await service.verifyFactor(userId, factorId, '2026-01-01T00:02:00.000Z'); + if (verified.accepted) { + assert.equal(verified.value.factors[0]?.status, 'ACTIVE'); + assert.equal(verified.value.factors[0]?.verifiedAt, at); + } + const secondVerify = await service.verifyFactor(userId, factorId, '654321'); assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); }); @@ -40,15 +52,20 @@ void test('[IAM-015, IAM-016] recovery code redemption is one-time and does not assert.equal(code.accepted, true); if (!code.accepted) return; await repository.saveState(userId as never, { factors: [], recoveryCodes: [code.value] }); - const service = new MfaService(repository, { - matches: (presented, stored) => presented === stored, - }); - const redeemed = await service.redeemRecovery(userId, 'digest-1', '2026-01-01T00:01:00.000Z'); + const service = new MfaService( + repository, + { + matches: (presented, stored) => presented === stored, + }, + undefined, + () => new Date(at), + ); + const redeemed = await service.redeemRecovery(userId, 'digest-1'); assert.equal(redeemed.accepted, true); if (!redeemed.accepted) return; assert.equal(redeemed.value.recoveryCodesRemaining, 0); assert.equal('digest' in redeemed.value, false); - assert.deepEqual(await service.redeemRecovery(userId, 'digest-1', '2026-01-01T00:02:00.000Z'), { + assert.deepEqual(await service.redeemRecovery(userId, 'digest-1'), { accepted: false, code: 'RECOVERY_CODE_USED', }); 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 64527aeb..942780ff 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -11,6 +11,7 @@ import { import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1, + type IamTransactionDatabaseClientV1, type IamMembershipDatabaseRowV1, } from '../../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; @@ -111,10 +112,12 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { return { count: 1 }; }, }, - $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => { + $transaction: async ( + work: (transaction: IamTransactionDatabaseClientV1) => Promise, + ) => { const before = new Map(memberships); try { - return await work(client); + return await work({ membershipIdentity: client.membershipIdentity }); } catch (error) { memberships.clear(); for (const [key, value] of before) memberships.set(key, value); @@ -145,6 +148,24 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and ); }); +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', + }; + const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client); + + assert.deepEqual( + ( + await repository.listMemberships( + context({ scopeType: 'workspace', organizationId, workspaceId }), + ) + ).map((membership) => membership.id), + [valid.id], + ); +}); + void test('[IAM-003, IAM-014] Prisma membership authority chooses the narrowest containing scope', async () => { const projectScope = { scopeType: 'project', 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 6af14a07..f19500ee 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 @@ -186,6 +186,62 @@ void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy i ); }); +void test('[IAM-001, IAM-011] bootstrap lookup selects the personal organization among multiple ownerships', async () => { + const state = createDatabase(); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + const unrelatedOrganizationId = '00000000-0000-4000-8000-000000000006'; + const unrelatedMembershipId = '00000000-0000-4000-8000-000000000007'; + state.organizations.set(unrelatedOrganizationId, { + id: unrelatedOrganizationId, + name: 'Client organization', + personal: false, + status: 'ACTIVE', + createdAt, + }); + state.memberships.set(unrelatedMembershipId, { + id: unrelatedMembershipId, + principalType: 'USER', + principalId: userId, + scopeType: 'ORGANIZATION', + organizationId: unrelatedOrganizationId, + workspaceId: null, + projectId: null, + roleId: 'owner', + status: 'ACTIVE', + startsAt: null, + expiresAt: null, + revision: 1, + }); + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + await adapter.save(validated.value); + + assert.equal( + (await adapter.findByUserId(validated.value.user.id))?.organization.id, + organizationId, + ); +}); + +void test('[IAM-001, IAM-011] bootstrap lookup survives personal workspace and project renames', 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 workspace = state.workspaces.get(workspaceId); + const project = state.projects.get(projectId); + assert.ok(workspace); + assert.ok(project); + state.workspaces.set(workspaceId, { ...workspace, name: 'Finance workspace' }); + state.projects.set(projectId, { ...project, name: 'Monthly close' }); + + const loaded = await adapter.findByUserId(validated.value.user.id); + assert.equal(loaded?.workspace.name, 'Finance workspace'); + assert.equal(loaded?.project.name, 'Monthly close'); +}); + void test('[IAM-001] bootstrap transaction rollback does not retain a partially written hierarchy', async () => { const state = createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.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 42b5f762..b08dd7fc 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -194,6 +194,21 @@ void test('[IAM-005] refresh rotation is transactional and reuse revokes the com assert.equal(await adapter.findPrincipal(first.sessionId), undefined); }); +void test('[IAM-005] refresh fails closed when a family has no active token', async () => { + const { client, refreshTokens, sessions } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const issued = await adapter.issue(principal, 'desktop'); + for (const [id, row] of refreshTokens) refreshTokens.set(id, { ...row, status: 'USED' }); + + assert.deepEqual(await adapter.refresh(issued.refreshToken, 'desktop'), { + accepted: false, + code: 'REUSE_DETECTED', + }); + assert.equal(sessions.get(issued.sessionId)?.status, 'REVOKED'); +}); + void test('[IAM-005] expired refresh tokens fail closed without returning token material', async () => { let now = new Date('2026-01-01T00:00:00.000Z'); const { client } = createDatabase(); @@ -210,6 +225,22 @@ void test('[IAM-005] expired refresh tokens fail closed without returning token }); }); +void test('[IAM-005] refresh cannot restart an expired inactivity window', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const { client, sessions, refreshTokens, accessTokens } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { clock: () => new Date(now) }); + const session = await adapter.issue(principal, 'android'); + now = new Date('2026-01-01T01:00:00.000Z'); + + assert.deepEqual(await adapter.refresh(session.refreshToken, 'android'), { + accepted: false, + code: 'EXPIRED', + }); + assert.equal(sessions.get(session.sessionId)?.status, 'EXPIRED'); + assert.equal([...refreshTokens.values()][0]?.status, 'EXPIRED'); + assert.equal([...accessTokens.values()][0]?.status, 'EXPIRED'); +}); + void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => { const { client } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client); diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 91bd14cd..364c38cf 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -501,6 +501,7 @@ void test('sign-out revokes idempotently and clears browser credentials', async 'x-csrf-token': csrfToken, origin: 'http://localhost:3000', authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-web-001', }, payload: { clientPlatform: 'web', @@ -520,7 +521,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async const native = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', - headers: { authorization: 'Bearer sign-out-access-token' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-native-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', @@ -536,7 +540,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async const crossUser = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', - headers: { authorization: 'Bearer sign-out-access-token' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-cross-user-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000099', @@ -561,7 +568,10 @@ void test('sign-out revokes idempotently and clears browser credentials', async const response = await app.inject({ method: 'POST', url: '/v1/auth/sign-out', - headers: { authorization: 'Bearer sign-out-access-token' }, + headers: { + authorization: 'Bearer sign-out-access-token', + 'idempotency-key': 'sign-out-unavailable-001', + }, payload: { clientPlatform: 'android', sessionId: '00000000-0000-4000-8000-000000000011', @@ -623,7 +633,21 @@ void test('protected artifact reads derive tenant scope from an authenticated ac headers: { authorization: 'Bearer access-token-for-context-1' }, }); assert.equal(auditEvents.statusCode, 200); - assert.deepEqual(auditEvents.json(), []); + assert.deepEqual(auditEvents.json(), { items: [] }); + + const invalidAuditCursor = await app.inject({ + method: 'GET', + url: '/v1/audit/events?cursor=not-a-cursor', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assertProblem(invalidAuditCursor, 400, 'VALIDATION_FAILED'); + + const invalidAuditLimit = await app.inject({ + method: 'GET', + url: '/v1/audit/events?limit=101', + headers: { authorization: 'Bearer access-token-for-context-1' }, + }); + assertProblem(invalidAuditLimit, 400, 'VALIDATION_FAILED'); const auditSeals = await app.inject({ method: 'GET', @@ -631,7 +655,7 @@ void test('protected artifact reads derive tenant scope from an authenticated ac headers: { authorization: 'Bearer access-token-for-context-1' }, }); assert.equal(auditSeals.statusCode, 200); - assert.deepEqual(auditSeals.json(), []); + assert.deepEqual(auditSeals.json(), { items: [] }); const usage = await app.inject({ method: 'GET', @@ -680,8 +704,8 @@ void test('protected artifact reads derive tenant scope from an authenticated ac void test('audit read outages return retryable service-unavailable problems', async () => { const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { - listEvents: () => Promise.reject(new Error(`database ${leakedMarker}`)), - listSeals: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listEventPage: () => Promise.reject(new Error(`database ${leakedMarker}`)), + listSealPage: () => Promise.reject(new Error(`database ${leakedMarker}`)), }); const principal = { userId: '00000000-0000-4000-8000-000000000001', @@ -717,9 +741,16 @@ void test('audit read outages return retryable service-unavailable problems', as void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { const actorId = '00000000-0000-4000-8000-000000000001'; - const mfaService = new MfaService(new InMemoryMfaRepositoryAdapter(), { - matches: (presented, stored) => presented === stored, - }); + const mfaService = new MfaService( + new InMemoryMfaRepositoryAdapter(), + { + matches: (presented, stored) => presented === stored, + }, + { + verify: ({ proof }) => Promise.resolve(proof === '654321'), + }, + () => new Date('2026-01-01T00:00:00.000Z'), + ); const contextResult = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', @@ -735,6 +766,18 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con if (!contextResult.accepted) return; const requestTenantContext = { resolve: () => Promise.resolve(contextResult.value) }; await withApp({ mfaService, requestTenantContext }, async (app) => { + const forgedEnrollmentTime = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors', + payload: { + id: '00000000-0000-4000-8000-000000000010', + method: 'TOTP', + secretReference: 'vault://iam/mfa/test-factor', + enrolledAt: '2000-01-01T00:00:00.000Z', + }, + }); + assertProblem(forgedEnrollmentTime, 400, 'VALIDATION_FAILED'); + const enrolled = await app.inject({ method: 'POST', url: '/v1/auth/mfa/factors', @@ -742,7 +785,6 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con id: '00000000-0000-4000-8000-000000000010', method: 'TOTP', secretReference: 'vault://iam/mfa/test-factor', - enrolledAt: '2026-01-01T00:00:00.000Z', }, }); assert.equal(enrolled.statusCode, 200); @@ -755,7 +797,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con const verified = await app.inject({ method: 'POST', url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000010/verify', - payload: { at: '2026-01-01T00:01:00.000Z' }, + payload: { proof: '654321' }, }); assert.equal(verified.statusCode, 200); const verifiedBody = parsedBody<{ readonly factors: readonly [{ readonly status: string }] }>( @@ -766,7 +808,7 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con const invalid = await app.inject({ method: 'POST', url: '/v1/auth/mfa/factors/00000000-0000-4000-8000-000000000099/verify', - payload: { at: '2026-01-01T00:02:00.000Z' }, + payload: { proof: '654321' }, }); assertProblem(invalid, 400, 'MFA_REQUEST_REJECTED'); }); @@ -782,7 +824,6 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con id: '00000000-0000-4000-8000-000000000010', method: 'TOTP', secretReference: 'vault://iam/mfa/test-factor', - enrolledAt: '2026-01-01T00:00:00.000Z', }, }); assertProblem(response, 503, 'MFA_UNAVAILABLE'); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index cf1a55d7..93ddb713 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -21,6 +21,7 @@ interface ResponseLike { interface OperationLike { readonly parameters?: readonly ParameterLike[]; readonly responses: Record; + readonly security?: readonly Readonly>[]; } type PathItemLike = Partial>; @@ -178,6 +179,13 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.equal(documentedClientVersion.test('1.2.3'), true); assert.equal(documentedClientVersion.test('1.2.3-beta.1'), true); assert.equal(documentedClientVersion.test('1.2.3garbage'), false); + const refreshResponse = firstDocument.components?.schemas?.[ + 'SessionRefreshResponseDto' + ] as Record; + const refreshToken = (refreshResponse['properties'] as Record>)[ + 'refreshToken' + ]; + assert.equal(refreshToken?.['writeOnly'], undefined); for (const operation of operations(firstDocument)) { const headerNames = (operation.parameters ?? []) @@ -193,6 +201,29 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, } } + const publicOperations = new Set([ + 'GET /health/live', + 'GET /health/ready', + 'GET /v1/system/compatibility', + 'POST /v1/system/compatibility/check', + 'POST /v1/auth/sign-in', + 'POST /v1/auth/refresh', + ]); + for (const [path, pathItem] of Object.entries(firstDocument.paths) as Array< + [string, PathItemLike] + >) { + for (const method of httpMethods) { + const operation = pathItem[method]; + if (operation === undefined) continue; + const key = `${method.toUpperCase()} ${path}`; + if (publicOperations.has(key)) { + assert.equal(operation.security, undefined, `${key} must remain explicitly public`); + } else { + assert.deepEqual(operation.security, [{ bearer: [] }], `${key} must require bearer auth`); + } + } + } + for (const path of ['/v1/audit/events', '/v1/audit/seals'] as const) { const auditRead = firstDocument.paths[path]?.get as OperationLike | undefined; assert.ok(auditRead?.responses['200'], `${path} must document its successful response`); diff --git a/services/api/test/platform/http/csrf-protection.test.ts b/services/api/test/platform/http/csrf-protection.test.ts index 54c483bb..e5647737 100644 --- a/services/api/test/platform/http/csrf-protection.test.ts +++ b/services/api/test/platform/http/csrf-protection.test.ts @@ -83,6 +83,23 @@ void test('requires a valid double-submit token for cookie-authenticated mutatio ); }); +void test('accepts standard token characters in unrelated cookie names', () => { + assert.deepEqual( + evaluateCsrfRequestV1( + { + method: 'POST', + headers: { + cookie: `analytics-id=value; preference.v1=value; databreeze_refresh=session-value; databreeze_csrf=${token}`, + origin: 'https://app.databreeze.example', + 'x-csrf-token': token, + }, + }, + { allowedOrigins }, + ), + { accepted: true }, + ); +}); + void test('rejects hostile, ambiguous, or missing browser origin signals', () => { const headers = { cookie: `databreeze_refresh=session-value; databreeze_csrf=${token}`, diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index cb8a6eaf..6e6cc77b 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -73,6 +73,25 @@ void test('uses the request id for read-only calls and rejects unsafe principal await assert.rejects( adapter.resolve({ id: 'request-read-001', + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, 'CONTEXT_INVALID'); + return true; + }, + ); +}); + +void test('requires an explicit idempotency key for authenticated mutations', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => Promise.resolve(principal), + }); + + await assert.rejects( + adapter.resolve({ + id: 'request-mutation-001', + method: 'POST', headers: { authorization: 'Bearer opaque-access-token-123456789' }, }), (error: unknown) => { diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc538be6..f71da3a0 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -123,6 +123,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802300000_sa_spreadsheet_audits', '20260803000000_iae_lineage_uniqueness', '20260803010000_iam_session_scope_binding', + '20260803020000_bua_project_usage_scope', 'migration_lock.toml', ]); const migration = await readFile( diff --git a/tools/repo-cli/src/check-aws-infrastructure.mjs b/tools/repo-cli/src/check-aws-infrastructure.mjs index 5cbf42f2..8d12bd54 100644 --- a/tools/repo-cli/src/check-aws-infrastructure.mjs +++ b/tools/repo-cli/src/check-aws-infrastructure.mjs @@ -9,7 +9,10 @@ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)) const infrastructureRoot = path.join(repositoryRoot, 'infrastructure', 'aws'); const requiredFiles = [ 'README.md', + '.opentofu-version', 'environments/alpha/main.tf', + 'environments/alpha/.terraform.lock.hcl', + 'environments/alpha/tests/alpha-plan.tofutest.hcl', 'environments/alpha/variables.tf', 'environments/alpha/versions.tf', 'modules/network/main.tf', @@ -28,6 +31,14 @@ for (const relativePath of requiredFiles) { if (!existsSync(path.join(infrastructureRoot, relativePath))) fail(`missing ${relativePath}`); } +const opentofuVersion = readFileSync( + path.join(infrastructureRoot, '.opentofu-version'), + 'utf8', +).trim(); +if (!/^\d+\.\d+\.\d+$/u.test(opentofuVersion)) { + fail('the OpenTofu version pin must be one exact semantic version'); +} + const allTerraform = requiredFiles .filter((relativePath) => relativePath.endsWith('.tf')) .map((relativePath) => readFileSync(path.join(infrastructureRoot, relativePath), 'utf8')) @@ -97,11 +108,15 @@ if (tofu.error?.code === 'ENOENT') { const tofuDataDirectory = mkdtempSync(path.join(os.tmpdir(), 'databreeze-tofu-')); const tofuEnvironment = { ...process.env, TF_DATA_DIR: tofuDataDirectory }; try { - const init = spawnSync('tofu', ['init', '-backend=false', '-input=false', '-no-color'], { - cwd: alphaDirectory, - env: tofuEnvironment, - encoding: 'utf8', - }); + const init = spawnSync( + 'tofu', + ['init', '-backend=false', '-input=false', '-lockfile=readonly', '-no-color'], + { + cwd: alphaDirectory, + env: tofuEnvironment, + encoding: 'utf8', + }, + ); if (init.status !== 0) { console.error(init.stdout || init.stderr); process.exitCode = init.status ?? 1; diff --git a/tools/repo-cli/src/local-services.mjs b/tools/repo-cli/src/local-services.mjs index 1d8409a3..e7d2bf93 100644 --- a/tools/repo-cli/src/local-services.mjs +++ b/tools/repo-cli/src/local-services.mjs @@ -18,6 +18,7 @@ const services = [ 'otel-collector', 'otel-collector-health', ]; +const completionServices = ['minio-init']; const logServices = [...services, 'minio-init']; const hostPorts = [ { service: 'postgres', key: 'POSTGRES_PORT', fallback: 5432 }, @@ -138,6 +139,19 @@ function runDocker(args, { allowFailure = false, capture = true, timeoutMs = 30_ return result; } +export function composeOperationTimeoutMs(waitSeconds) { + return (waitSeconds + 30) * 1000; +} + +export function classifyCompletionStatus(status, exitCode) { + const detail = `${status}/${exitCode}`; + if (status === 'exited') { + return exitCode === 0 ? { state: 'complete', detail } : { state: 'failed', detail }; + } + if (status === 'dead' || status === 'removing') return { state: 'failed', detail }; + return { state: 'pending', detail: status }; +} + function requireDocker() { const result = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { cwd: repositoryRoot, @@ -245,24 +259,60 @@ function inspectHealth(service, values) { return { state, health, detail: `${state}/${health}` }; } +function inspectCompletion(service, values) { + const idResult = runDocker([...composeArgs(values), 'ps', '-aq', service], { + allowFailure: true, + }); + const id = idResult.stdout.trim(); + if (!id) return { state: 'pending', detail: 'no container' }; + const inspect = runDocker(['inspect', '--format', '{{.State.Status}}|{{.State.ExitCode}}', id], { + allowFailure: true, + }); + const inspection = inspect.stdout?.trim(); + if (inspect.error || inspect.status !== 0 || !inspection) { + return { state: 'pending', detail: 'inspect unavailable' }; + } + const [status, rawExitCode] = inspection.split('|'); + return classifyCompletionStatus(status, Number(rawExitCode)); +} + async function waitForReady(values, waitSeconds) { const deadline = Date.now() + waitSeconds * 1000; let last = new Map(); + let lastCompletions = new Map(); while (Date.now() <= deadline) { last = new Map(services.map((service) => [service, inspectHealth(service, values)])); + lastCompletions = new Map( + completionServices.map((service) => [service, inspectCompletion(service, values)]), + ); + const failedCompletion = [...lastCompletions.entries()].find( + ([, result]) => result.state === 'failed', + ); + if (failedCompletion) { + fail(`${failedCompletion[0]} failed (${failedCompletion[1].detail})`); + } if ( - [...last.values()].every(({ state, health }) => state === 'running' && health === 'healthy') + [...last.values()].every( + ({ state, health }) => state === 'running' && health === 'healthy', + ) && + [...lastCompletions.values()].every(({ state }) => state === 'complete') ) { - console.log(`Local services ready (${services.join(', ')}).`); + console.log(`Local services ready (${[...services, ...completionServices].join(', ')}).`); return; } - const summary = services.map((service) => `${service}=${last.get(service).detail}`).join(' '); + const summary = [ + ...services.map((service) => `${service}=${last.get(service).detail}`), + ...completionServices.map((service) => `${service}=${lastCompletions.get(service).detail}`), + ].join(' '); process.stdout.write(`Waiting for local services: ${summary}\r`); await delay(1000); } console.error('\nLocal services did not become ready:'); for (const service of services) console.error(`- ${service}: ${last.get(service)?.detail ?? 'unknown'}`); + for (const service of completionServices) { + console.error(`- ${service}: ${lastCompletions.get(service)?.detail ?? 'unknown'}`); + } fail(`readiness timeout after ${waitSeconds}s`); } @@ -341,6 +391,7 @@ function parseArguments(argv, values = environment()) { export async function main(argv = process.argv.slice(2)) { const values = environment(); const { command, options } = parseArguments(argv, values); + const operationTimeoutMs = composeOperationTimeoutMs(options.waitSeconds); if (command === 'help') { usage(); return; @@ -363,6 +414,8 @@ export async function main(argv = process.argv.slice(2)) { if (command === 'status') { for (const service of services) console.log(`${service}: ${inspectHealth(service, values).detail}`); + for (const service of completionServices) + console.log(`${service}: ${inspectCompletion(service, values).detail}`); return; } if (command === 'logs') { @@ -374,7 +427,7 @@ export async function main(argv = process.argv.slice(2)) { return; } if (command === 'stop') { - runDocker([...composeArgs(values), 'stop']); + runDocker([...composeArgs(values), 'stop'], { timeoutMs: operationTimeoutMs }); console.log('Local services stopped; named volumes and containers were preserved.'); return; } @@ -392,14 +445,16 @@ export async function main(argv = process.argv.slice(2)) { return; } if (command === 'reset') { - runDocker([...composeArgs(values), 'down', '--remove-orphans']); - runDocker([...composeArgs(values), 'up', '-d']); + runDocker([...composeArgs(values), 'down', '--remove-orphans'], { + timeoutMs: operationTimeoutMs, + }); + runDocker([...composeArgs(values), 'up', '-d'], { timeoutMs: operationTimeoutMs }); await waitForReady(values, options.waitSeconds); console.log('Local services reset without removing named volumes.'); return; } if (command === 'restart-check') { - runDocker([...composeArgs(values), 'restart']); + runDocker([...composeArgs(values), 'restart'], { timeoutMs: operationTimeoutMs }); await waitForReady(values, options.waitSeconds); console.log( 'Local service restart and health checks passed. Use persistence-check for a Redis sentinel probe.', @@ -423,7 +478,7 @@ export async function main(argv = process.argv.slice(2)) { 'EX', '300', ]); - runDocker([...composeArgs(values), 'restart', 'redis']); + runDocker([...composeArgs(values), 'restart', 'redis'], { timeoutMs: operationTimeoutMs }); await waitForReady(values, options.waitSeconds); const result = runDocker([ ...composeArgs(values), @@ -446,7 +501,8 @@ export async function main(argv = process.argv.slice(2)) { console.log('Local Redis persistence check passed; sentinel was removed.'); return; } - if (shouldStart) runDocker([...composeArgs(values), 'up', '-d']); + if (shouldStart) + runDocker([...composeArgs(values), 'up', '-d'], { timeoutMs: operationTimeoutMs }); await waitForReady(values, options.waitSeconds); } diff --git a/tools/repo-cli/src/validate-aws-opentofu.mjs b/tools/repo-cli/src/validate-aws-opentofu.mjs new file mode 100644 index 00000000..82cae21d --- /dev/null +++ b/tools/repo-cli/src/validate-aws-opentofu.mjs @@ -0,0 +1,112 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const infrastructureRoot = path.join(repositoryRoot, 'infrastructure', 'aws'); +const alphaDirectory = '/workspace/environments/alpha'; +const containerDataDirectory = '/tmp/databreeze-tofu'; + +function usage() { + console.log(`Usage: pnpm infra:validate + +Runs format, backend-disabled initialization, and validation through the +official pinned OpenTofu container. The command never plans or applies +infrastructure and removes its isolated provider cache on completion.`); +} + +function fail(message) { + throw new Error(`AWS OpenTofu validation: ${message}`); +} + +function runDocker(args, timeout = 600_000) { + const result = spawnSync('docker', args, { + cwd: repositoryRoot, + stdio: 'inherit', + timeout, + }); + if (result.error?.code === 'ENOENT') fail('Docker CLI is not installed or not on PATH'); + if (result.error?.code === 'ETIMEDOUT') fail(`docker ${args[0]} timed out after ${timeout}ms`); + if (result.error || result.status !== 0) { + fail(`docker ${args[0]} failed with status ${result.status ?? 'unknown'}`); + } +} + +function removeValidationDirectory(directory) { + const temporaryRoot = path.resolve(os.tmpdir()); + const resolved = path.resolve(directory); + if ( + !resolved.startsWith(`${temporaryRoot}${path.sep}`) || + !path.basename(resolved).startsWith('databreeze-tofu-') + ) { + fail('refusing to remove a provider cache outside the bounded temporary directory'); + } + rmSync(resolved, { recursive: true, force: true }); +} + +export function main(argv = process.argv.slice(2)) { + if (argv.includes('--help') || argv.includes('-h')) { + usage(); + return; + } + if (argv.length > 0) fail(`unknown argument: ${argv[0]}`); + + const version = readFileSync(path.join(infrastructureRoot, '.opentofu-version'), 'utf8').trim(); + if (!/^\d+\.\d+\.\d+$/u.test(version)) fail('version pin is not an exact semantic version'); + const image = `ghcr.io/opentofu/opentofu:${version}`; + const sourceMount = `type=bind,source=${infrastructureRoot},target=/workspace`; + const validationDirectory = mkdtempSync(path.join(os.tmpdir(), 'databreeze-tofu-')); + const dataMount = `type=bind,source=${validationDirectory},target=${containerDataDirectory}`; + + try { + runDocker([ + 'run', + '--rm', + '--mount', + sourceMount, + image, + 'fmt', + '-check', + '-recursive', + '/workspace', + ]); + const base = [ + 'run', + '--rm', + '--workdir', + alphaDirectory, + '--mount', + sourceMount, + '--mount', + dataMount, + '--env', + 'TF_DATA_DIR=/tmp/databreeze-tofu', + image, + ]; + runDocker([ + ...base, + 'init', + '-backend=false', + '-input=false', + '-lockfile=readonly', + '-no-color', + ]); + runDocker([...base, 'validate', '-no-color']); + runDocker([...base, 'test', '-no-color']); + } finally { + removeValidationDirectory(validationDirectory); + } + + console.log(`AWS OpenTofu ${version} container validation passed without planning or applying.`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/tools/repo-cli/test/aws-infrastructure.test.mjs b/tools/repo-cli/test/aws-infrastructure.test.mjs index 3c3e5af8..91b34f58 100644 --- a/tools/repo-cli/test/aws-infrastructure.test.mjs +++ b/tools/repo-cli/test/aws-infrastructure.test.mjs @@ -8,6 +8,34 @@ import { fileURLToPath } from 'node:url'; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const read = (relativePath) => readFileSync(path.join(repositoryRoot, relativePath), 'utf8'); +test('AWS validation pins one OpenTofu CLI and official container release', () => { + const version = read('infrastructure/aws/.opentofu-version').trim(); + const readme = read('infrastructure/aws/README.md'); + assert.equal(version, '1.12.5'); + assert.match(readme, /ghcr\.io\/opentofu\/opentofu:1\.12\.5/u); +}); + +test('AWS container validation command is pinned, isolated, and non-applying', () => { + const script = path.join(repositoryRoot, 'tools/repo-cli/src/validate-aws-opentofu.mjs'); + const help = spawnSync(process.execPath, [script, '--help'], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /official pinned OpenTofu container/u); + const source = read('tools/repo-cli/src/validate-aws-opentofu.mjs'); + assert.match(source, /'fmt',\s*'-check',\s*'-recursive'/u); + assert.match(source, /'init',\s*'-backend=false',\s*'-input=false',\s*'-lockfile=readonly'/u); + assert.match(source, /'validate', '-no-color'/u); + assert.match(source, /'test', '-no-color'/u); + assert.match(source, /TF_DATA_DIR=\/tmp\/databreeze-tofu/u); + assert.doesNotMatch(source, /['"]apply['"]/u); + assert.match( + read('package.json'), + /"infra:validate": "node tools\/repo-cli\/src\/validate-aws-opentofu\.mjs"/u, + ); +}); + test('AWS foundation has reusable modules and safe alpha composition', () => { for (const relativePath of [ 'infrastructure/aws/modules/network/main.tf', @@ -59,11 +87,11 @@ test('AWS sources expose encryption, private data, and OIDC boundaries without s 'master_user_secret_kms_key_id', 'block_public_policy', 'storage_encrypted', - 'manage_master_user_password = true', - 'publicly_accessible = false', - 'transit_encryption_enabled = true', ]) assert.match(sources, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(sources, /manage_master_user_password\s*=\s*true/u); + assert.match(sources, /publicly_accessible\s*=\s*false/u); + assert.match(sources, /transit_encryption_enabled\s*=\s*true/u); assert.doesNotMatch(sources, /AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH) PRIVATE KEY/); assert.doesNotMatch(sources, /ingress[\s\S]*?cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/u); assert.doesNotMatch(sources, /principals[\s\S]*?identifiers\s*=\s*\[[^\]]*"\*"/u); @@ -89,12 +117,21 @@ test('AWS validation script is non-applying and reports missing OpenTofu clearly ); const source = read('tools/repo-cli/src/check-aws-infrastructure.mjs'); assert.match(source, /init', '-backend=false/); + assert.match(source, /'-lockfile=readonly'/u); assert.match(source, /validate', '-no-color/); assert.match(source, /process\.exitCode \?\? 0/); assert.match(source, /missing required safety boundary/u); assert.doesNotMatch(source, /tofu',\s*\['apply'/u); }); +test('AWS provider selection is locked for reproducible validation', () => { + const lock = read('infrastructure/aws/environments/alpha/.terraform.lock.hcl'); + assert.match(lock, /registry\.opentofu\.org\/hashicorp\/aws/u); + assert.match(lock, /version\s+=\s+"6\.0\.0"/u); + assert.match(lock, /constraints\s+=\s+"6\.0\.0"/u); + assert.match(read('.gitattributes'), /^\*\.hcl text eol=lf$/m); +}); + test('AWS production profile enables recovery and prevents public data paths', () => { const production = read('infrastructure/aws/environments/alpha/production.tfvars.example'); const versions = read('infrastructure/aws/environments/alpha/versions.tf'); diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index d0ebbc11..5132f4e6 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -205,7 +205,7 @@ test('repository checker validates the committed orchestration package', () => { test('ledger records verified task evidence before advancing the next task', () => { const ledger = readJson('docs/plans/execution-orchestration.json'); - assert.equal(ledger.nextTaskId, 'FND-003'); + assert.equal(ledger.nextTaskId, 'FND-005'); assert.equal(ledger.activeBatchId, 'B01'); assert.equal(ledger.checkpoint.remoteDev, '783a4710c0aa2a2808d78ad7f0643e6731150bd7'); assert.equal(ledger.checkpoint.remoteMain, '3ed3d77d0281ef239d0509c81ded447d8fffd213'); diff --git a/tools/repo-cli/test/local-infrastructure.test.mjs b/tools/repo-cli/test/local-infrastructure.test.mjs index 084cad42..8e920810 100644 --- a/tools/repo-cli/test/local-infrastructure.test.mjs +++ b/tools/repo-cli/test/local-infrastructure.test.mjs @@ -5,9 +5,40 @@ import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { classifyCompletionStatus, composeOperationTimeoutMs } from '../src/local-services.mjs'; + const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const read = (relativePath) => readFileSync(path.join(repositoryRoot, relativePath), 'utf8'); +test('local lifecycle grants image pulls the bounded readiness window plus teardown margin', () => { + assert.equal(composeOperationTimeoutMs(1), 31_000); + assert.equal(composeOperationTimeoutMs(60), 90_000); + assert.equal(composeOperationTimeoutMs(3600), 3_630_000); +}); + +test('local readiness requires successful completion jobs', () => { + assert.deepEqual(classifyCompletionStatus('created', 0), { + state: 'pending', + detail: 'created', + }); + assert.deepEqual(classifyCompletionStatus('running', 0), { + state: 'pending', + detail: 'running', + }); + assert.deepEqual(classifyCompletionStatus('exited', 0), { + state: 'complete', + detail: 'exited/0', + }); + assert.deepEqual(classifyCompletionStatus('exited', 2), { + state: 'failed', + detail: 'exited/2', + }); + assert.deepEqual(classifyCompletionStatus('dead', 137), { + state: 'failed', + detail: 'dead/137', + }); +}); + test('local compose defines pinned, healthy disposable dependencies', () => { const compose = read('infrastructure/local/compose.yml'); const envExample = read('infrastructure/local/.env.example'); @@ -25,7 +56,10 @@ test('local compose defines pinned, healthy disposable dependencies', () => { } assert.match(compose, /postgres:17\.5-alpine/); assert.match(compose, /redis:7\.4\.5-alpine/); - assert.match(compose, /RELEASE\.2025-06-13T11-33-47Z/); + assert.match(compose, /MINIO_IMAGE:-quay\.io\/minio\/minio:RELEASE\.2025-06-13T11-33-47Z/u); + assert.match(compose, /MINIO_MC_IMAGE:-quay\.io\/minio\/mc:RELEASE\.2025-08-13T08-35-41Z/u); + assert.match(envExample, /^MINIO_IMAGE=quay\.io\/minio\/minio:RELEASE\.2025-06-13T11-33-47Z$/m); + assert.match(envExample, /^MINIO_MC_IMAGE=quay\.io\/minio\/mc:RELEASE\.2025-08-13T08-35-41Z$/m); assert.match(compose, /mailpit:v1\.21\.8/); assert.match(compose, /collector-contrib:0\.128\.0/); assert.match(compose, /curlimages\/curl:8\.14\.1/); @@ -79,6 +113,8 @@ test('local bootstrap is credential-free and creates every owned module schema', assert.doesNotMatch(sql, /DROP\s+SCHEMA|DROP\s+DATABASE|TRUNCATE/u); const bucketScript = read('infrastructure/local/minio/bootstrap-buckets.sh'); + assert.doesNotMatch(bucketScript, /\r/u); + assert.match(read('.gitattributes'), /^\*\.sh text eol=lf$/m); assert.match(bucketScript, /MINIO_ROOT_PASSWORD/); assert.match(bucketScript, /mc mb --ignore-existing/u); assert.match(bucketScript, /mc anonymous set none/u);