diff --git a/docs/operations/coderabbit-pr-40-disposition.md b/docs/operations/coderabbit-pr-40-disposition.md new file mode 100644 index 00000000..e8e12883 --- /dev/null +++ b/docs/operations/coderabbit-pr-40-disposition.md @@ -0,0 +1,48 @@ +# CodeRabbit disposition for promotion PR 40 + +Promotion PR [#40](https://github.com/DatabreezeService/databreeze-platform/pull/40) +received one automatic full CodeRabbit review. After the focused fix PR merged +into `dev`, the CodeRabbit integration automatically ran one incremental review +on the new promotion head; no additional review was manually requested or +invoked, and no further run will be requested. + +- Review ID: `4845720374` +- Run ID: `2397e2ad-4258-4b05-9516-0a8b6fb4f39c` +- Submitted: `2026-08-03T15:20:07Z` +- Reviewed range: `8a4c0af52ed872715103710e3c89ca832f999bd4..f1573921446e9f86313e0f58b926777aed9e1402` +- Automatic incremental review ID: `4846097863` +- Automatic incremental run ID: `ce0a1c0d-b669-4551-bbd1-b9cad29de291` +- Incremental range: `f1573921446e9f86313e0f58b926777aed9e1402..c80994cf4e2cf97be5a9137160417b8feb2b4eb7` + +## Valid findings fixed + +All six actionable inline findings, the outside-diff orchestration finding, and +the twelve review-body nitpicks were reproduced against the reviewed code and +fixed in focused commits on `fix/coderabbit-pr-40-reconciliation`: + +| Finding | Disposition and evidence | +|---|---| +| FND-007 was omitted from B01 task traversal. | Accepted. `789a3db` records `FND-007` as an explicit handoff task and asserts its position in the orchestration checker. | +| Project-scoped bootstrap sessions lost `projectId`; `apiVersion` was too broad. | Accepted. `37f2289` preserves project scope and constrains the generated API schema. | +| Invitation and removed memberships could be activated through `transition`. | Accepted. `cc1118a` requires an existing `ACTIVE` membership for administrative transitions; invitation activation remains in `accept`. | +| Membership identity uniqueness did not cover nullable scope components. | Accepted. `e98c63e` adds the null-safe PostgreSQL uniqueness index, in-memory parity, conflict mapping, and migration inventory coverage. | +| Hierarchy reads and membership outcomes returned denial/not-found/conflict envelopes as HTTP 200. | Accepted. `0689d70` maps hierarchy `NOT_FOUND` to 404 and membership result codes to 400/403/404/409/410/503, with generated OpenAPI and regression tests. | +| Windows Android test command mixed PowerShell and cmd.exe syntax. | Accepted. `de3ff3d` documents valid commands for both shells. | +| Maintainability and boundary nitpicks (shared DTO constants, cross-field scope validation, identity state coverage, adapter equality/filtering, rollback assertions, and mapped bootstrap assertions). | Accepted. These are covered by `c459a10`, `06588ea`, `0689d70`, and the preceding `37f2289` test changes. | +| The documented membership error body did not match the emitted rejection envelope. | Accepted. `73b6199` documents `MembershipRejectedResponseDto` with `accepted: false` and the bounded error-code enum for every mapped error status. | +| `handoffTaskIds` accepted malformed non-array values and could throw while checking the ledger. | Accepted. `8414b83` normalizes and diagnoses malformed shapes, with object/string regression coverage. | +| The uniqueness migration test omitted `project_id` normalization. | Accepted. `d79c74b` asserts the project `COALESCE` expression alongside the workspace assertion. | + +## Rejected findings + +None. Every posted actionable finding and review-body nitpick had a reproducible +correctness, contract, security, or test-coverage improvement in this slice. + +## Verification and merge rule + +The focused fixes must pass the affected API/domain tests, OpenAPI drift check, +`corepack pnpm repo:check`, `corepack pnpm repo:build`, and the hosted checks on +the follow-up `dev` PR. This document records both the one full review and the +integration-triggered incremental result; it does not authorize another +CodeRabbit run. PR #40 remains unmergeable until its current checks are green +and all valid findings from both completed results are resolved. diff --git a/docs/operations/foundation-handoff-2026-08-03.md b/docs/operations/foundation-handoff-2026-08-03.md new file mode 100644 index 00000000..69bef29a --- /dev/null +++ b/docs/operations/foundation-handoff-2026-08-03.md @@ -0,0 +1,71 @@ +# Engineering foundation handoff + +**Observed at:** 2026-08-03 (UTC) + +**Repository:** `databreeze-platform` + +## Current checkpoint + +- Integration base: `origin/dev` at `9265e15125c2e50cfcaca455524c903b6b92383e`. +- Stable base: `origin/main` at `8a4c0af52ed872715103710e3c89ca832f999bd4`. +- Active worktree branch: `feat/foundation-fnd005-reconciliation-20260803`. +- Active delivery batch: `B01` (foundation verification and identity completion). +- Foundation evidence units: FND-005 telemetry reconciliation is recorded; + FND-006 hosted supply-chain protection remains an explicit external gate; + FND-007 is this handoff record plus the linked runbooks. +- Next implementation boundary after this reconciliation: `IAM-001`. + +The implementation program remains conservative: merged code and green tests +do not promote product requirements to `verified` or `released`. + +## Verification record + +The following evidence is reproducible from the checkpoint: + +- `corepack pnpm --filter @databreeze/telemetry test` — TypeScript package, + hostile-input, exporter-isolation, and cross-runtime source parity tests pass. +- `uv run pytest tests/test_telemetry.py` from `services/engine` — Python + telemetry tests pass. +- PowerShell: `$env:ANDROID_HOME = Join-Path $env:LOCALAPPDATA 'Android\\Sdk'`, then + `& .\\apps\\android\\gradlew.bat :app:testDebugUnitTest --offline --no-daemon` — + Android/Kotlin unit suite passes when the SDK is supplied by the workstation/toolchain. +- cmd.exe: `set "ANDROID_HOME=%LOCALAPPDATA%\\Android\\Sdk"`, then + `call apps\\android\\gradlew.bat :app:testDebugUnitTest --offline --no-daemon` — + the same Android/Kotlin unit suite passes from a Windows command prompt. +- `corepack pnpm orchestration:check` and `corepack pnpm requirements:check` + pass with 611 requirement records and the B01 dependency graph intact. +- Existing root checks, API tests, OpenAPI drift checks, infrastructure static + checks, and build evidence remain in the prior foundation records. + +No customer data or credentials were used. No AWS infrastructure, database +migration against a customer environment, or production release was applied. + +## Explicit external gates + +- FND-006 protected GitHub release-environment reviewers and administrator + branch restrictions still require hosted evidence; the local policy tests do + not assert that an administrator configured those controls. +- OpenTofu live format/validate and Docker collector health remain hosted or + workstation gates recorded by the FND-003/FND-004 evidence. +- Android instrumentation, signed packaging, hosted exporter health, and + release provenance remain production-readiness gates. + +These gaps are not hidden, weakened, or represented as product completion. + +## B01 resume point + +Resume on a short-lived feature branch from the fetched `origin/dev` base. +Read Plan 020 and the IAM-001 task, then use the TDD loop to reconcile tenant +transaction context, ancestry checks, authorization epochs, and atomic +mutation/audit boundaries before implementing later IAM lifecycle surfaces. +Keep feature PRs targeted to `dev` without CodeRabbit; only the subsequent +promotion to `main` receives the single full CodeRabbit review allowed for that +promotion PR. + +## Rollback points + +- `adba0aa` — telemetry diagnostics reconciliation evidence and its guard test. +- The containing handoff commit can be reverted independently without touching + runtime code, tenant data, or infrastructure state. +- Existing merged foundation promotion commits remain available through the + fetched `origin/dev`/`origin/main` refs and the local historical branches. diff --git a/docs/operations/foundation-telemetry-diagnostics-2026-08-03.md b/docs/operations/foundation-telemetry-diagnostics-2026-08-03.md new file mode 100644 index 00000000..ece32b84 --- /dev/null +++ b/docs/operations/foundation-telemetry-diagnostics-2026-08-03.md @@ -0,0 +1,70 @@ +# Foundation telemetry and diagnostics reconciliation + +**Evidence date:** 2026-08-03 (UTC) + +**Source checkpoint:** `origin/dev` at `9265e15125c2e50cfcaca455524c903b6b92383e` + +**Scope:** FND-005 content-safe telemetry and the diagnostics boundary across +the TypeScript control plane/shared clients, Python engine, and native Android +client. This record is task evidence only. No requirement record was promoted +to `verified`. + +No requirement record was promoted to `verified`. + +## Scope and safety boundary + +The canonical record and allowlist live in `packages/telemetry/schemas/v1.json` +and `packages/telemetry/src/v1.ts`. TypeScript, Python, and Kotlin mirrors are +checked against the same safe attribute set. Telemetry carries bounded IDs, +route/operation/outcome tokens, counters, durations, status, and correlation +context only. Paths, filenames, source values, formulas, evidence excerpts, +prompts, contact data, credentials, tokens, provider payloads, and raw +exception messages are outside the contract. + +Provider/exporter failures are isolated from domain work. Diagnostics use a +generic unreadable result when a provider mapping, clock, header collection, or +attribute map cannot be safely inspected; the provider's cause is never copied +into a record or Problem response. + +## Cross-runtime verification + +The following checks passed from the source checkpoint: + +| Runtime/boundary | Command | Result | +| --- | --- | --- | +| TypeScript package and source parity | `corepack pnpm --filter @databreeze/telemetry test` | 12 tests passed, including the canonical TypeScript/Python/Android allowlist parity check. | +| Python engine | `uv run pytest tests/test_telemetry.py` from `services/engine` | 14 tests passed. | +| Android/Kotlin | `ANDROID_HOME=%LOCALAPPDATA%\\Android\\Sdk apps/android/gradlew.bat :app:testDebugUnitTest --offline --no-daemon` | Build and unit suite passed; 31 Gradle tasks completed. | +| API propagation | `corepack pnpm --filter @databreeze/api test` | Trace-context propagation and safe failure tests passed as part of the API suite. | + +The Android invocation uses the workstation SDK path only as an environment +configuration; it is not committed and no device credentials are required. + +## Failure and privacy probes + +The suites exercise hostile getter/proxy-backed attributes and headers, +ambiguous or malformed correlation/traceparent values, invalid timestamps, +oversized and path-like values, email/source-like values, provider exporter +exceptions, clock failures, and Python/Kotlin mapping failures. Assertions +verify that sanitized output is empty or bounded, stable generic errors are +returned, and provider causes do not appear in serialized records or messages. + +## Known environment limits + +- A clean shell without `ANDROID_HOME` cannot locate the Android SDK; CI and + release workspaces must provide the SDK through the documented toolchain + setup. With the local SDK path configured, the Kotlin suite passed above. +- OpenTofu formatting/validation, live Docker collector health, hosted + OpenTelemetry delivery, and protected release-environment approvals remain + external gates recorded by FND-003/FND-004/FND-006. No infrastructure was + applied and no collector was treated as domain authority. +- Android instrumentation, signed packaging, and production exporter health + are later release gates; this record does not claim them complete. + +## Release decision + +FND-005 has cross-runtime implementation and privacy evidence at this +checkpoint. Keep product requirements conservative and retain FND-006 as +`implemented` until protected hosted release-environment evidence is supplied. +This task record is reversible with the documentation/test commit and does not +change customer-data handling or application authority. diff --git a/docs/operations/iam-foundation-checkpoint-2026-08-03.md b/docs/operations/iam-foundation-checkpoint-2026-08-03.md new file mode 100644 index 00000000..2f27889f --- /dev/null +++ b/docs/operations/iam-foundation-checkpoint-2026-08-03.md @@ -0,0 +1,35 @@ +# IAM foundation checkpoint — 2026-08-03 + +This checkpoint records the work on `feat/foundation-fnd005-reconciliation-20260803` before the first 30-commit integration boundary. It is evidence for the partial IAM-001/IAM-003/IAM-004 slice; it does not claim that the IAM plan or the product is complete. + +## Reversible implementation units + +- `be7367d` accepts a scoped invitation only for the invited principal, rejects stale/expired requests, and clears invitation-only lifetime fields. +- `b90c9d4` persists cleared invitation lifetime fields through the Prisma membership adapter. +- `2b97550` exposes invitation acceptance through the versioned API and OpenAPI artifact. +- `7eb92f4` adds owner-only, revisioned atomic ownership promotion/demotion with transaction rollback coverage. +- `11484b1` exposes ownership transfer through the versioned API and OpenAPI artifact. +- `c4ed1e6`, `2419607`, `5f1d9b3`, `74582a2`, and `dcce9b3` compose, read, expose, and type the authenticated personal-tenant bootstrap state. +- `6ec3282`, `110848d`, and `98f1774` constrain owner invitations, authorize membership listing, and protect Owner membership removals. +- `66c91d9`, `9e80b73`, and `6593454` prove ownership rollback and stable availability failures. + +## Verification recorded + +- API TypeScript tests compile successfully with `tsconfig.test.json`. +- Membership service direct run: 12 passing tests. +- Identity bootstrap direct run: 5 passing tests. +- Bootstrap controller direct run: 2 passing tests. +- Deterministic OpenAPI test and `openapi:check` pass; the checked-in `services/api/openapi/v1.json` includes `/v1/me/bootstrap`, invitation acceptance, and ownership transfer. +- Repository gates pass on this branch: formatting, lint, strict typecheck, orchestration and 611-requirement validation, contract drift, infrastructure static checks, 81 repository tests, 21 workspace test tasks (including 336 API tests), and the complete workspace build. + +## Deliberate remaining gaps + +The following requirements remain partial and must not be promoted to `verified` from this checkpoint: + +- durable invitation token hashing/email binding and single-use token redemption; +- recent-MFA step-up assertions and the specified seven-day signed ownership-transfer request/explicit recipient acceptance flow; +- audit/outbox events for every membership mutation and authorization-epoch invalidation; +- account registration/recovery orchestration and full organization enumeration for non-personal team tenants; +- production migration/restore evidence. + +The next integration action is to prepare the feature PR to `dev` from this clean, verified branch. A promotion PR to `main` receives the single CodeRabbit review cycle required by the repository workflow. This checkpoint still represents a partial IAM slice and does not promote any requirement to `verified` beyond the already recorded foundation evidence. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index e1e404dc..0ee91463 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -16,14 +16,14 @@ } }, "checkpoint": { - "observedAt": "2026-08-02T13:38:28Z", - "remoteDev": "783a4710c0aa2a2808d78ad7f0643e6731150bd7", - "remoteMain": "3ed3d77d0281ef239d0509c81ded447d8fffd213", - "lastFeaturePullRequest": 19, - "lastPromotionPullRequest": 20, - "lastPromotionFixPullRequest": 23, + "observedAt": "2026-08-03T00:00:00Z", + "remoteDev": "9265e15125c2e50cfcaca455524c903b6b92383e", + "remoteMain": "8a4c0af52ed872715103710e3c89ca832f999bd4", + "lastFeaturePullRequest": 36, + "lastPromotionPullRequest": 38, + "lastPromotionFixPullRequest": 37, "openPullRequestsObserved": 0, - "note": "PR 19 merged a 73-commit foundation batch to dev; PR 20 promoted dev to main after CodeRabbit review and PRs 21-23 delivered validated review fixes. Historical observation only; every session must fetch and recompute current state." + "note": "PRs 37 and 38 promoted the remaining foundation commits to main. PR 37 received one CodeRabbit review cycle and valid findings were reconciled; PR 38's one full-review request was rate-limited after hosted checks. Historical observation only; every session must fetch and recompute current state." }, "reviewPolicy": { "featurePullRequest": { @@ -54,7 +54,7 @@ "post-ga-planned", "blocked" ], - "nextTaskId": "FND-005", + "nextTaskId": "IAM-001", "activeBatchId": "B01", "taskState": { "FND-001": { @@ -111,14 +111,17 @@ "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", - "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "status": "verified", + "commit": "adba0aac0fdd2060a6dd965f726c0bc245b96b77", "evidence": [ "docs/operations/telemetry-safety.md", + "docs/operations/foundation-telemetry-diagnostics-2026-08-03.md", + "tools/repo-cli/test/foundation-telemetry-reconciliation.test.mjs", "packages/telemetry/test/telemetry-v1.test.mjs", - "services/engine/tests/test_telemetry.py" + "services/engine/tests/test_telemetry.py", + "apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt" ], - "note": "TypeScript and Python telemetry boundaries, hostile-input normalization, and redaction tests are promoted; final clean-checkout reconciliation remains in B01." + "note": "Cross-runtime TypeScript, Python, and Kotlin allowlist/correlation boundaries, hostile-input normalization, exporter isolation, and privacy probes were reconciled. Android unit evidence uses the documented SDK environment; instrumentation and hosted exporter gates remain external." }, "FND-006": { "status": "implemented", @@ -133,29 +136,30 @@ "note": "Hosted checks, supply-chain policy, SBOM, and provenance safeguards are promoted. GitHub release-environment protection remains an administrator-recorded external gate." }, "FND-007": { - "status": "partial-needs-reconciliation", - "commit": "3ed3d77d0281ef239d0509c81ded447d8fffd213", + "status": "verified", + "commit": "7670ee6798f9ce50368aca626ef3fbe237cba61d", "evidence": [ + "docs/operations/foundation-handoff-2026-08-03.md", "docs/development/README.md", "docs/operations/engineering-foundation-verification.md", "docs/operations/deployment-and-rollback.md", "docs/operations/secret-rotation.md", - "docs/operations/provider-adapters.md" + "docs/operations/provider-adapters.md", + "tools/repo-cli/test/foundation-handoff.test.mjs" ], - "note": "Runbooks and static evidence exist; B01 must reconcile live external gates and publish the final foundation handoff before advancing plan status." + "note": "Development, deployment, rollback, provider, support, telemetry, and clean-checkpoint evidence is published. External release-environment and hosted-toolchain gates remain explicit rather than being represented as product completion." } }, "deliveryBatches": [ { "batchId": "B01", - "name": "Foundation verification and identity completion", + "name": "Foundation external gate and identity completion", "branch": "feat/foundation-identity-reconciliation", "dependencies": [], "status": "in-progress", "commitBudget": { "minimum": 30, "target": 45, "maximum": 79 }, "maximumChangedFiles": 260, "taskIds": [ - "FND-005", "FND-006", "FND-007", "IAM-001", @@ -170,6 +174,7 @@ "IAM-006", "IAM-007" ], + "handoffTaskIds": ["FND-007"], "exitGate": "Foundation external gates are explicit and IAM, AUD, and BUA requirements are reconciled, completed, tested, and evidenced." }, { diff --git a/packages/domain/src/identity/v1.ts b/packages/domain/src/identity/v1.ts index f45324f3..bf0bb45e 100644 --- a/packages/domain/src/identity/v1.ts +++ b/packages/domain/src/identity/v1.ts @@ -158,6 +158,7 @@ export type IdentityErrorCodeV1 = | 'INVALID_EPOCH' | 'INVALID_SCOPE' | 'INVALID_LIFETIME' + | 'INVALID_KIND' | 'INVALID_ROLE' | 'INVALID_PLATFORM' | 'INVALID_PUBLIC_KEY' @@ -192,6 +193,11 @@ function boundedText(input: unknown, maxLength: number): string | undefined { return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; } +/** Shared bounded-text predicate for application-layer preflight without placeholder identities. */ +export function isBoundedTextV1(input: unknown, maxLength: number): input is string { + return boundedText(input, maxLength) !== undefined; +} + function containsControlCharacterV1(input: string): boolean { for (const character of input) { const codePoint = character.codePointAt(0); @@ -303,6 +309,118 @@ export function createUserIdentityV1(input: { ); } +export type ProjectKindV1 = 'INTERNAL' | 'CLIENT' | 'LOCATION' | 'ENGAGEMENT'; + +export function isProjectKindV1(input: unknown): input is ProjectKindV1 { + return ( + input === 'INTERNAL' || input === 'CLIENT' || input === 'LOCATION' || input === 'ENGAGEMENT' + ); +} + +function activeOrArchived(input: unknown): input is 'ACTIVE' | 'ARCHIVED' { + return input === 'ACTIVE' || input === 'ARCHIVED'; +} + +/** IAM-001, IAM-003: validate a team organization before persistence. */ +export function createOrganizationIdentityV1(input: { + readonly id: unknown; + readonly name: unknown; + readonly personal?: unknown; + readonly status?: unknown; + readonly createdAt: unknown; +}): IdentityResultV1 { + const id = stableId(input.id); + const name = boundedText(input.name, 200); + const createdAt = timestamp(input.createdAt); + const personal = input.personal ?? false; + const status = input.status ?? 'ACTIVE'; + if (!id) return rejected('INVALID_IDENTIFIER'); + if (!name) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (typeof personal !== 'boolean') return rejected('INVALID_STATE'); + if (status !== 'ACTIVE' && status !== 'SUSPENDED' && status !== 'DEACTIVATED') + return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + schemaVersion: 1 as const, + id, + name, + personal, + status, + createdAt, + }), + ); +} + +/** IAM-001, IAM-019: validate workspace identity and its organization ancestry. */ +export function createWorkspaceIdentityV1(input: { + readonly id: unknown; + readonly organizationId: unknown; + readonly name: unknown; + readonly status?: unknown; + readonly authorizationEpoch?: unknown; + readonly createdAt: unknown; +}): IdentityResultV1 { + const id = stableId(input.id); + const organizationId = stableId(input.organizationId); + const name = boundedText(input.name, 200); + const createdAt = timestamp(input.createdAt); + const status = input.status ?? 'ACTIVE'; + const authorizationEpoch = + input.authorizationEpoch === undefined ? 1 : positiveEpoch(input.authorizationEpoch); + if (!id || !organizationId) return rejected('INVALID_IDENTIFIER'); + if (!name) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!activeOrArchived(status)) return rejected('INVALID_STATE'); + if (!authorizationEpoch) return rejected('INVALID_EPOCH'); + return accepted( + Object.freeze({ + schemaVersion: 1 as const, + id, + organizationId, + name, + status, + authorizationEpoch, + createdAt, + }), + ); +} + +/** IAM-001, IAM-014, IAM-019: validate project identity without widening ancestry. */ +export function createProjectIdentityV1(input: { + readonly id: unknown; + readonly organizationId: unknown; + readonly workspaceId: unknown; + readonly kind: unknown; + readonly name: unknown; + readonly status?: unknown; + readonly createdAt: unknown; +}): IdentityResultV1 { + const id = stableId(input.id); + const organizationId = stableId(input.organizationId); + const workspaceId = stableId(input.workspaceId); + const name = boundedText(input.name, 200); + const createdAt = timestamp(input.createdAt); + const status = input.status ?? 'ACTIVE'; + if (!id || !organizationId || !workspaceId) return rejected('INVALID_IDENTIFIER'); + if (!name) return rejected('INVALID_TEXT'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + if (!isProjectKindV1(input.kind)) return rejected('INVALID_KIND'); + if (!activeOrArchived(status)) return rejected('INVALID_STATE'); + return accepted( + Object.freeze({ + schemaVersion: 1 as const, + id, + organizationId, + workspaceId, + kind: input.kind, + name, + status, + createdAt, + }), + ); +} + export function bootstrapPersonalOrganizationV1(input: { readonly user: Parameters[0]; readonly organizationId: unknown; diff --git a/packages/domain/test/identity-hierarchy-v1.test.mjs b/packages/domain/test/identity-hierarchy-v1.test.mjs new file mode 100644 index 00000000..ccd9fd05 --- /dev/null +++ b/packages/domain/test/identity-hierarchy-v1.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createOrganizationIdentityV1, + createProjectIdentityV1, + createWorkspaceIdentityV1, +} from '../dist/identity/v1.js'; + +const ids = { + organization: '00000000-0000-4000-8000-000000000101', + workspace: '00000000-0000-4000-8000-000000000102', + project: '00000000-0000-4000-8000-000000000103', +}; +const createdAt = '2026-01-01T00:00:00.000Z'; + +void test('[IAM-001, IAM-003] hierarchy constructors create non-personal organizations with UTC metadata', () => { + const organization = createOrganizationIdentityV1({ + id: ids.organization, + name: 'Acme Việt Nam', + createdAt, + }); + assert.equal(organization.accepted, true); + if (!organization.accepted) return; + assert.equal(organization.value.personal, false); + assert.equal(organization.value.status, 'ACTIVE'); + assert.equal(organization.value.createdAt, createdAt); +}); + +void test('[IAM-003, IAM-014] hierarchy constructors preserve ancestry and narrow project kinds', () => { + const workspace = createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: 'Operations', + createdAt, + }); + assert.equal(workspace.accepted, true); + if (!workspace.accepted) return; + assert.equal(workspace.value.authorizationEpoch, 1); + + const project = createProjectIdentityV1({ + id: ids.project, + organizationId: ids.organization, + workspaceId: ids.workspace, + kind: 'CLIENT', + name: 'Client rollout', + createdAt, + }); + assert.equal(project.accepted, true); + if (!project.accepted) return; + assert.equal(project.value.workspaceId, workspace.value.id); + assert.equal(project.value.kind, 'CLIENT'); +}); + +void test('[IAM-001] hierarchy constructors reject malformed identifiers, names, epochs, and states', () => { + assert.deepEqual(createOrganizationIdentityV1({ id: 'not-an-id', name: 'Org', createdAt }), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + assert.deepEqual( + createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: ' ', + createdAt, + }), + { accepted: false, code: 'INVALID_TEXT' }, + ); + assert.deepEqual( + createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: 'Operations', + authorizationEpoch: 0, + createdAt, + }), + { accepted: false, code: 'INVALID_EPOCH' }, + ); + assert.deepEqual( + createProjectIdentityV1({ + id: ids.project, + organizationId: ids.organization, + workspaceId: ids.workspace, + kind: 'UNKNOWN', + name: 'Project', + createdAt, + }), + { accepted: false, code: 'INVALID_KIND' }, + ); + assert.deepEqual( + createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: 'Operations', + status: 'DELETED', + createdAt, + }), + { accepted: false, code: 'INVALID_STATE' }, + ); +}); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 24a6ccda..98362cd8 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1392,6 +1392,1458 @@ "tags": ["auth"] } }, + "/v1/organizations/{organizationId}": { + "get": { + "operationId": "IamHierarchyController.getOrganization", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The organization metadata.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The organization is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read one organization inside the authenticated tenant scope", + "tags": ["identity"] + } + }, + "/v1/organizations/{organizationId}/workspaces": { + "get": { + "operationId": "IamHierarchyController.listWorkspaces", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The content-free workspace list.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List workspaces in the authenticated organization scope", + "tags": ["identity"] + }, + "post": { + "operationId": "IamHierarchyController.createWorkspace", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateWorkspaceDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable workspace in the authenticated organization", + "tags": ["identity"] + } + }, + "/v1/workspaces/{workspaceId}": { + "get": { + "operationId": "IamHierarchyController.getWorkspace", + "parameters": [ + { "name": "workspaceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The workspace metadata.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The workspace is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read one workspace inside the authenticated tenant scope", + "tags": ["identity"] + } + }, + "/v1/workspaces/{workspaceId}/projects": { + "get": { + "operationId": "IamHierarchyController.listProjects", + "parameters": [ + { "name": "workspaceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The content-free project list.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List projects in the authenticated workspace scope", + "tags": ["identity"] + }, + "post": { + "operationId": "IamHierarchyController.createProject", + "parameters": [ + { "name": "workspaceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateProjectDto" } } + } + }, + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create an immutable project in the authenticated workspace", + "tags": ["identity"] + } + }, + "/v1/projects/{projectId}": { + "get": { + "operationId": "IamHierarchyController.getProject", + "parameters": [ + { "name": "projectId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The project metadata.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The project is not visible.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read one project inside the authenticated tenant scope", + "tags": ["identity"] + } + }, + "/v1/memberships": { + "get": { + "operationId": "IamMembershipController.list", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "The membership list.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request is invalid.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List memberships visible in the authenticated tenant scope", + "tags": ["identity"] + }, + "post": { + "operationId": "IamMembershipController.invite", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/InviteMembershipDto" } } + } + }, + "responses": { + "200": { + "description": "The invited membership.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request is invalid.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Invite a principal with a bounded role and tenant scope", + "tags": ["identity"] + } + }, + "/v1/memberships/{membershipId}/transition": { + "post": { + "operationId": "IamMembershipController.transition", + "parameters": [ + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TransitionMembershipDto" } + } + } + }, + "responses": { + "200": { + "description": "The transitioned membership.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request is invalid.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Transition one membership with an optimistic revision", + "tags": ["identity"] + } + }, + "/v1/memberships/{membershipId}/accept": { + "post": { + "operationId": "IamMembershipController.accept", + "parameters": [ + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AcceptMembershipDto" } } + } + }, + "responses": { + "200": { + "description": "The accepted membership.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request is invalid.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Accept an invitation as the invited principal", + "tags": ["identity"] + } + }, + "/v1/memberships/{membershipId}/transfer-ownership": { + "post": { + "operationId": "IamMembershipController.transferOwnership", + "parameters": [ + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TransferOwnershipDto" } + } + } + }, + "responses": { + "200": { + "description": "The transferred membership.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request is invalid.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "The authenticated actor lacks the required scope.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "The membership is not visible.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "The membership revision or ownership invariant conflicts.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "The invitation has expired.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Membership persistence is unavailable.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MembershipRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Transfer organization ownership to an active member", + "tags": ["identity"] + } + }, + "/v1/me/bootstrap": { + "get": { + "operationId": "IamBootstrapController.bootstrap", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/BootstrapResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Load safe identity and personal-tenant bootstrap state", + "tags": ["identity"] + } + }, "/v1/artifacts/inbox": { "post": { "operationId": "InboxController.create", @@ -7542,6 +8994,182 @@ }, "required": ["presentedDigest"] }, + "CreateWorkspaceDto": { + "type": "object", + "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 } }, + "required": ["name"] + }, + "CreateProjectDto": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["INTERNAL", "CLIENT", "LOCATION", "ENGAGEMENT"] }, + "name": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + "required": ["kind", "name"] + }, + "MembershipRejectedResponseDto": { + "type": "object", + "properties": { + "accepted": { "type": "boolean", "enum": [false], "example": false }, + "code": { + "type": "string", + "enum": [ + "INVALID_IDENTIFIER", + "INVALID_SCOPE", + "INVALID_ROLE", + "INVALID_STATE", + "SCOPE_DENIED", + "NOT_FOUND", + "CONFLICT", + "EXPIRED", + "LAST_OWNER", + "UNAVAILABLE" + ] + } + }, + "required": ["accepted", "code"] + }, + "MembershipScopeDto": { + "type": "object", + "properties": { + "scopeType": { "type": "string", "enum": ["organization", "workspace", "project"] }, + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["scopeType", "organizationId"] + }, + "InviteMembershipDto": { + "type": "object", + "properties": { + "principalId": { "type": "string", "format": "uuid" }, + "scope": { "$ref": "#/components/schemas/MembershipScopeDto" }, + "roleId": { + "type": "string", + "enum": ["owner", "admin", "analyst", "operator", "approver", "viewer"] + } + }, + "required": ["principalId", "scope", "roleId"] + }, + "TransitionMembershipDto": { + "type": "object", + "properties": { + "expectedRevision": { "type": "number", "minimum": 1, "maximum": 9007199254740991 }, + "status": { "type": "string", "enum": ["ACTIVE", "SUSPENDED", "REMOVED"] } + }, + "required": ["expectedRevision", "status"] + }, + "AcceptMembershipDto": { + "type": "object", + "properties": { + "expectedRevision": { "type": "number", "minimum": 1, "maximum": 9007199254740991 } + }, + "required": ["expectedRevision"] + }, + "TransferOwnershipDto": { + "type": "object", + "properties": { + "expectedRevision": { "type": "number", "minimum": 1, "maximum": 9007199254740991 } + }, + "required": ["expectedRevision"] + }, + "BootstrapUserDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "displayName": { "type": "string" }, + "locale": { "type": "string", "enum": ["vi-VN", "en"] }, + "mfaState": { "type": "string", "enum": ["ENABLED", "NOT_CONFIGURED"] } + }, + "required": ["id", "displayName", "locale", "mfaState"] + }, + "BootstrapProjectDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "kind": { "type": "string", "enum": ["INTERNAL", "CLIENT", "LOCATION", "ENGAGEMENT"] }, + "status": { "type": "string", "enum": ["ACTIVE", "ARCHIVED"] } + }, + "required": ["id", "name", "kind", "status"] + }, + "BootstrapWorkspaceDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["ACTIVE", "ARCHIVED"] }, + "projects": { + "type": "array", + "items": { "$ref": "#/components/schemas/BootstrapProjectDto" } + } + }, + "required": ["id", "name", "status", "projects"] + }, + "BootstrapOrganizationDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "personal": { "type": "boolean" }, + "status": { "type": "string", "enum": ["ACTIVE", "SUSPENDED", "DEACTIVATED"] }, + "workspaces": { + "type": "array", + "items": { "$ref": "#/components/schemas/BootstrapWorkspaceDto" } + } + }, + "required": ["id", "name", "personal", "status", "workspaces"] + }, + "BootstrapScopeDto": { + "type": "object", + "properties": { + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["organizationId"] + }, + "BootstrapSessionDto": { + "type": "object", + "properties": { + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "projectId": { "type": "string", "format": "uuid" }, + "authorizationEpoch": { "type": "number", "minimum": 1 } + }, + "required": ["organizationId", "authorizationEpoch"] + }, + "BootstrapPlatformDto": { + "type": "object", + "properties": { "apiVersion": { "type": "string", "enum": ["v1"] } }, + "required": ["apiVersion"] + }, + "BootstrapValueDto": { + "type": "object", + "properties": { + "user": { "$ref": "#/components/schemas/BootstrapUserDto" }, + "organizations": { + "type": "array", + "items": { "$ref": "#/components/schemas/BootstrapOrganizationDto" } + }, + "recentScopes": { + "type": "array", + "items": { "$ref": "#/components/schemas/BootstrapScopeDto" } + }, + "session": { "$ref": "#/components/schemas/BootstrapSessionDto" }, + "platform": { "$ref": "#/components/schemas/BootstrapPlatformDto" } + }, + "required": ["user", "organizations", "recentScopes", "session", "platform"] + }, + "BootstrapResponseDto": { + "type": "object", + "properties": { + "accepted": { "type": "boolean" }, + "value": { "$ref": "#/components/schemas/BootstrapValueDto" }, + "code": { "type": "string", "enum": ["INVALID_IDENTIFIER", "NOT_FOUND", "UNAVAILABLE"] } + }, + "required": ["accepted"] + }, "CreateInboxItemDto": { "type": "object", "properties": { diff --git a/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql b/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql new file mode 100644 index 00000000..e065f5f2 --- /dev/null +++ b/services/api/prisma/migrations/20260803030000_iam_membership_scope_uniqueness/migration.sql @@ -0,0 +1,32 @@ +-- IAM-004: enforce one membership identity per principal and fully-qualified scope. +-- The expression coalesces nullable descendants so organization/workspace/project +-- scopes cannot bypass uniqueness through PostgreSQL NULL semantics. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "iam"."memberships" AS duplicate + GROUP BY + duplicate."principal_type", + duplicate."principal_id", + duplicate."scope_type", + duplicate."organization_id", + duplicate."workspace_id", + duplicate."project_id" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'duplicate IAM membership identities must be reconciled before applying scope uniqueness'; + END IF; +END +$$; + +CREATE UNIQUE INDEX "memberships_principal_scope_identity_key" +ON "iam"."memberships" ( + "principal_type", + "principal_id", + ( + "scope_type" || ':' || "organization_id"::text || ':' || + COALESCE("workspace_id"::text, '') || ':' || + COALESCE("project_id"::text, '') + ) +); diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index b75cc8db..279aff05 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -68,6 +68,8 @@ model ProjectIdentity { } model MembershipIdentity { + /// A null-safe raw expression index in 20260803030000_iam_membership_scope_uniqueness + /// enforces one principal/scope identity because Prisma composite uniques treat NULLs as distinct. id String @id @db.Uuid principalType String @map("principal_type") @db.VarChar(24) principalId String @map("principal_id") @db.Uuid diff --git a/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts new file mode 100644 index 00000000..72e47555 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.ts @@ -0,0 +1,267 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { + OrganizationIdentityV1, + ProjectIdentityV1, + WorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; + +import type { + IamHierarchyRepositoryPortV1, + IamHierarchyTransactionPortV1, +} from '../application/hierarchy-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; + +function cloneOrganization(value: OrganizationIdentityV1): OrganizationIdentityV1 { + return Object.freeze({ ...value }); +} + +function cloneWorkspace(value: WorkspaceIdentityV1): WorkspaceIdentityV1 { + return Object.freeze({ ...value }); +} + +function cloneProject(value: ProjectIdentityV1): ProjectIdentityV1 { + return Object.freeze({ ...value }); +} + +function ownedFieldsMatch(existing: TValue, expected: TValue): boolean { + const existingRecord = existing as Record; + const expectedRecord = expected as Record; + return Object.keys(expectedRecord).every((key) => + Object.is(existingRecord[key], expectedRecord[key]), + ); +} + +function organizationScope(organizationId: StableIdentifierV1): TenantScopeV1 { + return Object.freeze({ scopeType: 'organization', organizationId }); +} + +function workspaceScope( + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, +): TenantScopeV1 { + return Object.freeze({ scopeType: 'workspace', organizationId, workspaceId }); +} + +function projectScope( + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, + projectId: StableIdentifierV1, +): TenantScopeV1 { + return Object.freeze({ scopeType: 'project', organizationId, workspaceId, projectId }); +} + +function cloneMaps(source: { + readonly organizations: Map; + readonly workspaces: Map; + readonly projects: Map; +}): { + readonly organizations: Map; + readonly workspaces: Map; + readonly projects: Map; +} { + return { + organizations: new Map( + [...source.organizations].map(([id, value]) => [id, cloneOrganization(value)]), + ), + workspaces: new Map([...source.workspaces].map(([id, value]) => [id, cloneWorkspace(value)])), + projects: new Map([...source.projects].map(([id, value]) => [id, cloneProject(value)])), + }; +} + +function organizationVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, +): boolean { + return tenantScopesEqualV1(context.tenantScope, organizationScope(organizationId)); +} + +function workspaceVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, +): boolean { + return tenantScopeContainsV1(context.tenantScope, workspaceScope(organizationId, workspaceId)); +} + +function projectVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, + projectId: StableIdentifierV1, +): boolean { + return tenantScopeContainsV1( + context.tenantScope, + projectScope(organizationId, workspaceId, projectId), + ); +} + +/** In-memory IAM hierarchy adapter with the same tenant and immutability checks as persistence. */ +export class InMemoryIamHierarchyRepositoryAdapter implements IamHierarchyRepositoryPortV1 { + private organizations = new Map(); + private workspaces = new Map(); + private projects = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async seed(input: { + readonly organizations: readonly OrganizationIdentityV1[]; + readonly workspaces: readonly WorkspaceIdentityV1[]; + readonly projects: readonly ProjectIdentityV1[]; + }): Promise { + await Promise.resolve(); + this.organizations = new Map( + input.organizations.map((value) => [value.id, cloneOrganization(value)]), + ); + this.workspaces = new Map(input.workspaces.map((value) => [value.id, cloneWorkspace(value)])); + this.projects = new Map(input.projects.map((value) => [value.id, cloneProject(value)])); + } + + public async findOrganization( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const value = this.organizations.get(organizationId); + return value && organizationVisible(context, organizationId) + ? cloneOrganization(value) + : undefined; + } + + public async listOrganizations( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + if (context.tenantScope.scopeType !== 'organization') return []; + const value = this.organizations.get(context.tenantScope.organizationId); + return value ? [cloneOrganization(value)] : []; + } + + public async findWorkspace( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const value = this.workspaces.get(workspaceId); + return value && workspaceVisible(context, value.organizationId, value.id) + ? cloneWorkspace(value) + : undefined; + } + + public async listWorkspaces( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + if (!organizationVisible(context, organizationId)) return []; + return [...this.workspaces.values()] + .filter((value) => value.organizationId === organizationId) + .sort((left, right) => left.id.localeCompare(right.id)) + .map(cloneWorkspace); + } + + public async findProject( + context: IamTenantContextV1, + projectId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const value = this.projects.get(projectId); + return value && projectVisible(context, value.organizationId, value.workspaceId, value.id) + ? cloneProject(value) + : undefined; + } + + public async listProjects( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + return [...this.projects.values()] + .filter( + (value) => + value.workspaceId === workspaceId && + projectVisible(context, value.organizationId, value.workspaceId, value.id), + ) + .sort((left, right) => left.id.localeCompare(right.id)) + .map(cloneProject); + } + + public async saveOrganization( + context: IamTenantContextV1, + value: OrganizationIdentityV1, + ): Promise { + await Promise.resolve(); + if (!organizationVisible(context, value.id)) throw new Error('IAM_SCOPE_DENIED'); + const existing = this.organizations.get(value.id); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); + if (!existing) this.organizations.set(value.id, cloneOrganization(value)); + } + + public async saveWorkspace( + context: IamTenantContextV1, + value: WorkspaceIdentityV1, + ): Promise { + await Promise.resolve(); + if (!organizationVisible(context, value.organizationId)) throw new Error('IAM_SCOPE_DENIED'); + if (!this.organizations.has(value.organizationId)) throw new Error('IAM_PARENT_NOT_FOUND'); + const existing = this.workspaces.get(value.id); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); + if (!existing) this.workspaces.set(value.id, cloneWorkspace(value)); + } + + public async saveProject(context: IamTenantContextV1, value: ProjectIdentityV1): Promise { + await Promise.resolve(); + if (!workspaceVisible(context, value.organizationId, value.workspaceId)) + throw new Error('IAM_SCOPE_DENIED'); + if ( + !this.workspaces.has(value.workspaceId) || + this.workspaces.get(value.workspaceId)?.organizationId !== value.organizationId + ) + throw new Error('IAM_PARENT_NOT_FOUND'); + const existing = this.projects.get(value.id); + if (existing && !ownedFieldsMatch(existing, value)) throw new Error('IAM_HIERARCHY_CONFLICT'); + if (!existing) this.projects.set(value.id, cloneProject(value)); + } + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: IamHierarchyTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + return previous.then(async () => { + const before = cloneMaps({ + organizations: this.organizations, + workspaces: this.workspaces, + projects: this.projects, + }); + try { + return await work({ + findOrganization: this.findOrganization.bind(this), + listOrganizations: this.listOrganizations.bind(this), + findWorkspace: this.findWorkspace.bind(this), + listWorkspaces: this.listWorkspaces.bind(this), + findProject: this.findProject.bind(this), + listProjects: this.listProjects.bind(this), + saveOrganization: this.saveOrganization.bind(this), + saveWorkspace: this.saveWorkspace.bind(this), + saveProject: this.saveProject.bind(this), + }); + } catch (error) { + this.organizations = before.organizations; + this.workspaces = before.workspaces; + this.projects = before.projects; + throw error; + } finally { + void context; + release(); + } + }); + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index a0ae8ca5..dc7c7592 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -1,5 +1,6 @@ import { tenantScopeContainsV1, + tenantScopesEqualV1, type StableIdentifierV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -54,6 +55,13 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const existing = this.memberships.find((item) => item.id === membership.id); + const duplicate = this.memberships.find( + (item) => + item.id !== membership.id && + item.principalId === membership.principalId && + tenantScopesEqualV1(item.scope, membership.scope), + ); + if (duplicate) throw new Error('IAM_MEMBERSHIP_CONFLICT'); if (existing && context.expectedRevision !== existing.revision) throw new Error('IAM_REVISION_CONFLICT'); if (!existing && context.expectedRevision !== undefined) diff --git a/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts new file mode 100644 index 00000000..9cf8a46b --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.ts @@ -0,0 +1,506 @@ +import { + createOrganizationIdentityV1, + createProjectIdentityV1, + createWorkspaceIdentityV1, + type OrganizationIdentityV1, + type ProjectIdentityV1, + type WorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { + parseStrictUtcTimestampV1, + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + IamHierarchyRepositoryPortV1, + IamHierarchyTransactionPortV1, +} from '../application/hierarchy-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; + +/** The fields owned by the IAM hierarchy tables. Updated timestamps are deliberately excluded. */ +export interface OrganizationIdentityDatabaseRowV1 { + readonly id: string; + readonly name: string; + readonly personal: boolean; + readonly status: string; + readonly createdAt: Date; +} + +export interface WorkspaceIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly name: string; + readonly status: string; + readonly authorizationEpoch: number; + readonly createdAt: Date; +} + +export interface ProjectIdentityDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly workspaceId: string; + readonly kind: string; + readonly name: string; + readonly status: string; + readonly createdAt: Date; +} + +interface IdentityDelegateV1 { + findUnique(input: { readonly where: { readonly id: string } }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + create(input: { readonly data: TRow }): Promise; +} + +export interface IamHierarchyDatabaseClientV1 { + readonly organizationIdentity: IdentityDelegateV1; + readonly workspaceIdentity: IdentityDelegateV1; + readonly projectIdentity: IdentityDelegateV1; + $transaction( + work: (transaction: IamHierarchyDatabaseClientV1) => Promise, + ): Promise; +} + +interface IamHierarchyDiagnosticsV1 { + readonly onMalformedOrganizationRow?: (organizationId: string) => void; + readonly onMalformedWorkspaceRow?: (workspaceId: string) => void; + readonly onMalformedProjectRow?: (projectId: string) => void; +} + +function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!(input instanceof Date) || !Number.isFinite(input.getTime())) return undefined; + try { + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; + } catch { + return undefined; + } +} + +function valuesEqual(left: unknown, right: unknown): boolean { + if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime(); + return left === right; +} + +function ownedFieldsMatch(existing: TRow, expected: TRow): boolean { + const existingRecord = existing as Record; + const expectedRecord = expected as Record; + return Object.keys(expectedRecord).every((key) => + valuesEqual(existingRecord[key], expectedRecord[key]), + ); +} + +function organizationFromRow(row: OrganizationIdentityDatabaseRowV1): OrganizationIdentityV1 { + const parsed = createOrganizationIdentityV1({ + id: row.id, + name: row.name, + personal: row.personal, + status: row.status, + createdAt: timestamp(row.createdAt), + }); + if (!parsed.accepted) throw new Error('IAM_PERSISTED_ORGANIZATION_INVALID'); + return parsed.value; +} + +function workspaceFromRow(row: WorkspaceIdentityDatabaseRowV1): WorkspaceIdentityV1 { + const parsed = createWorkspaceIdentityV1({ + id: row.id, + organizationId: row.organizationId, + name: row.name, + status: row.status, + authorizationEpoch: row.authorizationEpoch, + createdAt: timestamp(row.createdAt), + }); + if (!parsed.accepted) throw new Error('IAM_PERSISTED_WORKSPACE_INVALID'); + return parsed.value; +} + +function projectFromRow(row: ProjectIdentityDatabaseRowV1): ProjectIdentityV1 { + const parsed = createProjectIdentityV1({ + id: row.id, + organizationId: row.organizationId, + workspaceId: row.workspaceId, + kind: row.kind, + name: row.name, + status: row.status, + createdAt: timestamp(row.createdAt), + }); + if (!parsed.accepted) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + return parsed.value; +} + +function organizationRow(value: OrganizationIdentityV1): OrganizationIdentityDatabaseRowV1 { + return { + id: value.id, + name: value.name, + personal: value.personal, + status: value.status, + createdAt: new Date(value.createdAt), + }; +} + +function workspaceRow(value: WorkspaceIdentityV1): WorkspaceIdentityDatabaseRowV1 { + return { + id: value.id, + organizationId: value.organizationId, + name: value.name, + status: value.status, + authorizationEpoch: value.authorizationEpoch, + createdAt: new Date(value.createdAt), + }; +} + +function projectRow(value: ProjectIdentityV1): ProjectIdentityDatabaseRowV1 { + return { + id: value.id, + organizationId: value.organizationId, + workspaceId: value.workspaceId, + kind: value.kind, + name: value.name, + status: value.status, + createdAt: new Date(value.createdAt), + }; +} + +function organizationScope(organizationId: StableIdentifierV1): TenantScopeV1 { + return { scopeType: 'organization', organizationId }; +} + +function workspaceScope( + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, +): TenantScopeV1 { + return { scopeType: 'workspace', organizationId, workspaceId }; +} + +function projectScope( + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, + projectId: StableIdentifierV1, +): TenantScopeV1 { + return { scopeType: 'project', organizationId, workspaceId, projectId }; +} + +function organizationVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, +): boolean { + return tenantScopesEqualV1(context.tenantScope, organizationScope(organizationId)); +} + +function workspaceVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, +): boolean { + return tenantScopeContainsV1(context.tenantScope, workspaceScope(organizationId, workspaceId)); +} + +function projectVisible( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + workspaceId: StableIdentifierV1, + projectId: StableIdentifierV1, +): boolean { + return tenantScopeContainsV1( + context.tenantScope, + projectScope(organizationId, workspaceId, projectId), + ); +} + +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + +function reportMalformed( + diagnostics: IamHierarchyDiagnosticsV1, + kind: 'organization' | 'workspace' | 'project', + id: string, +): never { + try { + if (kind === 'organization') diagnostics.onMalformedOrganizationRow?.(id); + if (kind === 'workspace') diagnostics.onMalformedWorkspaceRow?.(id); + if (kind === 'project') diagnostics.onMalformedProjectRow?.(id); + } catch { + // Diagnostics are best-effort; persistence corruption remains fail-closed. + } + throw new Error( + kind === 'organization' + ? 'IAM_PERSISTED_ORGANIZATION_INVALID' + : kind === 'workspace' + ? 'IAM_PERSISTED_WORKSPACE_INVALID' + : 'IAM_PERSISTED_PROJECT_INVALID', + ); +} + +function organizationFromRowWithDiagnostics( + row: OrganizationIdentityDatabaseRowV1, + diagnostics: IamHierarchyDiagnosticsV1, +): OrganizationIdentityV1 { + try { + return organizationFromRow(row); + } catch { + return reportMalformed(diagnostics, 'organization', row.id); + } +} + +function workspaceFromRowWithDiagnostics( + row: WorkspaceIdentityDatabaseRowV1, + diagnostics: IamHierarchyDiagnosticsV1, +): WorkspaceIdentityV1 { + try { + return workspaceFromRow(row); + } catch { + return reportMalformed(diagnostics, 'workspace', row.id); + } +} + +function projectFromRowWithDiagnostics( + row: ProjectIdentityDatabaseRowV1, + diagnostics: IamHierarchyDiagnosticsV1, +): ProjectIdentityV1 { + try { + return projectFromRow(row); + } catch { + return reportMalformed(diagnostics, 'project', row.id); + } +} + +class PrismaIamHierarchyTransactionAdapter implements IamHierarchyTransactionPortV1 { + public constructor( + private readonly client: IamHierarchyDatabaseClientV1, + private readonly diagnostics: IamHierarchyDiagnosticsV1, + ) {} + + public async findOrganization( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise { + if (!organizationVisible(context, organizationId)) return undefined; + const row = await this.client.organizationIdentity.findUnique({ + where: { id: organizationId }, + }); + return row ? organizationFromRowWithDiagnostics(row, this.diagnostics) : undefined; + } + + public async listOrganizations( + context: IamTenantContextV1, + ): Promise { + if (context.tenantScope.scopeType !== 'organization') return []; + const rows = await this.client.organizationIdentity.findMany({ + where: { id: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.map((row) => organizationFromRowWithDiagnostics(row, this.diagnostics)); + } + + public async findWorkspace( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise { + const row = await this.client.workspaceIdentity.findUnique({ where: { id: workspaceId } }); + if (!row) return undefined; + const workspace = workspaceFromRowWithDiagnostics(row, this.diagnostics); + return workspaceVisible(context, workspace.organizationId, workspace.id) + ? workspace + : undefined; + } + + public async listWorkspaces( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise { + if (!organizationVisible(context, organizationId)) return []; + const rows = await this.client.workspaceIdentity.findMany({ + where: { organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.map((row) => workspaceFromRowWithDiagnostics(row, this.diagnostics)); + } + + public async findProject( + context: IamTenantContextV1, + projectId: StableIdentifierV1, + ): Promise { + const row = await this.client.projectIdentity.findUnique({ where: { id: projectId } }); + if (!row) return undefined; + const project = projectFromRowWithDiagnostics(row, this.diagnostics); + return projectVisible(context, project.organizationId, project.workspaceId, project.id) + ? project + : undefined; + } + + public async listProjects( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise { + const rows = await this.client.projectIdentity.findMany({ + where: { organizationId: context.tenantScope.organizationId, workspaceId }, + orderBy: { id: 'asc' }, + }); + return rows + .filter( + (row) => + context.tenantScope.scopeType !== 'project' || row.id === context.tenantScope.projectId, + ) + .map((row) => projectFromRowWithDiagnostics(row, this.diagnostics)); + } + + public async saveOrganization( + context: IamTenantContextV1, + value: OrganizationIdentityV1, + ): Promise { + const validated = createOrganizationIdentityV1(value); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + if (!organizationVisible(context, validated.value.id)) throw new Error('IAM_SCOPE_DENIED'); + await this.saveImmutable( + this.client.organizationIdentity, + organizationRow(validated.value), + 'IAM_HIERARCHY_CONFLICT', + ); + } + + public async saveWorkspace( + context: IamTenantContextV1, + value: WorkspaceIdentityV1, + ): Promise { + const validated = createWorkspaceIdentityV1(value); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + if (!organizationVisible(context, validated.value.organizationId)) + throw new Error('IAM_SCOPE_DENIED'); + const parent = await this.client.organizationIdentity.findUnique({ + where: { id: validated.value.organizationId }, + }); + if (!parent) throw new Error('IAM_PARENT_NOT_FOUND'); + organizationFromRowWithDiagnostics(parent, this.diagnostics); + await this.saveImmutable( + this.client.workspaceIdentity, + workspaceRow(validated.value), + 'IAM_HIERARCHY_CONFLICT', + ); + } + + public async saveProject(context: IamTenantContextV1, value: ProjectIdentityV1): Promise { + const validated = createProjectIdentityV1(value); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + if (!workspaceVisible(context, validated.value.organizationId, validated.value.workspaceId)) + throw new Error('IAM_SCOPE_DENIED'); + const parent = await this.client.workspaceIdentity.findUnique({ + where: { id: validated.value.workspaceId }, + }); + if (!parent) throw new Error('IAM_PARENT_NOT_FOUND'); + const workspace = workspaceFromRowWithDiagnostics(parent, this.diagnostics); + if (workspace.organizationId !== validated.value.organizationId) + throw new Error('IAM_PARENT_NOT_FOUND'); + await this.saveImmutable( + this.client.projectIdentity, + projectRow(validated.value), + 'IAM_HIERARCHY_CONFLICT', + ); + } + + private async saveImmutable( + delegate: IdentityDelegateV1, + expected: TRow, + conflictCode: string, + ): Promise { + const existing = await delegate.findUnique({ where: { id: expected.id } }); + if (existing) { + if (!ownedFieldsMatch(existing, expected)) throw new Error(conflictCode); + return; + } + try { + await delegate.create({ data: expected }); + } catch (error) { + if (isUniqueConstraintViolation(error)) throw new Error(conflictCode); + throw error; + } + } +} + +/** Prisma-backed IAM hierarchy adapter. Reads are scope-filtered and writes are immutable. */ +export class PrismaIamHierarchyRepositoryAdapter implements IamHierarchyRepositoryPortV1 { + private readonly diagnostics: IamHierarchyDiagnosticsV1; + + public constructor( + private readonly client: IamHierarchyDatabaseClientV1, + diagnostics: IamHierarchyDiagnosticsV1 = {}, + ) { + this.diagnostics = diagnostics; + } + + private transaction(): PrismaIamHierarchyTransactionAdapter { + return new PrismaIamHierarchyTransactionAdapter(this.client, this.diagnostics); + } + + public findOrganization(context: IamTenantContextV1, organizationId: StableIdentifierV1) { + return this.transaction().findOrganization(context, organizationId); + } + + public listOrganizations(context: IamTenantContextV1) { + return this.transaction().listOrganizations(context); + } + + public findWorkspace(context: IamTenantContextV1, workspaceId: StableIdentifierV1) { + return this.transaction().findWorkspace(context, workspaceId); + } + + public listWorkspaces(context: IamTenantContextV1, organizationId: StableIdentifierV1) { + return this.transaction().listWorkspaces(context, organizationId); + } + + public findProject(context: IamTenantContextV1, projectId: StableIdentifierV1) { + return this.transaction().findProject(context, projectId); + } + + public listProjects(context: IamTenantContextV1, workspaceId: StableIdentifierV1) { + return this.transaction().listProjects(context, workspaceId); + } + + public saveOrganization(context: IamTenantContextV1, value: OrganizationIdentityV1) { + return this.client.$transaction((transaction) => + new PrismaIamHierarchyTransactionAdapter(transaction, this.diagnostics).saveOrganization( + context, + value, + ), + ); + } + + public saveWorkspace(context: IamTenantContextV1, value: WorkspaceIdentityV1) { + return this.client.$transaction((transaction) => + new PrismaIamHierarchyTransactionAdapter(transaction, this.diagnostics).saveWorkspace( + context, + value, + ), + ); + } + + public saveProject(context: IamTenantContextV1, value: ProjectIdentityV1) { + return this.client.$transaction((transaction) => + new PrismaIamHierarchyTransactionAdapter(transaction, this.diagnostics).saveProject( + context, + value, + ), + ); + } + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: IamHierarchyTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIamHierarchyTransactionAdapter(transaction, this.diagnostics)), + ); + } +} 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 48590c17..ab16723a 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 @@ -250,6 +250,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { data: { roleId: membership.roleId, status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, revision: membership.revision, }, }); diff --git a/services/api/src/features/iam/api/bootstrap.controller.ts b/services/api/src/features/iam/api/bootstrap.controller.ts new file mode 100644 index 00000000..53667de8 --- /dev/null +++ b/services/api/src/features/iam/api/bootstrap.controller.ts @@ -0,0 +1,91 @@ +import { Controller, Get, Inject, Optional, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + IDENTITY_BOOTSTRAP_SERVICE, + type IdentityBootstrapService, +} from '../application/identity-bootstrap.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { BootstrapResponseDto } from './bootstrap.dto.js'; + +/** IAM-001/IAM-009: bootstrap is derived from the authenticated principal, never request scope. */ +@ApiTags('identity') +@ApiBearerAuth() +@Controller('v1/me') +export class IamBootstrapController { + public constructor( + @Optional() + @Inject(IDENTITY_BOOTSTRAP_SERVICE) + private readonly identityBootstrap: IdentityBootstrapService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('bootstrap') + @ApiOperation({ summary: 'Load safe identity and personal-tenant bootstrap state' }) + @ApiOkResponse({ type: BootstrapResponseDto }) + async bootstrap(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + if (this.identityBootstrap === undefined) + return Object.freeze({ accepted: false, code: 'UNAVAILABLE' as const }); + const result = await this.identityBootstrap.find(context.actorId); + if (!result.accepted) return result; + const value = result.value; + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ + user: Object.freeze({ + id: value.user.id, + displayName: value.user.displayName, + locale: value.user.locale, + mfaState: + context.mfaRequired === true ? ('ENABLED' as const) : ('NOT_CONFIGURED' as const), + }), + organizations: Object.freeze([ + Object.freeze({ + id: value.organization.id, + name: value.organization.name, + personal: value.organization.personal, + status: value.organization.status, + workspaces: Object.freeze([ + Object.freeze({ + id: value.workspace.id, + name: value.workspace.name, + status: value.workspace.status, + projects: Object.freeze([ + Object.freeze({ + id: value.project.id, + name: value.project.name, + kind: value.project.kind, + status: value.project.status, + }), + ]), + }), + ]), + }), + ]), + recentScopes: Object.freeze([ + Object.freeze({ + organizationId: value.organization.id, + workspaceId: value.workspace.id, + projectId: value.project.id, + }), + ]), + session: Object.freeze({ + organizationId: context.tenantScope.organizationId, + ...(context.tenantScope.scopeType === 'organization' + ? {} + : { workspaceId: context.tenantScope.workspaceId }), + ...(context.tenantScope.scopeType === 'project' + ? { projectId: context.tenantScope.projectId } + : {}), + authorizationEpoch: context.authorizationEpoch, + }), + platform: Object.freeze({ apiVersion: 'v1' as const }), + }), + }); + } +} diff --git a/services/api/src/features/iam/api/bootstrap.dto.ts b/services/api/src/features/iam/api/bootstrap.dto.ts new file mode 100644 index 00000000..ae446ad1 --- /dev/null +++ b/services/api/src/features/iam/api/bootstrap.dto.ts @@ -0,0 +1,118 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BootstrapUserDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + displayName!: string; + + @ApiProperty({ enum: ['vi-VN', 'en'] }) + locale!: 'vi-VN' | 'en'; + + @ApiProperty({ enum: ['ENABLED', 'NOT_CONFIGURED'] }) + mfaState!: 'ENABLED' | 'NOT_CONFIGURED'; +} + +export class BootstrapProjectDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ enum: ['INTERNAL', 'CLIENT', 'LOCATION', 'ENGAGEMENT'] }) + kind!: 'INTERNAL' | 'CLIENT' | 'LOCATION' | 'ENGAGEMENT'; + + @ApiProperty({ enum: ['ACTIVE', 'ARCHIVED'] }) + status!: 'ACTIVE' | 'ARCHIVED'; +} + +export class BootstrapWorkspaceDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ enum: ['ACTIVE', 'ARCHIVED'] }) + status!: 'ACTIVE' | 'ARCHIVED'; + + @ApiProperty({ type: [BootstrapProjectDto] }) + projects!: BootstrapProjectDto[]; +} + +export class BootstrapOrganizationDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty() + personal!: boolean; + + @ApiProperty({ enum: ['ACTIVE', 'SUSPENDED', 'DEACTIVATED'] }) + status!: 'ACTIVE' | 'SUSPENDED' | 'DEACTIVATED'; + + @ApiProperty({ type: [BootstrapWorkspaceDto] }) + workspaces!: BootstrapWorkspaceDto[]; +} + +export class BootstrapScopeDto { + @ApiProperty({ format: 'uuid' }) + organizationId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + workspaceId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + projectId?: string; +} + +export class BootstrapSessionDto { + @ApiProperty({ format: 'uuid' }) + organizationId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + workspaceId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + projectId?: string; + + @ApiProperty({ minimum: 1 }) + authorizationEpoch!: number; +} + +export class BootstrapPlatformDto { + @ApiProperty({ enum: ['v1'] }) + apiVersion!: 'v1'; +} + +export class BootstrapValueDto { + @ApiProperty({ type: BootstrapUserDto }) + user!: BootstrapUserDto; + + @ApiProperty({ type: [BootstrapOrganizationDto] }) + organizations!: BootstrapOrganizationDto[]; + + @ApiProperty({ type: [BootstrapScopeDto] }) + recentScopes!: BootstrapScopeDto[]; + + @ApiProperty({ type: BootstrapSessionDto }) + session!: BootstrapSessionDto; + + @ApiProperty({ type: BootstrapPlatformDto }) + platform!: BootstrapPlatformDto; +} + +export class BootstrapResponseDto { + @ApiProperty() + accepted!: boolean; + + @ApiPropertyOptional({ type: BootstrapValueDto }) + value?: BootstrapValueDto; + + @ApiPropertyOptional({ enum: ['INVALID_IDENTIFIER', 'NOT_FOUND', 'UNAVAILABLE'] }) + code?: 'INVALID_IDENTIFIER' | 'NOT_FOUND' | 'UNAVAILABLE'; +} diff --git a/services/api/src/features/iam/api/hierarchy.controller.ts b/services/api/src/features/iam/api/hierarchy.controller.ts new file mode 100644 index 00000000..4967f779 --- /dev/null +++ b/services/api/src/features/iam/api/hierarchy.controller.ts @@ -0,0 +1,140 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + Req, + Res, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { + IAM_HIERARCHY_SERVICE, + type IamHierarchyService, +} from '../application/hierarchy.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { CreateProjectDto, CreateWorkspaceDto } from './hierarchy.dto.js'; +import type { FastifyReply } from 'fastify'; + +type HierarchyResult = { readonly accepted: boolean; readonly code?: string }; + +function preserveNotFoundStatus( + result: TValue, + reply?: FastifyReply, +): TValue { + if (!result.accepted && result.code === 'NOT_FOUND') reply?.code(HttpStatus.NOT_FOUND); + return result; +} + +/** IAM-001, IAM-003, IAM-019: content-free tenant hierarchy administration. */ +@ApiTags('identity') +@ApiBearerAuth() +@Controller('v1') +export class IamHierarchyController { + public constructor( + @Inject(IAM_HIERARCHY_SERVICE) private readonly hierarchy: IamHierarchyService, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('organizations/:organizationId') + @ApiOperation({ summary: 'Read one organization inside the authenticated tenant scope' }) + @ApiOkResponse({ description: 'The organization metadata.' }) + @ApiNotFoundResponse({ description: 'The organization is not visible.' }) + async getOrganization( + @Req() request: unknown, + @Param('organizationId') organizationId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveNotFoundStatus( + await this.hierarchy.getOrganization(context, organizationId), + reply, + ); + } + + @Get('organizations/:organizationId/workspaces') + @ApiOperation({ summary: 'List workspaces in the authenticated organization scope' }) + @ApiOkResponse({ description: 'The content-free workspace list.' }) + async listWorkspaces( + @Req() request: unknown, + @Param('organizationId') organizationId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.hierarchy.listWorkspaces(context, organizationId); + } + + @Post('organizations/:organizationId/workspaces') + @HttpCode(200) + @ApiOperation({ summary: 'Create an immutable workspace in the authenticated organization' }) + @ApiBody({ type: CreateWorkspaceDto }) + async createWorkspace( + @Req() request: unknown, + @Param('organizationId') organizationId: string, + @Body() input: CreateWorkspaceDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.hierarchy.createWorkspace(context, organizationId, input.name); + } + + @Get('workspaces/:workspaceId') + @ApiOperation({ summary: 'Read one workspace inside the authenticated tenant scope' }) + @ApiOkResponse({ description: 'The workspace metadata.' }) + @ApiNotFoundResponse({ description: 'The workspace is not visible.' }) + async getWorkspace( + @Req() request: unknown, + @Param('workspaceId') workspaceId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ) { + const context = await this.requestContext.resolve(request); + return preserveNotFoundStatus(await this.hierarchy.getWorkspace(context, workspaceId), reply); + } + + @Get('workspaces/:workspaceId/projects') + @ApiOperation({ summary: 'List projects in the authenticated workspace scope' }) + @ApiOkResponse({ description: 'The content-free project list.' }) + async listProjects(@Req() request: unknown, @Param('workspaceId') workspaceId: string) { + const context = await this.requestContext.resolve(request); + return this.hierarchy.listProjects(context, workspaceId); + } + + @Post('workspaces/:workspaceId/projects') + @HttpCode(200) + @ApiOperation({ summary: 'Create an immutable project in the authenticated workspace' }) + @ApiBody({ type: CreateProjectDto }) + async createProject( + @Req() request: unknown, + @Param('workspaceId') workspaceId: string, + @Body() input: CreateProjectDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.hierarchy.createProject(context, workspaceId, input.kind, input.name); + } + + @Get('projects/:projectId') + @ApiOperation({ summary: 'Read one project inside the authenticated tenant scope' }) + @ApiOkResponse({ description: 'The project metadata.' }) + @ApiNotFoundResponse({ description: 'The project is not visible.' }) + async getProject( + @Req() request: unknown, + @Param('projectId') projectId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ) { + const context = await this.requestContext.resolve(request); + return preserveNotFoundStatus(await this.hierarchy.getProject(context, projectId), reply); + } +} diff --git a/services/api/src/features/iam/api/hierarchy.dto.ts b/services/api/src/features/iam/api/hierarchy.dto.ts new file mode 100644 index 00000000..a8dbc5d3 --- /dev/null +++ b/services/api/src/features/iam/api/hierarchy.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsString, MaxLength, MinLength } from 'class-validator'; + +const PROJECT_KINDS = ['INTERNAL', 'CLIENT', 'LOCATION', 'ENGAGEMENT'] as const; +type ProjectKindDtoV1 = (typeof PROJECT_KINDS)[number]; + +export class CreateWorkspaceDto { + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; +} + +export class CreateProjectDto { + @ApiProperty({ enum: PROJECT_KINDS }) + @IsIn(PROJECT_KINDS) + kind!: ProjectKindDtoV1; + + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; +} diff --git a/services/api/src/features/iam/api/membership.controller.ts b/services/api/src/features/iam/api/membership.controller.ts new file mode 100644 index 00000000..64a39a24 --- /dev/null +++ b/services/api/src/features/iam/api/membership.controller.ts @@ -0,0 +1,211 @@ +import { + applyDecorators, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Optional, + Param, + Post, + Req, + Res, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiForbiddenResponse, + ApiGoneResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; + +import { + IAM_MEMBERSHIP_SERVICE, + type IamMembershipService, +} from '../application/membership.service.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + AcceptMembershipDto, + InviteMembershipDto, + MembershipRejectedResponseDto, + TransferOwnershipDto, + TransitionMembershipDto, +} from './membership.dto.js'; + +function membershipStatus(result: unknown): number { + if (typeof result !== 'object' || result === null || !('accepted' in result)) + return HttpStatus.SERVICE_UNAVAILABLE; + const candidate = result as { readonly accepted?: unknown; readonly code?: unknown }; + if (candidate.accepted === true) return HttpStatus.OK; + switch (candidate.code) { + case 'SCOPE_DENIED': + return HttpStatus.FORBIDDEN; + case 'NOT_FOUND': + return HttpStatus.NOT_FOUND; + case 'CONFLICT': + case 'LAST_OWNER': + return HttpStatus.CONFLICT; + case 'EXPIRED': + return HttpStatus.GONE; + case 'UNAVAILABLE': + return HttpStatus.SERVICE_UNAVAILABLE; + default: + return HttpStatus.BAD_REQUEST; + } +} + +function preserveMembershipStatus(result: TValue, reply?: FastifyReply): TValue { + reply?.code(membershipStatus(result)); + return result; +} + +function applyMembershipOutcomeResponses(): MethodDecorator { + return applyDecorators( + ApiBadRequestResponse({ + description: 'The request is invalid.', + type: MembershipRejectedResponseDto, + }), + ApiForbiddenResponse({ + description: 'The authenticated actor lacks the required scope.', + type: MembershipRejectedResponseDto, + }), + ApiNotFoundResponse({ + description: 'The membership is not visible.', + type: MembershipRejectedResponseDto, + }), + ApiConflictResponse({ + description: 'The membership revision or ownership invariant conflicts.', + type: MembershipRejectedResponseDto, + }), + ApiGoneResponse({ + description: 'The invitation has expired.', + type: MembershipRejectedResponseDto, + }), + ApiServiceUnavailableResponse({ + description: 'Membership persistence is unavailable.', + type: MembershipRejectedResponseDto, + }), + ); +} + +/** IAM-004: membership administration never accepts client-selected authority. */ +@ApiTags('identity') +@ApiBearerAuth() +@Controller('v1/memberships') +export class IamMembershipController { + public constructor( + @Optional() + @Inject(IAM_MEMBERSHIP_SERVICE) + private readonly memberships: IamMembershipService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private unavailable() { + return { accepted: false as const, code: 'UNAVAILABLE' as const }; + } + + @Get() + @ApiOperation({ summary: 'List memberships visible in the authenticated tenant scope' }) + @ApiOkResponse({ description: 'The membership list.' }) + @applyMembershipOutcomeResponses() + async list( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const result = this.memberships ? await this.memberships.list(context) : this.unavailable(); + return preserveMembershipStatus(result, reply); + } + + @Post() + @HttpCode(200) + @ApiOperation({ summary: 'Invite a principal with a bounded role and tenant scope' }) + @ApiBody({ type: InviteMembershipDto }) + @ApiOkResponse({ description: 'The invited membership.' }) + @applyMembershipOutcomeResponses() + async invite( + @Req() request: unknown, + @Body() input: InviteMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const result = this.memberships + ? await this.memberships.invite(context, input) + : this.unavailable(); + return preserveMembershipStatus(result, reply); + } + + @Post(':membershipId/transition') + @HttpCode(200) + @ApiOperation({ summary: 'Transition one membership with an optimistic revision' }) + @ApiBody({ type: TransitionMembershipDto }) + @ApiOkResponse({ description: 'The transitioned membership.' }) + @applyMembershipOutcomeResponses() + async transition( + @Req() request: unknown, + @Param('membershipId') membershipId: string, + @Body() input: TransitionMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const result = this.memberships + ? await this.memberships.transition( + context, + membershipId, + input.expectedRevision, + input.status, + ) + : this.unavailable(); + return preserveMembershipStatus(result, reply); + } + + @Post(':membershipId/accept') + @HttpCode(200) + @ApiOperation({ summary: 'Accept an invitation as the invited principal' }) + @ApiBody({ type: AcceptMembershipDto }) + @ApiOkResponse({ description: 'The accepted membership.' }) + @applyMembershipOutcomeResponses() + async accept( + @Req() request: unknown, + @Param('membershipId') membershipId: string, + @Body() input: AcceptMembershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const result = this.memberships + ? await this.memberships.accept(context, membershipId, input.expectedRevision) + : this.unavailable(); + return preserveMembershipStatus(result, reply); + } + + @Post(':membershipId/transfer-ownership') + @HttpCode(200) + @ApiOperation({ summary: 'Transfer organization ownership to an active member' }) + @ApiBody({ type: TransferOwnershipDto }) + @ApiOkResponse({ description: 'The transferred membership.' }) + @applyMembershipOutcomeResponses() + async transferOwnership( + @Req() request: unknown, + @Param('membershipId') membershipId: string, + @Body() input: TransferOwnershipDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const result = this.memberships + ? await this.memberships.transferOwnership(context, membershipId, input.expectedRevision) + : this.unavailable(); + return preserveMembershipStatus(result, reply); + } +} diff --git a/services/api/src/features/iam/api/membership.dto.ts b/services/api/src/features/iam/api/membership.dto.ts new file mode 100644 index 00000000..3a71d9b2 --- /dev/null +++ b/services/api/src/features/iam/api/membership.dto.ts @@ -0,0 +1,128 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsIn, + IsInt, + IsOptional, + IsUUID, + Max, + Min, + Validate, + ValidateNested, + ValidatorConstraint, +} from 'class-validator'; +import type { ValidationArguments, ValidatorConstraintInterface } from 'class-validator'; + +const MEMBERSHIP_SCOPE_TYPES = ['organization', 'workspace', 'project'] as const; +const MEMBERSHIP_ROLE_IDS = [ + 'owner', + 'admin', + 'analyst', + 'operator', + 'approver', + 'viewer', +] as const; +const MEMBERSHIP_ERROR_CODES = [ + 'INVALID_IDENTIFIER', + 'INVALID_SCOPE', + 'INVALID_ROLE', + 'INVALID_STATE', + 'SCOPE_DENIED', + 'NOT_FOUND', + 'CONFLICT', + 'EXPIRED', + 'LAST_OWNER', + 'UNAVAILABLE', +] as const; +type MembershipScopeTypeDtoV1 = (typeof MEMBERSHIP_SCOPE_TYPES)[number]; +type MembershipRoleIdDtoV1 = (typeof MEMBERSHIP_ROLE_IDS)[number]; +type MembershipErrorCodeDtoV1 = (typeof MEMBERSHIP_ERROR_CODES)[number]; + +@ValidatorConstraint({ name: 'membershipScopeShape', async: false }) +class MembershipScopeShapeConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const scope = args.object as Partial; + if (!MEMBERSHIP_SCOPE_TYPES.includes(scope.scopeType as MembershipScopeTypeDtoV1)) return true; + if (scope.scopeType === 'organization') + return scope.workspaceId === undefined && scope.projectId === undefined; + if (typeof scope.workspaceId !== 'string') return false; + if (scope.scopeType === 'workspace') return scope.projectId === undefined; + return typeof scope.projectId === 'string'; + } + + defaultMessage(): string { + return 'workspaceId and projectId must match scopeType'; + } +} + +export class MembershipScopeDto { + @ApiProperty({ enum: MEMBERSHIP_SCOPE_TYPES }) + @IsIn(MEMBERSHIP_SCOPE_TYPES) + @Validate(MembershipScopeShapeConstraint) + scopeType!: MembershipScopeTypeDtoV1; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + organizationId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + projectId?: string; +} + +export class InviteMembershipDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + principalId!: string; + + @ApiProperty({ type: MembershipScopeDto }) + @ValidateNested() + @Type(() => MembershipScopeDto) + scope!: MembershipScopeDto; + + @ApiProperty({ enum: MEMBERSHIP_ROLE_IDS }) + @IsIn(MEMBERSHIP_ROLE_IDS) + roleId!: MembershipRoleIdDtoV1; +} + +export class MembershipRejectedResponseDto { + @ApiProperty({ enum: [false], example: false }) + accepted!: false; + + @ApiProperty({ enum: MEMBERSHIP_ERROR_CODES }) + code!: MembershipErrorCodeDtoV1; +} + +export class TransitionMembershipDto { + @ApiProperty({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + expectedRevision!: number; + + @ApiProperty({ enum: ['ACTIVE', 'SUSPENDED', 'REMOVED'] }) + @IsIn(['ACTIVE', 'SUSPENDED', 'REMOVED']) + status!: 'ACTIVE' | 'SUSPENDED' | 'REMOVED'; +} + +export class AcceptMembershipDto { + @ApiProperty({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + expectedRevision!: number; +} + +export class TransferOwnershipDto { + @ApiProperty({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + expectedRevision!: number; +} diff --git a/services/api/src/features/iam/application/hierarchy-repository.port.ts b/services/api/src/features/iam/application/hierarchy-repository.port.ts new file mode 100644 index 00000000..b6673a3e --- /dev/null +++ b/services/api/src/features/iam/application/hierarchy-repository.port.ts @@ -0,0 +1,44 @@ +import type { + OrganizationIdentityV1, + ProjectIdentityV1, + WorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_HIERARCHY_REPOSITORY = Symbol('IAM_HIERARCHY_REPOSITORY'); + +export interface IamHierarchyTransactionPortV1 { + findOrganization( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise; + listOrganizations(context: IamTenantContextV1): Promise; + findWorkspace( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise; + listWorkspaces( + context: IamTenantContextV1, + organizationId: StableIdentifierV1, + ): Promise; + findProject( + context: IamTenantContextV1, + projectId: StableIdentifierV1, + ): Promise; + listProjects( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1, + ): Promise; + saveOrganization(context: IamTenantContextV1, value: OrganizationIdentityV1): Promise; + saveWorkspace(context: IamTenantContextV1, value: WorkspaceIdentityV1): Promise; + saveProject(context: IamTenantContextV1, value: ProjectIdentityV1): Promise; +} + +export interface IamHierarchyRepositoryPortV1 extends IamHierarchyTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: IamHierarchyTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iam/application/hierarchy.service.ts b/services/api/src/features/iam/application/hierarchy.service.ts new file mode 100644 index 00000000..333ccea5 --- /dev/null +++ b/services/api/src/features/iam/application/hierarchy.service.ts @@ -0,0 +1,278 @@ +import { randomUUID } from 'node:crypto'; + +import { + createProjectIdentityV1, + createWorkspaceIdentityV1, + isBoundedTextV1, + isProjectKindV1, + type OrganizationIdentityV1, + type ProjectIdentityV1, + type WorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { roleHasPermissionV1, PERMISSIONS_V1 } from '@databreeze/domain/permissions/v1'; + +import type { IamRepositoryPortV1 } from './iam-repository.port.js'; +import type { IamHierarchyRepositoryPortV1 } from './hierarchy-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_HIERARCHY_SERVICE = Symbol('IAM_HIERARCHY_SERVICE'); + +export type IamHierarchyApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_TEXT' + | 'INVALID_KIND' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UNAVAILABLE'; + +export type IamHierarchyApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: IamHierarchyApplicationCodeV1 }; + +export type IamHierarchyIdGeneratorV1 = () => string; +export type IamHierarchyClockV1 = () => Date; + +function accepted(value: TValue): IamHierarchyApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: IamHierarchyApplicationCodeV1): IamHierarchyApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function applicationError(error: unknown): IamHierarchyApplicationCodeV1 { + const message = error instanceof Error ? error.message : ''; + if (message === 'IAM_SCOPE_DENIED' || message === 'IAM_SCOPE_NARROWING_REQUIRED') + return 'SCOPE_DENIED'; + if (message === 'IAM_PARENT_NOT_FOUND' || message.endsWith('_NOT_FOUND')) return 'NOT_FOUND'; + if (message === 'IAM_HIERARCHY_CONFLICT' || message === 'IAM_REVISION_CONFLICT') + return 'CONFLICT'; + return 'UNAVAILABLE'; +} + +function identityCode(code: string): IamHierarchyApplicationCodeV1 { + if (code === 'INVALID_IDENTIFIER') return 'INVALID_IDENTIFIER'; + if (code === 'INVALID_TEXT') return 'INVALID_TEXT'; + if (code === 'INVALID_KIND') return 'INVALID_KIND'; + return 'UNAVAILABLE'; +} + +function parseId( + input: unknown, +): + | { readonly accepted: true; readonly value: StableIdentifierV1 } + | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed : { accepted: false, code: 'INVALID_IDENTIFIER' }; +} + +function isoNow(clock: IamHierarchyClockV1): string | undefined { + try { + const value = clock(); + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return undefined; + return value.toISOString(); + } catch { + return undefined; + } +} + +function workspaceScopeFor(context: IamTenantContextV1, workspaceId: StableIdentifierV1) { + return { + scopeType: 'workspace' as const, + organizationId: context.tenantScope.organizationId, + workspaceId, + }; +} + +/** IAM hierarchy use cases. Paths are checked against the authenticated scope; IDs are server-generated. */ +export class IamHierarchyService { + public constructor( + private readonly repository: IamHierarchyRepositoryPortV1, + private readonly idGenerator: IamHierarchyIdGeneratorV1 = () => randomUUID(), + private readonly clock: IamHierarchyClockV1 = () => new Date(), + private readonly authority?: IamRepositoryPortV1, + ) {} + + private async authorizeMutation( + context: IamTenantContextV1, + permission: (typeof PERMISSIONS_V1)[keyof typeof PERMISSIONS_V1], + ): Promise<'ALLOWED' | 'DENIED' | 'UNAVAILABLE'> { + if (!this.authority) return 'UNAVAILABLE'; + try { + const membership = await this.authority.findMembership(context, context.actorId); + return membership && roleHasPermissionV1(membership.roleId, permission) + ? 'ALLOWED' + : 'DENIED'; + } catch { + return 'UNAVAILABLE'; + } + } + + public async getOrganization( + context: IamTenantContextV1, + organizationIdInput: unknown, + ): Promise> { + const organizationId = parseId(organizationIdInput); + if (!organizationId.accepted) return rejected(organizationId.code); + try { + const value = await this.repository.findOrganization(context, organizationId.value); + return value ? accepted(value) : rejected('NOT_FOUND'); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async listWorkspaces( + context: IamTenantContextV1, + organizationIdInput: unknown, + ): Promise> { + const organizationId = parseId(organizationIdInput); + if (!organizationId.accepted) return rejected(organizationId.code); + if ( + context.tenantScope.scopeType !== 'organization' || + context.tenantScope.organizationId !== organizationId.value + ) + return rejected('SCOPE_DENIED'); + try { + return accepted(await this.repository.listWorkspaces(context, organizationId.value)); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async getWorkspace( + context: IamTenantContextV1, + workspaceIdInput: unknown, + ): Promise> { + const workspaceId = parseId(workspaceIdInput); + if (!workspaceId.accepted) return rejected(workspaceId.code); + try { + const value = await this.repository.findWorkspace(context, workspaceId.value); + return value ? accepted(value) : rejected('NOT_FOUND'); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async createWorkspace( + context: IamTenantContextV1, + organizationIdInput: unknown, + nameInput: unknown, + ): Promise> { + const organizationId = parseId(organizationIdInput); + if (!organizationId.accepted) return rejected(organizationId.code); + if ( + context.tenantScope.scopeType !== 'organization' || + context.tenantScope.organizationId !== organizationId.value + ) + return rejected('SCOPE_DENIED'); + const createdAt = isoNow(this.clock); + if (!createdAt) return rejected('UNAVAILABLE'); + if (!isBoundedTextV1(nameInput, 200)) return rejected('INVALID_TEXT'); + const authorization = await this.authorizeMutation( + context, + PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE, + ); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const parent = await transaction.findOrganization(context, organizationId.value); + if (!parent) return rejected('NOT_FOUND'); + const candidate = createWorkspaceIdentityV1({ + id: this.idGenerator(), + organizationId: parent.id, + name: nameInput, + createdAt, + }); + if (!candidate.accepted) return rejected(identityCode(candidate.code)); + await transaction.saveWorkspace(context, candidate.value); + return accepted(candidate.value); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async listProjects( + context: IamTenantContextV1, + workspaceIdInput: unknown, + ): Promise> { + const workspaceId = parseId(workspaceIdInput); + if (!workspaceId.accepted) return rejected(workspaceId.code); + if ( + context.tenantScope.scopeType === 'project' || + !tenantScopeContainsV1(context.tenantScope, workspaceScopeFor(context, workspaceId.value)) + ) + return rejected('SCOPE_DENIED'); + try { + return accepted(await this.repository.listProjects(context, workspaceId.value)); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async getProject( + context: IamTenantContextV1, + projectIdInput: unknown, + ): Promise> { + const projectId = parseId(projectIdInput); + if (!projectId.accepted) return rejected(projectId.code); + try { + const value = await this.repository.findProject(context, projectId.value); + return value ? accepted(value) : rejected('NOT_FOUND'); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async createProject( + context: IamTenantContextV1, + workspaceIdInput: unknown, + kindInput: unknown, + nameInput: unknown, + ): Promise> { + const workspaceId = parseId(workspaceIdInput); + if (!workspaceId.accepted) return rejected(workspaceId.code); + if ( + context.tenantScope.scopeType === 'project' || + !tenantScopeContainsV1(context.tenantScope, workspaceScopeFor(context, workspaceId.value)) + ) + return rejected('SCOPE_DENIED'); + const createdAt = isoNow(this.clock); + if (!createdAt) return rejected('UNAVAILABLE'); + if (!isProjectKindV1(kindInput)) return rejected('INVALID_KIND'); + if (!isBoundedTextV1(nameInput, 200)) return rejected('INVALID_TEXT'); + const authorization = await this.authorizeMutation( + context, + PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE, + ); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const parent = await transaction.findWorkspace(context, workspaceId.value); + if (!parent) return rejected('NOT_FOUND'); + const candidate = createProjectIdentityV1({ + id: this.idGenerator(), + organizationId: parent.organizationId, + workspaceId: parent.id, + kind: kindInput, + name: nameInput, + createdAt, + }); + if (!candidate.accepted) return rejected(identityCode(candidate.code)); + await transaction.saveProject(context, candidate.value); + return accepted(candidate.value); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } +} diff --git a/services/api/src/features/iam/application/iam-repository.port.ts b/services/api/src/features/iam/application/iam-repository.port.ts index f52be91b..8d1f8b61 100644 --- a/services/api/src/features/iam/application/iam-repository.port.ts +++ b/services/api/src/features/iam/application/iam-repository.port.ts @@ -1,4 +1,8 @@ -import type { StableIdentifierV1, TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { + StableIdentifierV1, + StrictUtcTimestampV1, + TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; import type { IamTenantContextV1 } from './tenant-context.js'; @@ -10,6 +14,8 @@ export interface IamMembershipRecordV1 { readonly scope: TenantScopeV1; readonly roleId: string; readonly status: 'INVITED' | 'ACTIVE' | 'SUSPENDED' | 'REMOVED'; + readonly startsAt?: StrictUtcTimestampV1; + readonly expiresAt?: StrictUtcTimestampV1; readonly revision: number; } diff --git a/services/api/src/features/iam/application/identity-bootstrap.service.ts b/services/api/src/features/iam/application/identity-bootstrap.service.ts index 812f84ea..4261bfa5 100644 --- a/services/api/src/features/iam/application/identity-bootstrap.service.ts +++ b/services/api/src/features/iam/application/identity-bootstrap.service.ts @@ -1,10 +1,20 @@ -import { bootstrapPersonalOrganizationV1 } from '@databreeze/domain/identity/v1'; +import { + bootstrapPersonalOrganizationV1, + type PersonalOrganizationBootstrapV1, +} from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; import type { IdentityBootstrapRepositoryPortV1, IdentityBootstrapResultV1, } from './identity-bootstrap-repository.port.js'; +export const IDENTITY_BOOTSTRAP_SERVICE = Symbol('IDENTITY_BOOTSTRAP_SERVICE'); + +export type IdentityBootstrapReadResultV1 = + | { readonly accepted: true; readonly value: PersonalOrganizationBootstrapV1 } + | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' | 'NOT_FOUND' | 'UNAVAILABLE' }; + function conflict(): IdentityBootstrapResultV1 { return Object.freeze({ accepted: false, code: 'BOOTSTRAP_CONFLICT' }); } @@ -29,4 +39,17 @@ export class IdentityBootstrapService { return Object.freeze({ accepted: true, value: validated.value }); }); } + + public async find(userIdInput: unknown): Promise { + const parsed = parseStableIdentifierV1(userIdInput); + if (!parsed.accepted) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); + try { + const value = await this.repository.findByUserId(parsed.value); + return value + ? Object.freeze({ accepted: true, value }) + : Object.freeze({ accepted: false, code: 'NOT_FOUND' }); + } catch { + return Object.freeze({ accepted: false, code: 'UNAVAILABLE' }); + } + } } diff --git a/services/api/src/features/iam/application/membership.service.ts b/services/api/src/features/iam/application/membership.service.ts new file mode 100644 index 00000000..7a6453f2 --- /dev/null +++ b/services/api/src/features/iam/application/membership.service.ts @@ -0,0 +1,399 @@ +import { randomUUID } from 'node:crypto'; + +import { + checkOwnerRemovalV1, + INVITATION_MAX_SECONDS_V1, + validateMembershipV1, + type MembershipIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { + PERMISSIONS_V1, + roleHasPermissionV1, + type PermissionV1, +} from '@databreeze/domain/permissions/v1'; +import { + parseStableIdentifierV1, + parseTenantScopeV1, + tenantScopeContainsV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + IamMembershipRecordV1, + IamRepositoryPortV1, + IamTransactionPortV1, +} from './iam-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_MEMBERSHIP_SERVICE = Symbol('IAM_MEMBERSHIP_SERVICE'); + +export type IamMembershipApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_ROLE' + | 'INVALID_STATE' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'EXPIRED' + | 'LAST_OWNER' + | 'UNAVAILABLE'; + +export type IamMembershipApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: IamMembershipApplicationCodeV1 }; + +export interface IamMembershipInviteInputV1 { + readonly principalId: unknown; + readonly scope: unknown; + readonly roleId: unknown; +} + +export type IamMembershipIdGeneratorV1 = () => string; +export type IamMembershipClockV1 = () => Date; + +function accepted(value: TValue): IamMembershipApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: IamMembershipApplicationCodeV1): IamMembershipApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function parseId( + input: unknown, +): + | { readonly accepted: true; readonly value: StableIdentifierV1 } + | { readonly accepted: false; readonly code: 'INVALID_IDENTIFIER' } { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed : { accepted: false, code: 'INVALID_IDENTIFIER' }; +} + +function parseScope( + input: unknown, +): + | { readonly accepted: true; readonly value: TenantScopeV1 } + | { readonly accepted: false; readonly code: 'INVALID_SCOPE' } { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed : { accepted: false, code: 'INVALID_SCOPE' }; +} + +function isoNow(clock: IamMembershipClockV1): string | undefined { + try { + const value = clock(); + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return undefined; + return value.toISOString(); + } catch { + return undefined; + } +} + +function applicationError(error: unknown): IamMembershipApplicationCodeV1 { + const message = error instanceof Error ? error.message : ''; + if (message === 'IAM_SCOPE_DENIED' || message === 'IAM_SCOPE_NARROWING_REQUIRED') + return 'SCOPE_DENIED'; + if (message === 'IAM_REVISION_CONFLICT' || message === 'IAM_MEMBERSHIP_CONFLICT') + return 'CONFLICT'; + if (message.endsWith('_NOT_FOUND')) return 'NOT_FOUND'; + return 'UNAVAILABLE'; +} + +function permissionFor(scope: TenantScopeV1) { + return scope.scopeType === 'organization' + ? PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE + : PERMISSIONS_V1.WORKSPACE_SETTINGS_MANAGE; +} + +function identityFromRecord(record: IamMembershipRecordV1): MembershipIdentityV1 { + return { + schemaVersion: 1, + id: record.id, + principalType: 'USER', + principalId: record.principalId, + scope: record.scope, + roleId: record.roleId as MembershipIdentityV1['roleId'], + status: record.status, + ...(record.startsAt === undefined ? {} : { startsAt: record.startsAt }), + ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }), + revision: record.revision, + }; +} + +/** IAM-004: scoped invitations and optimistic membership status transitions. */ +export class IamMembershipService { + public constructor( + private readonly repository: IamRepositoryPortV1, + private readonly idGenerator: IamMembershipIdGeneratorV1 = () => randomUUID(), + private readonly clock: IamMembershipClockV1 = () => new Date(), + ) {} + + private async authorize( + context: IamTenantContextV1, + scope: TenantScopeV1, + permission: PermissionV1 = permissionFor(scope), + repository: IamTransactionPortV1 = this.repository, + ): Promise<'ALLOWED' | 'DENIED' | 'UNAVAILABLE'> { + if (!tenantScopeContainsV1(context.tenantScope, scope)) return 'DENIED'; + try { + const membership = await repository.findMembership(context, context.actorId); + return membership && roleHasPermissionV1(membership.roleId, permission) + ? 'ALLOWED' + : 'DENIED'; + } catch { + return 'UNAVAILABLE'; + } + } + + public async list( + context: IamTenantContextV1, + ): Promise> { + const authorization = await this.authorize(context, context.tenantScope); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + try { + return accepted(await this.repository.listMemberships(context)); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async invite( + context: IamTenantContextV1, + input: IamMembershipInviteInputV1, + ): Promise> { + const principalId = parseId(input.principalId); + if (!principalId.accepted) return rejected(principalId.code); + const scope = parseScope(input.scope); + if (!scope.accepted) return rejected(scope.code); + const authorization = await this.authorize(context, scope.value); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + if (input.roleId === 'owner') { + if (scope.value.scopeType !== 'organization') return rejected('INVALID_STATE'); + try { + const actor = await this.repository.findMembership(context, context.actorId); + if (!actor || actor.roleId !== 'owner') return rejected('SCOPE_DENIED'); + } catch { + return rejected('UNAVAILABLE'); + } + } + const startedAt = isoNow(this.clock); + if (!startedAt) return rejected('UNAVAILABLE'); + const expiresAt = new Date( + Date.parse(startedAt) + INVITATION_MAX_SECONDS_V1 * 1_000, + ).toISOString(); + let candidateId: string; + try { + candidateId = this.idGenerator(); + } catch { + return rejected('UNAVAILABLE'); + } + const candidate = validateMembershipV1({ + id: candidateId, + principalType: 'USER', + principalId: principalId.value, + scope: scope.value, + roleId: input.roleId, + status: 'INVITED', + startsAt: startedAt, + expiresAt, + revision: 1, + }); + if (!candidate.accepted) { + if (candidate.code === 'INVALID_IDENTIFIER') return rejected('INVALID_IDENTIFIER'); + if (candidate.code === 'INVALID_SCOPE') return rejected('INVALID_SCOPE'); + if (candidate.code === 'INVALID_ROLE') return rejected('INVALID_ROLE'); + return rejected('INVALID_STATE'); + } + const record: IamMembershipRecordV1 = Object.freeze({ + id: candidate.value.id, + principalId: candidate.value.principalId, + scope: candidate.value.scope, + roleId: candidate.value.roleId, + status: candidate.value.status, + ...(candidate.value.startsAt === undefined ? {} : { startsAt: candidate.value.startsAt }), + ...(candidate.value.expiresAt === undefined ? {} : { expiresAt: candidate.value.expiresAt }), + revision: candidate.value.revision, + }); + try { + await this.repository.saveMembership(context, record); + return accepted(record); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async transition( + context: IamTenantContextV1, + membershipIdInput: unknown, + expectedRevisionInput: unknown, + statusInput: unknown, + ): Promise> { + const membershipId = parseId(membershipIdInput); + if (!membershipId.accepted) return rejected(membershipId.code); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + if (statusInput !== 'ACTIVE' && statusInput !== 'SUSPENDED' && statusInput !== 'REMOVED') + return rejected('INVALID_STATE'); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const memberships = await transaction.listMemberships(context); + const current = memberships.find((membership) => membership.id === membershipId.value); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize(context, current.scope, undefined, transaction); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + if (current.revision !== expectedRevisionInput) return rejected('CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('CONFLICT'); + if (statusInput !== 'ACTIVE' && current.roleId === 'owner') { + const actor = await transaction.findMembership(context, context.actorId); + if (!actor || actor.roleId !== 'owner') return rejected('SCOPE_DENIED'); + const ownerDecision = checkOwnerRemovalV1( + memberships.map(identityFromRecord), + current.id, + ); + if (ownerDecision === 'LAST_OWNER') return rejected('LAST_OWNER'); + } + const next = Object.freeze({ + ...current, + status: statusInput, + revision: current.revision + 1, + }); + const mutationContext = Object.freeze({ ...context, expectedRevision: current.revision }); + await transaction.saveMembership(mutationContext, next); + return accepted(next); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } + + /** Transfer organization ownership in one optimistic transaction. */ + public async transferOwnership( + context: IamTenantContextV1, + targetMembershipIdInput: unknown, + targetExpectedRevisionInput: unknown, + ): Promise> { + const targetMembershipId = parseId(targetMembershipIdInput); + if (!targetMembershipId.accepted) return rejected(targetMembershipId.code); + if ( + typeof targetExpectedRevisionInput !== 'number' || + !Number.isSafeInteger(targetExpectedRevisionInput) || + targetExpectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + if (context.tenantScope.scopeType !== 'organization') return rejected('SCOPE_DENIED'); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const actor = await transaction.findMembership(context, context.actorId); + if ( + !actor || + actor.scope.scopeType !== 'organization' || + actor.scope.organizationId !== context.tenantScope.organizationId + ) + return rejected('SCOPE_DENIED'); + const authorization = await this.authorize( + context, + actor.scope, + PERMISSIONS_V1.ORGANIZATION_OWNERSHIP_TRANSFER, + transaction, + ); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + const memberships = await transaction.listMemberships(context); + const target = memberships.find((membership) => membership.id === targetMembershipId.value); + if (!target) return rejected('NOT_FOUND'); + if ( + target.id === actor.id || + target.scope.scopeType !== 'organization' || + target.scope.organizationId !== context.tenantScope.organizationId + ) + return rejected('SCOPE_DENIED'); + if (target.status !== 'ACTIVE' || target.roleId === 'owner') + return rejected('INVALID_STATE'); + if (target.revision !== targetExpectedRevisionInput) return rejected('CONFLICT'); + const nextActor: IamMembershipRecordV1 = Object.freeze({ + ...actor, + roleId: 'admin', + revision: actor.revision + 1, + }); + const nextTarget: IamMembershipRecordV1 = Object.freeze({ + ...target, + roleId: 'owner', + revision: target.revision + 1, + }); + await transaction.saveMembership( + Object.freeze({ ...context, expectedRevision: actor.revision }), + nextActor, + ); + await transaction.saveMembership( + Object.freeze({ ...context, expectedRevision: target.revision }), + nextTarget, + ); + return accepted(nextTarget); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } + + /** Accept an invitation only by the invited principal, clearing invitation-only expiry. */ + public async accept( + context: IamTenantContextV1, + membershipIdInput: unknown, + expectedRevisionInput: unknown, + ): Promise> { + const membershipId = parseId(membershipIdInput); + if (!membershipId.accepted) return rejected(membershipId.code); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + const startedAt = isoNow(this.clock); + if (!startedAt) return rejected('UNAVAILABLE'); + const nowMs = Date.parse(startedAt); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const current = (await transaction.listMemberships(context)).find( + (membership) => membership.id === membershipId.value, + ); + if (!current) return rejected('NOT_FOUND'); + if ( + current.principalId !== context.actorId || + !tenantScopeContainsV1(context.tenantScope, current.scope) + ) + return rejected('SCOPE_DENIED'); + if (current.revision !== expectedRevisionInput) return rejected('CONFLICT'); + if (current.status !== 'INVITED') return rejected('CONFLICT'); + if (current.expiresAt !== undefined && Date.parse(current.expiresAt) <= nowMs) + return rejected('EXPIRED'); + if (current.startsAt !== undefined && Date.parse(current.startsAt) > nowMs) + return rejected('CONFLICT'); + const { + startsAt: _startsAt, + expiresAt: _expiresAt, + ...withoutInvitationLifetime + } = current; + void _startsAt; + void _expiresAt; + const next: IamMembershipRecordV1 = Object.freeze({ + ...withoutInvitationLifetime, + status: 'ACTIVE', + revision: current.revision + 1, + }); + const mutationContext = Object.freeze({ ...context, expectedRevision: current.revision }); + await transaction.saveMembership(mutationContext, next); + return accepted(next); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } +} diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 8efcbc89..9a12cecd 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -3,6 +3,9 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; import { MfaController } from './api/mfa.controller.js'; +import { IamHierarchyController } from './api/hierarchy.controller.js'; +import { IamMembershipController } from './api/membership.controller.js'; +import { IamBootstrapController } from './api/bootstrap.controller.js'; import { AuthenticationService } from './application/authentication.service.js'; import { AUTHENTICATION_USE_CASE, @@ -18,6 +21,10 @@ import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT, type IdentityBootstrapRepositoryPortV1, } from './application/identity-bootstrap-repository.port.js'; +import { + IDENTITY_BOOTSTRAP_SERVICE, + IdentityBootstrapService, +} from './application/identity-bootstrap.service.js'; import { MFA_REPOSITORY_PORT, type MfaRepositoryPortV1, @@ -32,6 +39,12 @@ import { IAM_REPOSITORY_PORT, type IamRepositoryPortV1, } from './application/iam-repository.port.js'; +import { + IAM_HIERARCHY_REPOSITORY, + type IamHierarchyRepositoryPortV1, +} from './application/hierarchy-repository.port.js'; +import { IAM_HIERARCHY_SERVICE, IamHierarchyService } from './application/hierarchy.service.js'; +import { IAM_MEMBERSHIP_SERVICE, IamMembershipService } from './application/membership.service.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -54,6 +67,11 @@ import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1, } from './adapter/prisma-iam-repository.adapter.js'; +import { InMemoryIamHierarchyRepositoryAdapter } from './adapter/in-memory-iam-hierarchy-repository.adapter.js'; +import { + PrismaIamHierarchyRepositoryAdapter, + type IamHierarchyDatabaseClientV1, +} from './adapter/prisma-iam-hierarchy-repository.adapter.js'; import { DeviceIdentityController } from './api/device-identity.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { @@ -85,6 +103,7 @@ export interface IamModuleOptions { readonly sessionDatabase?: SessionLifecycleDatabaseClientV1; readonly identityBootstrapRepository?: IdentityBootstrapRepositoryPortV1; readonly identityBootstrapDatabase?: IdentityBootstrapDatabaseClientV1; + readonly identityBootstrapService?: IdentityBootstrapService; readonly mfaRepository?: MfaRepositoryPortV1; readonly mfaDatabase?: MfaDatabaseClientV1; readonly mfaService?: MfaService; @@ -95,6 +114,10 @@ export interface IamModuleOptions { }; readonly iamRepository?: IamRepositoryPortV1; readonly iamDatabase?: IamDatabaseClientV1; + readonly hierarchyRepository?: IamHierarchyRepositoryPortV1; + readonly hierarchyDatabase?: IamHierarchyDatabaseClientV1; + readonly hierarchyService?: IamHierarchyService; + readonly membershipService?: IamMembershipService; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; @@ -143,6 +166,11 @@ export class IamModule { (options.identityBootstrapDatabase === undefined ? undefined : new PrismaIdentityBootstrapRepositoryAdapter(options.identityBootstrapDatabase)); + const identityBootstrapService = + options.identityBootstrapService ?? + (identityBootstrapRepository === undefined + ? undefined + : new IdentityBootstrapService(identityBootstrapRepository)); const mfaRepository = options.mfaRepository ?? (options.mfaDatabase === undefined @@ -165,6 +193,17 @@ export class IamModule { (options.iamDatabase === undefined ? undefined : new PrismaIamRepositoryAdapter(options.iamDatabase)); + const hierarchyRepository = + options.hierarchyRepository ?? + (options.hierarchyDatabase === undefined + ? new InMemoryIamHierarchyRepositoryAdapter() + : new PrismaIamHierarchyRepositoryAdapter(options.hierarchyDatabase)); + const hierarchyService = + options.hierarchyService ?? + new IamHierarchyService(hierarchyRepository, undefined, undefined, iamRepository); + const membershipService = + options.membershipService ?? + (iamRepository === undefined ? undefined : new IamMembershipService(iamRepository)); const authentication = options.authentication ?? (credentials && sessions @@ -181,16 +220,30 @@ export class IamModule { deviceIdentityRepository, options.deviceEnrollmentProofVerifier ?? new UnavailableDeviceEnrollmentProofVerifier(), ); - const exports = [DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE]; + const exports = [ + DEVICE_IDENTITY_REPOSITORY_PORT, + DEVICE_IDENTITY_SERVICE, + IAM_HIERARCHY_REPOSITORY, + IAM_HIERARCHY_SERVICE, + ]; if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); if (identityBootstrapRepository) exports.unshift(IDENTITY_BOOTSTRAP_REPOSITORY_PORT); + if (identityBootstrapService) exports.unshift(IDENTITY_BOOTSTRAP_SERVICE); if (mfaRepository) exports.unshift(MFA_REPOSITORY_PORT); if (mfaService) exports.unshift(MFA_SERVICE); if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); + if (membershipService) exports.unshift(IAM_MEMBERSHIP_SERVICE); return { module: IamModule, - controllers: [AuthenticationController, DeviceIdentityController, MfaController], + controllers: [ + AuthenticationController, + DeviceIdentityController, + MfaController, + IamHierarchyController, + IamMembershipController, + IamBootstrapController, + ], providers: [ { provide: AUTHENTICATION_USE_CASE, @@ -220,6 +273,14 @@ export class IamModule { }, ] : []), + ...(identityBootstrapService + ? [ + { + provide: IDENTITY_BOOTSTRAP_SERVICE, + useValue: identityBootstrapService, + }, + ] + : []), ...(mfaRepository ? [ { @@ -244,6 +305,22 @@ export class IamModule { }, ] : []), + { + provide: IAM_HIERARCHY_REPOSITORY, + useValue: hierarchyRepository, + }, + { + provide: IAM_HIERARCHY_SERVICE, + useValue: hierarchyService, + }, + ...(membershipService + ? [ + { + provide: IAM_MEMBERSHIP_SERVICE, + useValue: membershipService, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 9cc318c2..7a1c1f34 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -12,12 +12,27 @@ import { PrismaCredentialLookupAdapter } from '../../src/features/iam/adapter/pr import { PrismaSessionLifecycleAdapter } from '../../src/features/iam/adapter/prisma-session-lifecycle.adapter.js'; import { SESSION_LIFECYCLE_PORT } from '../../src/features/iam/application/session-lifecycle.port.js'; import { IDENTITY_BOOTSTRAP_REPOSITORY_PORT } from '../../src/features/iam/application/identity-bootstrap-repository.port.js'; +import { + IDENTITY_BOOTSTRAP_SERVICE, + IdentityBootstrapService, +} from '../../src/features/iam/application/identity-bootstrap.service.js'; import { PrismaIdentityBootstrapRepositoryAdapter } from '../../src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.js'; import { MFA_REPOSITORY_PORT } from '../../src/features/iam/application/mfa-repository.port.js'; import { PrismaMfaRepositoryAdapter } from '../../src/features/iam/adapter/prisma-mfa-repository.adapter.js'; import { MFA_SERVICE, MfaService } from '../../src/features/iam/application/mfa.service.js'; import { IAM_REPOSITORY_PORT } from '../../src/features/iam/application/iam-repository.port.js'; import { PrismaIamRepositoryAdapter } from '../../src/features/iam/adapter/prisma-iam-repository.adapter.js'; +import { IAM_HIERARCHY_REPOSITORY } from '../../src/features/iam/application/hierarchy-repository.port.js'; +import { + IAM_HIERARCHY_SERVICE, + IamHierarchyService, +} from '../../src/features/iam/application/hierarchy.service.js'; +import { InMemoryIamHierarchyRepositoryAdapter } from '../../src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.js'; +import { PrismaIamHierarchyRepositoryAdapter } from '../../src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.js'; +import { + IAM_MEMBERSHIP_SERVICE, + IamMembershipService, +} from '../../src/features/iam/application/membership.service.js'; import { AudModule } from '../../src/features/aud/aud.module.js'; import { AUDIT_REPOSITORY_PORT } from '../../src/features/aud/application/audit-repository.port.js'; import { PrismaAuditRepositoryAdapter } from '../../src/features/aud/adapter/prisma-audit-repository.adapter.js'; @@ -227,6 +242,20 @@ void test('[IAM-001, IAM-011] configured identity bootstrap persistence uses the assert.ok(provider.useValue instanceof PrismaIdentityBootstrapRepositoryAdapter); }); +void test('[IAM-001, IAM-011] configured identity bootstrap persistence composes its application service', () => { + const registered = IamModule.register({ identityBootstrapDatabase: {} as never }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IDENTITY_BOOTSTRAP_SERVICE, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof IdentityBootstrapService); +}); + void test('[IAM-012, IAM-014] configured MFA persistence uses the Prisma adapter', () => { const database = {} as never; const registered = IamModule.register({ mfaDatabase: database }); @@ -272,6 +301,66 @@ void test('[IAM-009, IAM-019] configured IAM membership persistence uses the Pri assert.ok(provider.useValue instanceof PrismaIamRepositoryAdapter); }); +void test('[IAM-001, IAM-003, IAM-019] configured hierarchy persistence and service use the Prisma boundary', () => { + const registered = IamModule.register({ hierarchyDatabase: {} as never }); + const repository = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_HIERARCHY_REPOSITORY, + ); + const service = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_HIERARCHY_SERVICE, + ); + assert.ok(repository && 'useValue' in repository); + assert.ok(service && 'useValue' in service); + if (!repository || !('useValue' in repository) || !service || !('useValue' in service)) return; + assert.ok(repository.useValue instanceof PrismaIamHierarchyRepositoryAdapter); + assert.ok(service.useValue instanceof IamHierarchyService); +}); + +void test('[IAM-001] default hierarchy composition remains locally testable and fail-closed', () => { + const registered = IamModule.register(); + const repository = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_HIERARCHY_REPOSITORY, + ); + const service = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_HIERARCHY_SERVICE, + ); + assert.ok(repository && 'useValue' in repository); + assert.ok(service && 'useValue' in service); + if (!repository || !('useValue' in repository) || !service || !('useValue' in service)) return; + assert.ok(repository.useValue instanceof InMemoryIamHierarchyRepositoryAdapter); + assert.ok(service.useValue instanceof IamHierarchyService); +}); + +void test('[IAM-004] configured IAM membership persistence composes the lifecycle service', () => { + const registered = IamModule.register({ iamDatabase: {} as never }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === IAM_MEMBERSHIP_SERVICE, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof IamMembershipService); +}); + void test('[BUA-001] configured entitlement persistence uses the Prisma adapter instead of the local fallback', () => { const database = {} as never; const registered = BuaModule.register({ entitlementDatabase: database }); diff --git a/services/api/test/features/iam/bootstrap-controller.test.ts b/services/api/test/features/iam/bootstrap-controller.test.ts new file mode 100644 index 00000000..40091ad6 --- /dev/null +++ b/services/api/test/features/iam/bootstrap-controller.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/require-await -- test doubles mirror async ports. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IamBootstrapController } from '../../../src/features/iam/api/bootstrap.controller.js'; +import type { IdentityBootstrapService } from '../../../src/features/iam/application/identity-bootstrap.service.js'; + +const bootstrap = { + user: { + schemaVersion: 1 as const, + id: '00000000-0000-4000-8000-000000000001' as never, + status: 'ACTIVE' as const, + displayName: 'Nguyen An', + locale: 'vi-VN' as const, + securityEpoch: 1, + createdAt: '2026-01-01T00:00:00.000Z' as never, + }, + organization: { + schemaVersion: 1 as const, + id: '00000000-0000-4000-8000-000000000002' as never, + name: "Nguyen An's DataBreeze", + personal: true, + status: 'ACTIVE' as const, + createdAt: '2026-01-01T00:00:00.000Z' as never, + }, + workspace: { + schemaVersion: 1 as const, + id: '00000000-0000-4000-8000-000000000003' as never, + organizationId: '00000000-0000-4000-8000-000000000002' as never, + name: 'Personal workspace', + status: 'ACTIVE' as const, + authorizationEpoch: 1, + createdAt: '2026-01-01T00:00:00.000Z' as never, + }, + project: { + schemaVersion: 1 as const, + id: '00000000-0000-4000-8000-000000000004' as never, + organizationId: '00000000-0000-4000-8000-000000000002' as never, + workspaceId: '00000000-0000-4000-8000-000000000003' as never, + kind: 'INTERNAL' as const, + name: 'Personal project', + status: 'ACTIVE' as const, + createdAt: '2026-01-01T00:00:00.000Z' as never, + }, + membership: { + schemaVersion: 1 as const, + id: '00000000-0000-4000-8000-000000000005' as never, + principalType: 'USER' as const, + principalId: '00000000-0000-4000-8000-000000000001' as never, + scope: { + scopeType: 'organization' as const, + organizationId: '00000000-0000-4000-8000-000000000002' as never, + }, + roleId: 'owner' as const, + status: 'ACTIVE' as const, + revision: 1, + }, +}; + +void test('[IAM-001, IAM-009] bootstrap controller derives the actor from the authenticated request context', async () => { + const calls: unknown[] = []; + const service = { + find: async (actorId: unknown) => { + calls.push(actorId); + return { accepted: true as const, value: bootstrap }; + }, + } as unknown as IdentityBootstrapService; + const context = { + actorId: bootstrap.user.id, + tenantScope: { + scopeType: 'workspace' as const, + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + }, + authorizationEpoch: 1, + mfaRequired: true, + } as never; + const controller = new IamBootstrapController(service, { resolve: async () => context }); + const result = await controller.bootstrap({}); + assert.equal((result as { readonly accepted: boolean }).accepted, true); + assert.deepEqual(calls, [bootstrap.user.id]); + assert.equal( + (result as { readonly value: { readonly user: { readonly id: string } } }).value.user.id, + bootstrap.user.id, + ); + const value = (result as { readonly value: unknown }).value; + assert.equal( + (value as { readonly user: { readonly mfaState: string } }).user.mfaState, + 'ENABLED', + ); + assert.deepEqual((value as { readonly session: unknown }).session, { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + authorizationEpoch: 1, + }); + const organizations = ( + value as { + readonly organizations: readonly { + readonly workspaces: readonly { readonly projects: readonly unknown[] }[]; + }[]; + } + ).organizations; + assert.deepEqual(organizations[0]?.workspaces[0]?.projects[0], { + id: bootstrap.project.id, + name: bootstrap.project.name, + kind: bootstrap.project.kind, + status: bootstrap.project.status, + }); + assert.deepEqual((value as { readonly recentScopes: unknown }).recentScopes, [ + { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + }, + ]); +}); + +void test('[IAM-001] bootstrap session preserves an authenticated project scope', async () => { + const context = { + actorId: bootstrap.user.id, + tenantScope: { + scopeType: 'project' as const, + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + }, + authorizationEpoch: 4, + mfaRequired: false, + } as never; + const controller = new IamBootstrapController( + { find: async () => ({ accepted: true as const, value: bootstrap }) } as never, + { resolve: async () => context }, + ); + const result = await controller.bootstrap({}); + assert.equal((result as { readonly accepted: boolean }).accepted, true); + if (!(result as { readonly accepted: boolean }).accepted) return; + assert.deepEqual((result as { readonly value: { readonly session: unknown } }).value.session, { + organizationId: bootstrap.organization.id, + workspaceId: bootstrap.workspace.id, + projectId: bootstrap.project.id, + authorizationEpoch: 4, + }); +}); + +void test('[IAM-001] bootstrap controller fails closed when durable identity storage is unavailable', async () => { + const controller = new IamBootstrapController(undefined, { resolve: async () => ({}) as never }); + assert.deepEqual(await controller.bootstrap({}), { accepted: false, code: 'UNAVAILABLE' }); +}); diff --git a/services/api/test/features/iam/hierarchy-controller.test.ts b/services/api/test/features/iam/hierarchy-controller.test.ts new file mode 100644 index 00000000..3385068c --- /dev/null +++ b/services/api/test/features/iam/hierarchy-controller.test.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/require-await -- test doubles mirror async ports. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { IamHierarchyController } from '../../../src/features/iam/api/hierarchy.controller.js'; +import type { IamHierarchyService } from '../../../src/features/iam/application/hierarchy.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + principal: '00000000-0000-4000-8000-000000000141', + correlation: '00000000-0000-4000-8000-000000000142', + organization: '00000000-0000-4000-8000-000000000143', + workspace: '00000000-0000-4000-8000-000000000144', + project: '00000000-0000-4000-8000-000000000145', +}; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid hierarchy controller fixture identifier'); + return result.value; +} + +function tenantContext() { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + actorId: stable(ids.principal), + correlationId: stable(ids.correlation), + idempotencyKey: 'hierarchy-controller-001', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid hierarchy controller fixture context'); + return result.value; +} + +void test('[IAM-001, IAM-003] hierarchy controller forwards authenticated context and strips body identity fields', async () => { + const calls: Array = []; + const service = { + getOrganization: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: ids.organization } }; + }, + listWorkspaces: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: [] as const }; + }, + createWorkspace: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: ids.workspace } }; + }, + getWorkspace: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: ids.workspace } }; + }, + listProjects: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: [] as const }; + }, + createProject: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: ids.project } }; + }, + getProject: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: ids.project } }; + }, + } as unknown as IamHierarchyService; + const context = tenantContext(); + const controller = new IamHierarchyController(service, { + resolve: async () => context, + }); + + assert.deepEqual(await controller.getOrganization({}, ids.organization), { + accepted: true, + value: { id: ids.organization }, + }); + assert.deepEqual(await controller.listWorkspaces({}, ids.organization), { + accepted: true, + value: [], + }); + assert.deepEqual( + await controller.createWorkspace({}, ids.organization, { + name: 'Operations', + organizationId: 'body-must-not-be-used', + } as unknown as { readonly name: string }), + { accepted: true, value: { id: ids.workspace } }, + ); + assert.deepEqual(await controller.getWorkspace({}, ids.workspace), { + accepted: true, + value: { id: ids.workspace }, + }); + assert.deepEqual(await controller.listProjects({}, ids.workspace), { + accepted: true, + value: [], + }); + assert.deepEqual( + await controller.createProject({}, ids.workspace, { + kind: 'CLIENT', + name: 'Northwind', + workspaceId: 'body-must-not-be-used', + } as unknown as { readonly kind: 'CLIENT'; readonly name: string }), + { accepted: true, value: { id: ids.project } }, + ); + assert.deepEqual(await controller.getProject({}, ids.project), { + accepted: true, + value: { id: ids.project }, + }); + assert.equal(calls.length, 7); + for (const call of calls) assert.equal(call[0], context); + assert.equal(calls[2]?.[1], ids.organization); + assert.equal(calls[2]?.[2], 'Operations'); + assert.equal(calls[5]?.[1], ids.workspace); + assert.equal(calls[5]?.[2], 'CLIENT'); + assert.equal(calls[5]?.[3], 'Northwind'); +}); + +void test('[IAM-003, IAM-019] hierarchy controller preserves safe rejected service results', async () => { + const service = { + getOrganization: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), + listWorkspaces: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), + createWorkspace: async () => ({ accepted: false as const, code: 'CONFLICT' as const }), + getWorkspace: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), + listProjects: async () => ({ accepted: false as const, code: 'UNAVAILABLE' as const }), + createProject: async () => ({ accepted: false as const, code: 'INVALID_KIND' as const }), + getProject: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), + } as unknown as IamHierarchyService; + const controller = new IamHierarchyController(service, { + resolve: async () => tenantContext(), + }); + assert.deepEqual(await controller.getOrganization({}, ids.organization), { + accepted: false, + code: 'NOT_FOUND', + }); + const statuses: number[] = []; + const reply = { + code(status: number) { + statuses.push(status); + return this; + }, + }; + assert.deepEqual(await controller.getOrganization({}, ids.organization, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(await controller.getWorkspace({}, ids.workspace, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(await controller.getProject({}, ids.project, reply as never), { + accepted: false, + code: 'NOT_FOUND', + }); + assert.deepEqual(statuses, [404, 404, 404]); + assert.deepEqual( + await controller.createProject({}, ids.workspace, { kind: 'CLIENT', name: 'x' }), + { + accepted: false, + code: 'INVALID_KIND', + }, + ); +}); diff --git a/services/api/test/features/iam/hierarchy-repository.test.ts b/services/api/test/features/iam/hierarchy-repository.test.ts new file mode 100644 index 00000000..dc7373a5 --- /dev/null +++ b/services/api/test/features/iam/hierarchy-repository.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createOrganizationIdentityV1, + createProjectIdentityV1, + createWorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryIamHierarchyRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + principal: '00000000-0000-4000-8000-000000000111', + correlation: '00000000-0000-4000-8000-000000000112', + organization: '00000000-0000-4000-8000-000000000113', + otherOrganization: '00000000-0000-4000-8000-000000000114', + workspace: '00000000-0000-4000-8000-000000000115', + otherWorkspace: '00000000-0000-4000-8000-000000000116', + project: '00000000-0000-4000-8000-000000000117', +}; +const createdAt = '2026-01-01T00:00:00.000Z'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid hierarchy fixture identifier'); + return parsed.value; +} + +function context(scope: unknown, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId: stable(ids.principal), + correlationId: stable(ids.correlation), + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid hierarchy fixture context'); + return result.value; +} + +function organization(id: string, name: string) { + const result = createOrganizationIdentityV1({ id, name, createdAt }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid organization fixture'); + return result.value; +} + +function workspace(id: string, organizationId: string, name: string) { + const result = createWorkspaceIdentityV1({ id, organizationId, name, createdAt }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid workspace fixture'); + return result.value; +} + +function project(id: string, organizationId: string, workspaceId: string) { + const result = createProjectIdentityV1({ + id, + organizationId, + workspaceId, + kind: 'INTERNAL', + name: 'Project', + createdAt, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid project fixture'); + return result.value; +} + +void test('[IAM-003, IAM-019] hierarchy repository only exposes records inside the complete tenant ancestry', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + await repository.seed({ + organizations: [organization(ids.organization, 'Acme')], + workspaces: [ + workspace(ids.workspace, ids.organization, 'Operations'), + workspace(ids.otherWorkspace, ids.organization, 'Finance'), + ], + projects: [project(ids.project, ids.organization, ids.workspace)], + }); + + const workspaceContext = context( + { + scopeType: 'workspace', + organizationId: stable(ids.organization), + workspaceId: stable(ids.workspace), + }, + 'hierarchy-read-001', + ); + assert.equal( + (await repository.findWorkspace(workspaceContext, stable(ids.workspace)))?.name, + 'Operations', + ); + assert.equal( + await repository.findWorkspace(workspaceContext, stable(ids.otherWorkspace)), + undefined, + ); + assert.equal( + (await repository.findProject(workspaceContext, stable(ids.project)))?.id, + stable(ids.project), + ); + + const siblingOrganizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.otherOrganization) }, + 'hierarchy-read-002', + ); + assert.equal( + await repository.findOrganization(siblingOrganizationContext, stable(ids.organization)), + undefined, + ); +}); + +void test('[IAM-019] hierarchy writes reject missing parents, sibling scopes, and conflicting immutable identities', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + await repository.seed({ + organizations: [organization(ids.organization, 'Acme')], + workspaces: [workspace(ids.workspace, ids.organization, 'Operations')], + projects: [], + }); + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-write-001', + ); + await assert.rejects( + repository.saveWorkspace( + organizationContext, + workspace(ids.otherWorkspace, ids.otherOrganization, 'Outside'), + ), + /IAM_SCOPE_DENIED/u, + ); + await assert.rejects( + repository.saveProject( + organizationContext, + project(ids.project, ids.organization, ids.otherWorkspace), + ), + /IAM_PARENT_NOT_FOUND/u, + ); + await repository.saveWorkspace( + organizationContext, + workspace(ids.otherWorkspace, ids.organization, 'Finance'), + ); + await assert.rejects( + repository.saveWorkspace( + organizationContext, + workspace(ids.otherWorkspace, ids.organization, 'Renamed'), + ), + /IAM_HIERARCHY_CONFLICT/u, + ); + const equivalentWorkspace = workspace(ids.otherWorkspace, ids.organization, 'Finance'); + await repository.saveWorkspace(organizationContext, { + name: equivalentWorkspace.name, + id: equivalentWorkspace.id, + organizationId: equivalentWorkspace.organizationId, + schemaVersion: equivalentWorkspace.schemaVersion, + status: equivalentWorkspace.status, + authorizationEpoch: equivalentWorkspace.authorizationEpoch, + createdAt: equivalentWorkspace.createdAt, + }); +}); + +void test('[IAM-001] hierarchy transaction rolls back all staged writes', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + const transactionContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-transaction-001', + ); + await assert.rejects( + repository.withTransaction(transactionContext, async (transaction) => { + await transaction.saveOrganization( + transactionContext, + organization(ids.organization, 'Acme'), + ); + await transaction.saveWorkspace( + transactionContext, + workspace(ids.workspace, ids.organization, 'Operations'), + ); + throw new Error('hierarchy rollback'); + }), + /hierarchy rollback/u, + ); + assert.equal( + await repository.findOrganization(transactionContext, stable(ids.organization)), + undefined, + ); + assert.equal( + await repository.findWorkspace(transactionContext, stable(ids.workspace)), + undefined, + ); +}); diff --git a/services/api/test/features/iam/hierarchy-service.test.ts b/services/api/test/features/iam/hierarchy-service.test.ts new file mode 100644 index 00000000..9681390f --- /dev/null +++ b/services/api/test/features/iam/hierarchy-service.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createOrganizationIdentityV1, + type OrganizationIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryIamHierarchyRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-hierarchy-repository.adapter.js'; +import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-repository.adapter.js'; +import { + IamHierarchyService, + type IamHierarchyClockV1, + type IamHierarchyIdGeneratorV1, +} from '../../../src/features/iam/application/hierarchy.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + principal: '00000000-0000-4000-8000-000000000131', + correlation: '00000000-0000-4000-8000-000000000132', + organization: '00000000-0000-4000-8000-000000000133', + workspace: '00000000-0000-4000-8000-000000000134', + project: '00000000-0000-4000-8000-000000000135', +}; +const createdAt = new Date('2026-01-02T00:00:00.000Z'); + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid hierarchy service fixture identifier'); + return parsed.value; +} + +function context(scope: unknown, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId: stable(ids.principal), + correlationId: stable(ids.correlation), + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid hierarchy service fixture context'); + return result.value; +} + +function organization(): OrganizationIdentityV1 { + const result = createOrganizationIdentityV1({ + id: ids.organization, + name: 'Acme', + createdAt: createdAt.toISOString(), + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid hierarchy service organization'); + return result.value; +} + +function deterministicIds(...values: string[]): IamHierarchyIdGeneratorV1 { + const queue = [...values]; + return () => { + const next = queue.shift(); + if (!next) throw new Error('test id generator exhausted'); + return next; + }; +} + +const clock: IamHierarchyClockV1 = () => createdAt; + +function authority(roleId: 'owner' | 'admin' | 'viewer' = 'owner') { + const repository = new InMemoryIamRepositoryAdapter(); + repository.seed([ + { + id: stable('00000000-0000-4000-8000-000000000151'), + principalId: stable(ids.principal), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId, + status: 'ACTIVE', + revision: 1, + }, + ]); + return repository; +} + +void test('[IAM-001, IAM-003] service creates server-identified workspaces and projects in one scoped transaction', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + await repository.seed({ organizations: [organization()], workspaces: [], projects: [] }); + const service = new IamHierarchyService( + repository, + deterministicIds(ids.workspace, ids.project), + clock, + authority(), + ); + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-service-001', + ); + + const workspace = await service.createWorkspace( + organizationContext, + ids.organization, + 'Operations', + ); + assert.equal(workspace.accepted, true); + if (!workspace.accepted) return; + assert.equal(workspace.value.id, stable(ids.workspace)); + assert.equal(workspace.value.organizationId, stable(ids.organization)); + + const project = await service.createProject( + organizationContext, + ids.workspace, + 'CLIENT', + 'Northwind', + ); + assert.equal(project.accepted, true); + if (!project.accepted) return; + assert.equal(project.value.id, stable(ids.project)); + assert.equal(project.value.workspaceId, stable(ids.workspace)); +}); + +void test('[IAM-003, IAM-004] hierarchy mutations require an authoritative role permission', async () => { + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-service-004', + ); + const withoutAuthority = new IamHierarchyService( + new InMemoryIamHierarchyRepositoryAdapter(), + deterministicIds(ids.workspace), + clock, + ); + assert.deepEqual( + await withoutAuthority.createWorkspace(organizationContext, ids.organization, 'Operations'), + { accepted: false, code: 'UNAVAILABLE' }, + ); + + const viewerRepository = new InMemoryIamHierarchyRepositoryAdapter(); + await viewerRepository.seed({ organizations: [organization()], workspaces: [], projects: [] }); + const viewer = new IamHierarchyService( + viewerRepository, + deterministicIds(ids.workspace), + clock, + authority('viewer'), + ); + assert.deepEqual( + await viewer.createWorkspace(organizationContext, ids.organization, 'Operations'), + { accepted: false, code: 'SCOPE_DENIED' }, + ); +}); + +void test('[IAM-003, IAM-019] service rejects path scope that is not the authenticated organization', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + await repository.seed({ organizations: [organization()], workspaces: [], projects: [] }); + const service = new IamHierarchyService( + repository, + deterministicIds(ids.workspace), + clock, + authority(), + ); + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-service-002', + ); + + const result = await service.createWorkspace( + organizationContext, + '00000000-0000-4000-8000-000000000199', + 'Operations', + ); + assert.deepEqual(result, { accepted: false, code: 'SCOPE_DENIED' }); +}); + +void test('[IAM-001] service returns stable input and parent errors without leaking adapter details', async () => { + const repository = new InMemoryIamHierarchyRepositoryAdapter(); + const service = new IamHierarchyService( + repository, + deterministicIds(ids.workspace), + clock, + authority(), + ); + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'hierarchy-service-003', + ); + + assert.deepEqual(await service.createWorkspace(organizationContext, ids.organization, ''), { + accepted: false, + code: 'INVALID_TEXT', + }); + assert.deepEqual( + await service.createWorkspace(organizationContext, ids.organization, 'Operations'), + { accepted: false, code: 'NOT_FOUND' }, + ); + assert.deepEqual( + await service.createProject(organizationContext, 'not-an-id', 'CLIENT', 'Northwind'), + { accepted: false, code: 'INVALID_IDENTIFIER' }, + ); +}); diff --git a/services/api/test/features/iam/identity-bootstrap.test.ts b/services/api/test/features/iam/identity-bootstrap.test.ts index 069c39c3..4d23528c 100644 --- a/services/api/test/features/iam/identity-bootstrap.test.ts +++ b/services/api/test/features/iam/identity-bootstrap.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/require-await -- repository double mirrors async storage. */ import assert from 'node:assert/strict'; import test from 'node:test'; @@ -39,6 +40,32 @@ void test('[IAM-001, IAM-009, IAM-011, IAM-016] bootstrap creates a personal own assert.deepEqual(await service.create(input), first); const stored = await repository.findByUserId(first.value.user.id); assert.deepEqual(stored, first.value); + assert.deepEqual(await service.find(input.user.id), { accepted: true, value: first.value }); +}); + +void test('[IAM-001, IAM-009] bootstrap reads reject malformed identities and hide absent users', async () => { + const service = new IdentityBootstrapService(new InMemoryIdentityBootstrapRepositoryAdapter()); + assert.deepEqual(await service.find('not-an-id'), { + accepted: false, + code: 'INVALID_IDENTIFIER', + }); + assert.deepEqual(await service.find('00000000-0000-4000-8000-000000000099'), { + accepted: false, + code: 'NOT_FOUND', + }); +}); + +void test('[IAM-001] bootstrap reads map repository failures to a stable availability code', async () => { + const service = new IdentityBootstrapService({ + findByUserId: async () => { + throw new Error('database details must not escape'); + }, + save: async () => undefined, + withTransaction: async () => { + throw new Error('database details must not escape'); + }, + }); + assert.deepEqual(await service.find(input.user.id), { accepted: false, code: 'UNAVAILABLE' }); }); void test('[IAM-011] conflicting bootstrap identity is rejected without replacing the owner', async () => { diff --git a/services/api/test/features/iam/membership-controller.test.ts b/services/api/test/features/iam/membership-controller.test.ts new file mode 100644 index 00000000..e70e6014 --- /dev/null +++ b/services/api/test/features/iam/membership-controller.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/require-await -- controller double mirrors async services. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IamMembershipController } from '../../../src/features/iam/api/membership.controller.js'; +import type { IamMembershipService } from '../../../src/features/iam/application/membership.service.js'; + +void test('[IAM-004] membership controller forwards invitation and transition fields without authority decisions in the client', async () => { + const calls: Array = []; + const service = { + list: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: [] as const }; + }, + invite: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: 'invitation' } }; + }, + transition: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: 'membership' } }; + }, + accept: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: 'accepted-membership' } }; + }, + transferOwnership: async (...input: unknown[]) => { + calls.push(input); + return { accepted: true as const, value: { id: 'transferred-membership' } }; + }, + } as unknown as IamMembershipService; + const context = { tenantScope: { scopeType: 'organization', organizationId: 'org' } } as never; + const controller = new IamMembershipController(service, { resolve: async () => context }); + assert.deepEqual(await controller.list({}), { accepted: true, value: [] }); + assert.deepEqual( + await controller.invite( + {}, + { + principalId: 'principal', + scope: { scopeType: 'organization', organizationId: 'org' }, + roleId: 'viewer', + }, + ), + { accepted: true, value: { id: 'invitation' } }, + ); + assert.deepEqual( + await controller.transition({}, 'membership-id', { + expectedRevision: 1, + status: 'SUSPENDED', + }), + { accepted: true, value: { id: 'membership' } }, + ); + assert.deepEqual(await controller.accept({}, 'membership-id', { expectedRevision: 1 }), { + accepted: true, + value: { id: 'accepted-membership' }, + }); + assert.deepEqual( + await controller.transferOwnership({}, 'membership-id', { expectedRevision: 1 }), + { accepted: true, value: { id: 'transferred-membership' } }, + ); + assert.equal(calls.length, 5); + assert.equal( + (calls[1]?.[1] as { readonly principalId?: unknown } | undefined)?.principalId, + 'principal', + ); + assert.equal(calls[2]?.[1], 'membership-id'); + assert.equal(calls[2]?.[2], 1); + assert.equal(calls[2]?.[3], 'SUSPENDED'); + assert.equal(calls[3]?.[1], 'membership-id'); + assert.equal(calls[3]?.[2], 1); + assert.equal(calls[4]?.[1], 'membership-id'); + assert.equal(calls[4]?.[2], 1); +}); + +void test('[IAM-004] membership controller fails closed when durable membership authority is not configured', async () => { + const controller = new IamMembershipController(undefined, { resolve: async () => ({}) as never }); + assert.deepEqual(await controller.list({}), { accepted: false, code: 'UNAVAILABLE' }); +}); + +void test('[IAM-004] membership controller maps rejected results to HTTP status codes', async () => { + const statuses: number[] = []; + const reply = { + code(status: number) { + statuses.push(status); + return this; + }, + }; + const service = { + list: async () => ({ accepted: false as const, code: 'SCOPE_DENIED' as const }), + invite: async () => ({ accepted: false as const, code: 'NOT_FOUND' as const }), + transition: async () => ({ accepted: false as const, code: 'CONFLICT' as const }), + accept: async () => ({ accepted: false as const, code: 'EXPIRED' as const }), + transferOwnership: async () => ({ accepted: false as const, code: 'UNAVAILABLE' as const }), + } as unknown as IamMembershipService; + const controller = new IamMembershipController(service, { resolve: async () => ({}) as never }); + await controller.list({}, reply as never); + await controller.invite( + {}, + { + principalId: 'principal', + scope: { scopeType: 'organization', organizationId: 'org' }, + roleId: 'viewer', + }, + reply as never, + ); + await controller.transition( + {}, + 'membership-id', + { expectedRevision: 1, status: 'SUSPENDED' }, + reply as never, + ); + await controller.accept({}, 'membership-id', { expectedRevision: 1 }, reply as never); + await controller.transferOwnership({}, 'membership-id', { expectedRevision: 1 }, reply as never); + assert.deepEqual(statuses, [403, 404, 409, 410, 503]); +}); diff --git a/services/api/test/features/iam/membership-dto.test.ts b/services/api/test/features/iam/membership-dto.test.ts new file mode 100644 index 00000000..2265b1aa --- /dev/null +++ b/services/api/test/features/iam/membership-dto.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validate } from 'class-validator'; + +import { MembershipScopeDto } from '../../../src/features/iam/api/membership.dto.js'; + +const ids = { + organization: '00000000-0000-4000-8000-000000000701', + workspace: '00000000-0000-4000-8000-000000000702', + project: '00000000-0000-4000-8000-000000000703', +}; + +async function errors(input: Partial) { + const value = Object.assign(new MembershipScopeDto(), input); + return validate(value); +} + +void test('[IAM-004] membership scope DTO accepts matching hierarchy identifiers', async () => { + assert.equal( + ( + await errors({ + scopeType: 'organization', + organizationId: ids.organization, + }) + ).length, + 0, + ); + assert.equal( + ( + await errors({ + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: ids.workspace, + }) + ).length, + 0, + ); + assert.equal( + ( + await errors({ + scopeType: 'project', + organizationId: ids.organization, + workspaceId: ids.workspace, + projectId: ids.project, + }) + ).length, + 0, + ); +}); + +void test('[IAM-004] membership scope DTO rejects inconsistent hierarchy identifiers', async () => { + assert.ok( + ( + await errors({ + scopeType: 'organization', + organizationId: ids.organization, + projectId: ids.project, + }) + ).some((error) => error.property === 'scopeType'), + ); + assert.ok( + ( + await errors({ + scopeType: 'workspace', + organizationId: ids.organization, + }) + ).some((error) => error.property === 'scopeType'), + ); + assert.ok( + ( + await errors({ + scopeType: 'project', + organizationId: ids.organization, + workspaceId: ids.workspace, + }) + ).some((error) => error.property === 'scopeType'), + ); +}); diff --git a/services/api/test/features/iam/membership-service.test.ts b/services/api/test/features/iam/membership-service.test.ts new file mode 100644 index 00000000..11ab36d5 --- /dev/null +++ b/services/api/test/features/iam/membership-service.test.ts @@ -0,0 +1,459 @@ +/* eslint-disable @typescript-eslint/require-await -- repository doubles mirror async ports. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { INVITATION_MAX_SECONDS_V1 } from '@databreeze/domain/identity/v1'; + +import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-repository.adapter.js'; +import { + IamMembershipService, + type IamMembershipClockV1, + type IamMembershipIdGeneratorV1, +} from '../../../src/features/iam/application/membership.service.js'; +import type { IamRepositoryPortV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + principal: '00000000-0000-4000-8000-000000000161', + invited: '00000000-0000-4000-8000-000000000162', + outsider: '00000000-0000-4000-8000-000000000167', + successor: '00000000-0000-4000-8000-000000000168', + correlation: '00000000-0000-4000-8000-000000000163', + organization: '00000000-0000-4000-8000-000000000164', + membership: '00000000-0000-4000-8000-000000000165', + invitation: '00000000-0000-4000-8000-000000000166', + successorMembership: '00000000-0000-4000-8000-000000000169', +}; +const now = new Date('2026-01-03T00:00:00.000Z'); + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid membership service fixture identifier'); + return result.value; +} + +function contextFor(actorId: string, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + actorId: stable(actorId), + correlationId: stable(ids.correlation), + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid membership service fixture context'); + return result.value; +} + +function context(idempotencyKey: string) { + return contextFor(ids.principal, idempotencyKey); +} + +function idsFrom(...values: string[]): IamMembershipIdGeneratorV1 { + const queue = [...values]; + return () => { + const next = queue.shift(); + if (!next) throw new Error('membership id generator exhausted'); + return next; + }; +} + +const clock: IamMembershipClockV1 = () => now; + +function repository(roleId: 'owner' | 'admin' | 'viewer' = 'owner') { + const value = new InMemoryIamRepositoryAdapter(); + value.seed([ + { + id: stable(ids.membership), + principalId: stable(ids.principal), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId, + status: 'ACTIVE', + revision: 1, + }, + ]); + return value; +} + +void test('[IAM-004] owner can create a server-identified, expiring invitation in scope', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const result = await service.invite(context('membership-service-001'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.id, stable(ids.invitation)); + assert.equal(result.value.principalId, stable(ids.invited)); + assert.equal(result.value.status, 'INVITED'); + assert.equal(result.value.revision, 1); + assert.equal(result.value.scope.scopeType, 'organization'); +}); + +void test('[IAM-004] duplicate principal and scope invitations are rejected', async () => { + const value = repository(); + const service = new IamMembershipService( + value, + idsFrom(ids.invitation, ids.successorMembership), + clock, + ); + const input = { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer' as const, + }; + const first = await service.invite(context('membership-service-duplicate-001'), input); + assert.equal(first.accepted, true); + assert.deepEqual(await service.invite(context('membership-service-duplicate-002'), input), { + accepted: false, + code: 'CONFLICT', + }); +}); + +void test('[IAM-003, IAM-004] viewer and out-of-scope invitations are denied', async () => { + const viewer = repository('viewer'); + const service = new IamMembershipService(viewer, idsFrom(ids.invitation), clock); + assert.deepEqual( + await service.invite(context('membership-service-002'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + const owner = repository(); + const ownerService = new IamMembershipService(owner, idsFrom(ids.invitation), clock); + assert.deepEqual( + await ownerService.invite(context('membership-service-003'), { + principalId: ids.invited, + scope: { + scopeType: 'organization', + organizationId: '00000000-0000-4000-8000-000000000199', + }, + roleId: 'viewer', + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); +}); + +void test('[IAM-004] membership administration listing requires a settings-management permission', async () => { + const viewer = repository('viewer'); + const service = new IamMembershipService(viewer, idsFrom(ids.invitation), clock); + assert.deepEqual(await service.list(context('membership-service-list-001')), { + accepted: false, + code: 'SCOPE_DENIED', + }); +}); + +void test('[IAM-004] membership listing maps authority outages to a stable availability code', async () => { + const base = repository(); + const unavailable: IamRepositoryPortV1 = { + findMembership: async () => { + throw new Error('membership store unavailable'); + }, + listMemberships: async () => { + throw new Error('membership store unavailable'); + }, + saveMembership: base.saveMembership.bind(base), + withTransaction: base.withTransaction.bind(base), + }; + const service = new IamMembershipService(unavailable, idsFrom(ids.invitation), clock); + assert.deepEqual(await service.list(context('membership-service-list-002')), { + accepted: false, + code: 'UNAVAILABLE', + }); +}); + +void test('[IAM-004] owner invitations are organization-only and cannot be delegated by an admin', async () => { + const admin = repository('admin'); + const adminService = new IamMembershipService(admin, idsFrom(ids.invitation), clock); + assert.deepEqual( + await adminService.invite(context('membership-service-owner-role-001'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'owner', + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + const owner = repository(); + const ownerService = new IamMembershipService(owner, idsFrom(ids.invitation), clock); + assert.deepEqual( + await ownerService.invite(context('membership-service-owner-role-002'), { + principalId: ids.invited, + scope: { + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: '00000000-0000-4000-8000-000000000170', + }, + roleId: 'owner', + }), + { accepted: false, code: 'INVALID_STATE' }, + ); +}); + +void test('[IAM-004] status transitions enforce revisions and cannot remove the last owner', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + assert.deepEqual( + await service.transition(context('membership-service-004'), ids.membership, 1, 'REMOVED'), + { accepted: false, code: 'LAST_OWNER' }, + ); + assert.deepEqual( + await service.transition(context('membership-service-005'), ids.membership, 2, 'SUSPENDED'), + { accepted: false, code: 'CONFLICT' }, + ); +}); + +void test('[IAM-004] an admin cannot remove an owner even when another owner exists', async () => { + const value = repository('admin'); + await value.saveMembership(context('membership-service-admin-owner-001'), { + id: stable(ids.successorMembership), + principalId: stable(ids.successor), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }); + await value.saveMembership(context('membership-service-admin-owner-003'), { + id: stable(ids.invitation), + principalId: stable(ids.invited), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + assert.deepEqual( + await service.transition( + context('membership-service-admin-owner-002'), + ids.successorMembership, + 1, + 'REMOVED', + ), + { accepted: false, code: 'SCOPE_DENIED' }, + ); +}); + +void test('[IAM-004] invitee can accept an unexpired invitation and invitation lifetime is cleared', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const invited = await service.invite(context('membership-service-006'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(invited.accepted, true); + if (!invited.accepted) return; + + const accepted = await service.accept( + contextFor(ids.invited, 'membership-service-007'), + invited.value.id, + invited.value.revision, + ); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + assert.equal(accepted.value.status, 'ACTIVE'); + assert.equal(accepted.value.revision, 2); + assert.equal(accepted.value.startsAt, undefined); + assert.equal(accepted.value.expiresAt, undefined); + assert.deepEqual( + (await value.listMemberships(context('membership-service-008'))).find( + (membership) => membership.id === invited.value.id, + ), + accepted.value, + ); +}); + +void test('[IAM-004] administrators cannot activate invitations outside the accept flow', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const invitation = await service.invite(context('membership-service-006b'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(invitation.accepted, true); + if (!invitation.accepted) return; + assert.deepEqual( + await service.transition(context('membership-service-006c'), invitation.value.id, 1, 'ACTIVE'), + { accepted: false, code: 'CONFLICT' }, + ); + assert.equal( + (await value.listMemberships(context('membership-service-006d'))).find( + (membership) => membership.id === invitation.value.id, + )?.status, + 'INVITED', + ); +}); + +void test('[IAM-004] invitation acceptance fails closed for an outsider, expiry, and stale revisions', async () => { + const value = repository(); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const invited = await service.invite(context('membership-service-009'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(invited.accepted, true); + if (!invited.accepted) return; + assert.deepEqual( + await service.accept(contextFor(ids.outsider, 'membership-service-010'), invited.value.id, 1), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + assert.deepEqual( + await service.accept(contextFor(ids.invited, 'membership-service-011'), invited.value.id, 2), + { accepted: false, code: 'CONFLICT' }, + ); + + const expiredClock: IamMembershipClockV1 = () => + new Date(Date.parse(now.toISOString()) + INVITATION_MAX_SECONDS_V1 * 1_000); + const expiringValue = repository(); + const expiring = new IamMembershipService(expiringValue, idsFrom(ids.invitation), clock); + const invitation = await expiring.invite(context('membership-service-012'), { + principalId: ids.invited, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + }); + assert.equal(invitation.accepted, true); + if (!invitation.accepted) return; + const expired = new IamMembershipService(expiringValue, idsFrom(), expiredClock); + assert.deepEqual( + await expired.accept(contextFor(ids.invited, 'membership-service-013'), invitation.value.id, 1), + { accepted: false, code: 'EXPIRED' }, + ); +}); + +void test('[IAM-004] owner transfer atomically promotes an active organization member', async () => { + const value = repository(); + await value.saveMembership(context('membership-service-014'), { + id: stable(ids.successorMembership), + principalId: stable(ids.successor), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'admin', + status: 'ACTIVE', + revision: 4, + }); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + const transferred = await service.transferOwnership( + context('membership-service-015'), + ids.successorMembership, + 4, + ); + assert.equal(transferred.accepted, true); + if (!transferred.accepted) return; + assert.equal(transferred.value.id, stable(ids.successorMembership)); + assert.equal(transferred.value.roleId, 'owner'); + assert.equal(transferred.value.revision, 5); + assert.equal( + (await value.findMembership(context('membership-service-016'), stable(ids.principal)))?.roleId, + 'admin', + ); + assert.equal( + ( + await value.findMembership( + contextFor(ids.successor, 'membership-service-017'), + stable(ids.successor), + ) + )?.roleId, + 'owner', + ); +}); + +void test('[IAM-004] owner transfer requires an owner and rolls back when target revision is stale', async () => { + const value = repository(); + await value.saveMembership(context('membership-service-018'), { + id: stable(ids.successorMembership), + principalId: stable(ids.successor), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'admin', + status: 'ACTIVE', + revision: 1, + }); + const service = new IamMembershipService(value, idsFrom(ids.invitation), clock); + assert.deepEqual( + await service.transferOwnership( + contextFor(ids.outsider, 'membership-service-019'), + ids.successorMembership, + 1, + ), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + assert.deepEqual( + await service.transferOwnership(context('membership-service-020'), ids.successorMembership, 2), + { accepted: false, code: 'CONFLICT' }, + ); + assert.equal( + (await value.findMembership(context('membership-service-021'), stable(ids.principal)))?.roleId, + 'owner', + ); + assert.equal( + ( + await value.findMembership( + contextFor(ids.successor, 'membership-service-022'), + stable(ids.successor), + ) + )?.roleId, + 'admin', + ); +}); + +void test('[IAM-004] owner transfer rolls back when the second optimistic write fails', async () => { + const base = repository(); + await base.saveMembership(context('membership-service-transfer-rollback-001'), { + id: stable(ids.successorMembership), + principalId: stable(ids.successor), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'admin', + status: 'ACTIVE', + revision: 1, + }); + let writes = 0; + const failing: IamRepositoryPortV1 = { + findMembership: base.findMembership.bind(base), + listMemberships: base.listMemberships.bind(base), + saveMembership: base.saveMembership.bind(base), + withTransaction: (requestContext, work) => + base.withTransaction(requestContext, (transaction) => + work({ + findMembership: transaction.findMembership.bind(transaction), + listMemberships: transaction.listMemberships.bind(transaction), + saveMembership: async (mutationContext, membership) => { + writes += 1; + if (writes === 2) throw new Error('target write failed'); + return transaction.saveMembership(mutationContext, membership); + }, + }), + ), + }; + const service = new IamMembershipService(failing, idsFrom(ids.invitation), clock); + assert.deepEqual( + await service.transferOwnership( + context('membership-service-transfer-rollback-002'), + ids.successorMembership, + 1, + ), + { accepted: false, code: 'UNAVAILABLE' }, + ); + assert.equal( + ( + await base.findMembership( + context('membership-service-transfer-rollback-003'), + stable(ids.principal), + ) + )?.roleId, + 'owner', + ); + assert.equal( + ( + await base.findMembership( + contextFor(ids.successor, 'membership-service-transfer-rollback-004'), + stable(ids.successor), + ) + )?.roleId, + 'admin', + ); +}); diff --git a/services/api/test/features/iam/prisma-iam-hierarchy-repository.test.ts b/services/api/test/features/iam/prisma-iam-hierarchy-repository.test.ts new file mode 100644 index 00000000..dcdf06c4 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-hierarchy-repository.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/require-await -- Prisma delegate doubles mirror async client signatures. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createOrganizationIdentityV1, + createWorkspaceIdentityV1, +} from '@databreeze/domain/identity/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaIamHierarchyRepositoryAdapter, + type IamHierarchyDatabaseClientV1, + type OrganizationIdentityDatabaseRowV1, + type WorkspaceIdentityDatabaseRowV1, + type ProjectIdentityDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-iam-hierarchy-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + principal: '00000000-0000-4000-8000-000000000121', + correlation: '00000000-0000-4000-8000-000000000122', + organization: '00000000-0000-4000-8000-000000000123', + siblingOrganization: '00000000-0000-4000-8000-000000000124', + workspace: '00000000-0000-4000-8000-000000000125', + siblingWorkspace: '00000000-0000-4000-8000-000000000126', + project: '00000000-0000-4000-8000-000000000127', +}; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid Prisma hierarchy fixture identifier'); + return parsed.value; +} + +function context(scope: unknown, idempotencyKey: string) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId: stable(ids.principal), + correlationId: stable(ids.correlation), + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid Prisma hierarchy fixture context'); + return result.value; +} + +function createDatabase(): { + readonly client: IamHierarchyDatabaseClientV1; + readonly organizations: Map; + readonly workspaces: Map; + readonly projects: Map; + readonly transactionCalls: { value: number }; +} { + const organizations = new Map(); + const workspaces = new Map(); + const projects = new Map(); + const transactionCalls = { value: 0 }; + const filter = ( + rows: Map, + where: Readonly>, + ) => + [...rows.values()].filter((row) => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some((candidate) => + Object.entries(candidate as Record).every( + ([candidateKey, candidateValue]) => + row[candidateKey as keyof TRow] === candidateValue, + ), + ); + } + return row[key as keyof TRow] === value; + }), + ); + const delegates = { + organizationIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + organizations.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + filter(organizations, where), + create: async ({ data }: { readonly data: OrganizationIdentityDatabaseRowV1 }) => { + organizations.set(data.id, data); + return data; + }, + }, + workspaceIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + workspaces.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + filter(workspaces, where), + create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { + workspaces.set(data.id, data); + return data; + }, + }, + projectIdentity: { + findUnique: async ({ where }: { readonly where: { readonly id: string } }) => + projects.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => + filter(projects, where), + create: async ({ data }: { readonly data: ProjectIdentityDatabaseRowV1 }) => { + projects.set(data.id, data); + return data; + }, + }, + }; + const client = { + ...delegates, + $transaction: async ( + work: (transaction: IamHierarchyDatabaseClientV1) => Promise, + ) => { + transactionCalls.value += 1; + const before = { + organizations: new Map(organizations), + workspaces: new Map(workspaces), + projects: new Map(projects), + }; + try { + return await work(client); + } catch (error) { + organizations.clear(); + workspaces.clear(); + projects.clear(); + for (const [id, row] of before.organizations) organizations.set(id, row); + for (const [id, row] of before.workspaces) workspaces.set(id, row); + for (const [id, row] of before.projects) projects.set(id, row); + throw error; + } + }, + } as unknown as IamHierarchyDatabaseClientV1; + return { client, organizations, workspaces, projects, transactionCalls }; +} + +void test('[IAM-001, IAM-003, IAM-019] Prisma hierarchy adapter scopes reads and maps rows through domain validation', async () => { + const state = createDatabase(); + const adapter = new PrismaIamHierarchyRepositoryAdapter(state.client); + const organization = createOrganizationIdentityV1({ + id: ids.organization, + name: 'Acme', + createdAt: createdAt.toISOString(), + }); + const workspace = createWorkspaceIdentityV1({ + id: ids.workspace, + organizationId: ids.organization, + name: 'Operations', + createdAt: createdAt.toISOString(), + }); + assert.equal(organization.accepted, true); + assert.equal(workspace.accepted, true); + if (!organization.accepted || !workspace.accepted) return; + const organizationContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'prisma-hierarchy-001', + ); + await adapter.saveOrganization(organizationContext, organization.value); + await adapter.saveWorkspace(organizationContext, workspace.value); + const loaded = await adapter.findWorkspace(organizationContext, stable(ids.workspace)); + assert.equal(loaded?.name, 'Operations'); + assert.equal( + await adapter.findWorkspace( + context( + { scopeType: 'organization', organizationId: stable(ids.siblingOrganization) }, + 'prisma-hierarchy-002', + ), + stable(ids.workspace), + ), + undefined, + ); + assert.equal(state.transactionCalls.value, 2); +}); + +void test('[IAM-001] Prisma hierarchy transactions roll back staged writes', async () => { + const state = createDatabase(); + const adapter = new PrismaIamHierarchyRepositoryAdapter(state.client); + const transactionContext = context( + { scopeType: 'organization', organizationId: stable(ids.organization) }, + 'prisma-hierarchy-003', + ); + const organization = createOrganizationIdentityV1({ + id: ids.organization, + name: 'Acme', + createdAt: createdAt.toISOString(), + }); + assert.equal(organization.accepted, true); + if (!organization.accepted) return; + await assert.rejects( + adapter.withTransaction(transactionContext, async (transaction) => { + await transaction.saveOrganization(transactionContext, organization.value); + throw new Error('prisma hierarchy rollback'); + }), + /prisma hierarchy rollback/u, + ); + assert.equal(state.organizations.size, 0); +}); 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 7ae9e578..a4d52efa 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -271,6 +271,31 @@ void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce op assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); }); +void test('[IAM-004] Prisma IAM membership updates persist cleared invitation lifetime fields', async () => { + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + const invitation = { + ...row(id('25'), 'WORKSPACE', workspaceId, 'viewer'), + status: 'INVITED', + startsAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2026-01-02T00:00:00.000Z'), + } satisfies IamMembershipDatabaseRowV1; + const { client, memberships } = createDatabase([invitation]); + const repository = new PrismaIamRepositoryAdapter(client); + + await repository.saveMembership(context(workspaceScope, 1), { + id: stable('25'), + principalId, + scope: workspaceScope, + roleId: 'viewer', + status: 'ACTIVE', + revision: 2, + }); + assert.equal(memberships.get(id('25'))?.startsAt, null); + assert.equal(memberships.get(id('25'))?.expiresAt, null); + assert.equal(memberships.get(id('25'))?.status, 'ACTIVE'); + assert.equal(memberships.get(id('25'))?.revision, 2); +}); + void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership', async () => { const { client, memberships } = createDatabase(); const repository = new PrismaIamRepositoryAdapter(client); diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index abe78db0..556aa5eb 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -60,6 +60,30 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, 'https://json-schema.org/draft/2020-12/schema', ); assert.equal(firstDocument.info.version, '1.0.0'); + const bootstrapResponse = ( + firstDocument.paths['/v1/me/bootstrap']?.get as OperationLike | undefined + )?.responses['200']; + assert.equal( + ( + bootstrapResponse?.content?.['application/json'] as + | { readonly schema?: { readonly $ref?: string } } + | undefined + )?.schema?.$ref, + '#/components/schemas/BootstrapResponseDto', + ); + const membershipResponses = ( + firstDocument.paths['/v1/memberships']?.get as OperationLike | undefined + )?.responses; + for (const status of ['400', '403', '404', '409', '410', '503']) { + assert.equal( + ( + membershipResponses?.[status]?.content?.['application/json'] as + | { readonly schema?: { readonly $ref?: string } } + | undefined + )?.schema?.$ref, + '#/components/schemas/MembershipRejectedResponseDto', + ); + } const paths = Object.keys(firstDocument.paths).sort(); assert.deepEqual(paths, [ @@ -135,7 +159,15 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/revoke', '/v1/entitlements/snapshots/{snapshotId}', '/v1/entitlements/usage', + '/v1/me/bootstrap', + '/v1/memberships', + '/v1/memberships/{membershipId}/accept', + '/v1/memberships/{membershipId}/transfer-ownership', + '/v1/memberships/{membershipId}/transition', + '/v1/organizations/{organizationId}', '/v1/organizations/{organizationId}/devices', + '/v1/organizations/{organizationId}/workspaces', + '/v1/projects/{projectId}', '/v1/protected-document-unlocks', '/v1/protected-document-unlocks/{requestId}', '/v1/protected-document-unlocks/{requestId}/expire', @@ -150,6 +182,8 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/spreadsheet-audits/{auditId}', '/v1/system/compatibility', '/v1/system/compatibility/check', + '/v1/workspaces/{workspaceId}', + '/v1/workspaces/{workspaceId}/projects', ]); assert.ok( paths.filter((path) => !path.startsWith('/health/')).every((path) => path.startsWith('/v1/')), diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index ea60efed..49a22a3a 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -124,6 +124,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803000000_iae_lineage_uniqueness', '20260803010000_iam_session_scope_binding', '20260803020000_bua_project_usage_scope', + '20260803030000_iam_membership_scope_uniqueness', 'migration_lock.toml', ]); const migration = await readFile( @@ -521,4 +522,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo } assert.match(sessionScopeMigration, /no production or legacy data migration/u); assert.match(sessionScopeMigration, /guessing tenant scope would be unsafe/u); + const membershipUniquenessMigration = await readFile( + path.join( + migrationsDirectory, + '20260803030000_iam_membership_scope_uniqueness', + 'migration.sql', + ), + 'utf8', + ); + assert.match( + membershipUniquenessMigration, + /CREATE UNIQUE INDEX "memberships_principal_scope_identity_key"/u, + ); + assert.match(membershipUniquenessMigration, /COALESCE\("workspace_id"::text, ''\)/u); + assert.match(membershipUniquenessMigration, /COALESCE\("project_id"::text, ''\)/u); }); diff --git a/tools/repo-cli/src/check-execution-orchestration.mjs b/tools/repo-cli/src/check-execution-orchestration.mjs index ddab9773..ab7e2b5e 100644 --- a/tools/repo-cli/src/check-execution-orchestration.mjs +++ b/tools/repo-cli/src/check-execution-orchestration.mjs @@ -135,6 +135,7 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti const batches = Array.isArray(ledger.deliveryBatches) ? ledger.deliveryBatches : []; const byId = new Map(); const batchByTask = new Map(); + const handoffTaskIdsByBatch = new Map(); for (const batch of batches) { if (typeof batch.batchId !== 'string' || batch.batchId.trim() === '') { @@ -176,6 +177,23 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti diagnostics.push(`batch ${batch.batchId} has no tasks`); continue; } + const rawHandoffTaskIds = batch.handoffTaskIds; + const normalizedHandoffTaskIds = + rawHandoffTaskIds === undefined + ? [] + : Array.isArray(rawHandoffTaskIds) + ? rawHandoffTaskIds + : []; + if (rawHandoffTaskIds !== undefined && !Array.isArray(rawHandoffTaskIds)) { + diagnostics.push(`batch ${batch.batchId} handoffTaskIds must be an array`); + } + handoffTaskIdsByBatch.set(batch.batchId, normalizedHandoffTaskIds); + const handoffTaskIds = new Set(normalizedHandoffTaskIds); + for (const taskId of handoffTaskIds) { + if (!batch.taskIds.includes(taskId)) { + diagnostics.push(`batch ${batch.batchId} handoff task ${taskId} is not in taskIds`); + } + } for (const taskId of batch.taskIds) { if (!taskIds.has(taskId)) diagnostics.push(`batch ${batch.batchId} has unknown task ${taskId}`); @@ -216,9 +234,19 @@ function validateDeliveryBatches({ ledger, plans, taskIds, taskToPlan, diagnosti .filter(([, state]) => ['verified', 'released'].includes(state?.status)) .map(([taskId]) => taskId), ); + for (const batch of batches) { + for (const taskId of handoffTaskIdsByBatch.get(batch.batchId) ?? []) { + if (!verifiedTasks.has(taskId)) { + diagnostics.push(`batch ${batch.batchId} handoff task ${taskId} is not verified`); + } + } + } for (const taskId of taskIds) { if (verifiedTasks.has(taskId)) { - if (batchByTask.has(taskId)) diagnostics.push(`verified task ${taskId} remains batched`); + const batchId = batchByTask.get(taskId); + if (batchId !== undefined && !handoffTaskIdsByBatch.get(batchId)?.includes(taskId)) { + diagnostics.push(`verified task ${taskId} remains batched without handoff declaration`); + } } else if (!batchByTask.has(taskId)) { diagnostics.push(`unfinished task ${taskId} has no delivery batch`); } diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 5132f4e6..f960f21b 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -160,7 +160,7 @@ test('delivery batches cover every unfinished task once within review budgets', assert.equal(ledger.deliveryBatches.length, 15); assert.equal(new Set(batchedTasks).size, batchedTasks.length); assert.deepEqual( - new Set(batchedTasks), + new Set(batchedTasks.filter((taskId) => !verifiedTasks.has(taskId))), new Set([...allTasks].filter((taskId) => !verifiedTasks.has(taskId))), ); for (const batch of ledger.deliveryBatches) { @@ -174,6 +174,8 @@ test('delivery batches cover every unfinished task once within review budgets', (batch) => batch.batchId === ledger.activeBatchId, ); assert.ok(activeBatch.taskIds.includes(ledger.nextTaskId)); + assert.deepEqual(activeBatch.taskIds.slice(0, 3), ['FND-006', 'FND-007', 'IAM-001']); + assert.deepEqual(activeBatch.handoffTaskIds, ['FND-007']); }); test('the handoff runbook contains deterministic resume and failure protocols', () => { @@ -205,13 +207,13 @@ 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-005'); + assert.equal(ledger.nextTaskId, 'IAM-001'); assert.equal(ledger.activeBatchId, 'B01'); - assert.equal(ledger.checkpoint.remoteDev, '783a4710c0aa2a2808d78ad7f0643e6731150bd7'); - assert.equal(ledger.checkpoint.remoteMain, '3ed3d77d0281ef239d0509c81ded447d8fffd213'); - assert.equal(ledger.checkpoint.lastFeaturePullRequest, 19); - assert.equal(ledger.checkpoint.lastPromotionPullRequest, 20); - assert.equal(ledger.checkpoint.lastPromotionFixPullRequest, 23); + assert.equal(ledger.checkpoint.remoteDev, '9265e15125c2e50cfcaca455524c903b6b92383e'); + assert.equal(ledger.checkpoint.remoteMain, '8a4c0af52ed872715103710e3c89ca832f999bd4'); + assert.equal(ledger.checkpoint.lastFeaturePullRequest, 36); + assert.equal(ledger.checkpoint.lastPromotionPullRequest, 38); + assert.equal(ledger.checkpoint.lastPromotionFixPullRequest, 37); assert.deepEqual(ledger.taskState?.['FND-001']?.status, 'verified'); assert.match(ledger.taskState?.['FND-001']?.commit ?? '', /^[0-9a-f]{40}$/u); assert.ok( @@ -319,3 +321,17 @@ test('repository checker rejects delivery-batch drift from the Luna plan', () => }, ); }); + +test('repository checker rejects malformed handoff task lists', () => { + for (const malformed of [{ invalid: true }, 'FND-007']) { + withTemporaryPlans( + ({ ledger }) => { + ledger.deliveryBatches[0].handoffTaskIds = malformed; + }, + (result) => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /handoffTaskIds must be an array/u); + }, + ); + } +}); diff --git a/tools/repo-cli/test/foundation-handoff.test.mjs b/tools/repo-cli/test/foundation-handoff.test.mjs new file mode 100644 index 00000000..5d48df1d --- /dev/null +++ b/tools/repo-cli/test/foundation-handoff.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const evidencePath = path.join( + repositoryRoot, + 'docs', + 'operations', + 'foundation-handoff-2026-08-03.md', +); + +test('foundation handoff records the current checkpoint and preserves unresolved gates', () => { + assert.ok(existsSync(evidencePath), 'foundation handoff evidence file is missing'); + const evidence = readFileSync(evidencePath, 'utf8'); + + assert.match(evidence, /^# Engineering foundation handoff$/mu); + for (const requiredSection of [ + '## Current checkpoint', + '## Verification record', + '## Explicit external gates', + '## B01 resume point', + '## Rollback points', + ]) { + assert.match(evidence, new RegExp(`^${requiredSection}$`, 'mu')); + } + assert.match(evidence, /origin\/dev.*9265e15/iu); + assert.match(evidence, /FND-005/iu); + assert.match(evidence, /FND-006/iu); + assert.match(evidence, /IAM-001/iu); + assert.match(evidence, /No customer data.*credentials/isu); +}); diff --git a/tools/repo-cli/test/foundation-telemetry-reconciliation.test.mjs b/tools/repo-cli/test/foundation-telemetry-reconciliation.test.mjs new file mode 100644 index 00000000..cba1e555 --- /dev/null +++ b/tools/repo-cli/test/foundation-telemetry-reconciliation.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const evidencePath = path.join( + repositoryRoot, + 'docs', + 'operations', + 'foundation-telemetry-diagnostics-2026-08-03.md', +); + +test('telemetry reconciliation records cross-runtime evidence and open environment gates', () => { + assert.ok(existsSync(evidencePath), 'telemetry reconciliation evidence file is missing'); + const evidence = readFileSync(evidencePath, 'utf8'); + + assert.match(evidence, /^# Foundation telemetry and diagnostics reconciliation$/mu); + for (const requiredSection of [ + '## Scope and safety boundary', + '## Cross-runtime verification', + '## Failure and privacy probes', + '## Known environment limits', + '## Release decision', + ]) { + assert.match(evidence, new RegExp(`^${requiredSection}$`, 'mu')); + } + assert.match(evidence, /TypeScript.*Python.*Kotlin/isu); + assert.match(evidence, /provider cause/iu); + assert.match(evidence, /Android SDK/iu); + assert.match(evidence, /No requirement record was promoted to `verified`/u); +});