diff --git a/docs/operations/iam-010-invitation-token-2026-08-03.md b/docs/operations/iam-010-invitation-token-2026-08-03.md new file mode 100644 index 00000000..67d4db89 --- /dev/null +++ b/docs/operations/iam-010-invitation-token-2026-08-03.md @@ -0,0 +1,31 @@ +# IAM-010 durable invitation token slice — 2026-08-03 + +## Scope + +This evidence record covers the durable persistence boundary for the IAM-010 invitation-token +slice. It is a partial implementation record, not a release approval or a claim that Plan 020 is +complete. + +## Delivered + +- `2a65756` adds the `iam.invitation_tokens` Prisma model and centrally ordered migration. +- Only the token digest and recipient-email digest are persisted; the raw bearer value is accepted + only by the delivery port and is never returned by the application result or stored in a row. +- `PrismaIamInvitationRepositoryAdapter` maps persisted rows through domain validation, enforces + tenant scope visibility, rejects sibling-token reads, prevents multiple active invitations per + membership, and uses compare-and-set revisions for redemption and membership activation. +- The Prisma foundation test proves the schema diff and migration inventory include the new table. + +## Verification + +- `corepack pnpm --filter @databreeze/api exec prisma validate --config prisma.config.ts` +- `corepack pnpm --filter @databreeze/api test` — 352 tests passed. +- `corepack pnpm --filter @databreeze/domain test` — 134 tests passed. +- `git diff --check` passed before commit. + +## Explicitly not complete + +The invitation HTTP/controller and production composition wiring, transactional AUD append, +registration for unknown recipients, resend/revocation administration, email-provider adapter, +and production PostgreSQL/backup/security evidence remain future work. IAM-010 therefore remains +`partial` and `not-verified` in the requirement manifest. diff --git a/docs/operations/iam-bua-security-slice-2026-08-03.md b/docs/operations/iam-bua-security-slice-2026-08-03.md new file mode 100644 index 00000000..2d817032 --- /dev/null +++ b/docs/operations/iam-bua-security-slice-2026-08-03.md @@ -0,0 +1,52 @@ +# IAM, audit, and entitlement security slice — 2026-08-03 + +## Scope + +This evidence record covers the 30-commit `feat/iam-security-completion` batch based on +`origin/dev`. It is implementation evidence only. It does not claim that Plan 020 or any +P0/P1 release gate is complete. + +## Delivered + +- IAM service-account identities now use bounded permissions, digest-only secrets, one-time + secret issuance, rotation, permanent revocation, last-use monotonicity, tenant-scoped + repositories, Prisma persistence, and versioned lifecycle HTTP contracts. +- AUD action vocabulary includes service-account lifecycle actions. Audit seal attestations + are canonical, independently signed, immutable, tenant-scoped, transaction-aware, and + available through in-memory and Prisma adapters with API verification. +- BUA entitlement snapshots validate their complete provider-independent plan projection. + Signed offline leases are bounded to 24 hours and snapshot expiry, bind revision and + security epoch, persist immutably, verify canonical payloads, and use a replaceable HMAC + signer or injected HSM/KMS-compatible signer. +- BUA and AUD module composition defaults to unavailable signing when key material is absent; + no secret is generated, logged, or committed by the repository. + +## Verification + +- Domain build and 148 domain tests pass, including canonical lease acceptance, malformed plan + rejection, attestation binding, tenant ancestry, and signature tampering cases. +- Focused API TypeScript compilation, ESLint, Prisma validation, OpenAPI generation/check, + Redocly validation, and focused IAM/AUD/BUA tests pass. +- Prisma migrations are ordered and add only `bua.entitlement_leases` and + `aud.audit_seal_attestations`; no migration was applied to a live environment. +- Traceability entries for IAM-013, AUD-015, AUD-016, BUA-017, and BUA-018 remain `partial` + and `not-verified`. They point to the concrete code, tests, and this evidence record. + +## Security and rollback notes + +- Lease payloads are canonicalized before signature verification; malformed, stale, expired, + overlong, wrong-scope, and tampered leases fail closed. +- Attestation storage never broadens a caller scope and rejects immutable-identity changes. +- HMAC keys must be at least 32 bytes and should be supplied by a secret manager. HMAC is a + portable default, not a replacement for a production KMS/HSM policy. +- Every commit on the feature branch is independently reversible. The migration commits must + be reverted only with a reviewed down-migration/restore procedure; no destructive rollback + was executed here. + +## Remaining gates + +Full audit export/legal-hold/retention administration, atomic cross-module audit coordination, +offline authorization snapshots, entitlement reconciliation/usage exports, real PostgreSQL +integration, backup restoration, security assessment, and release evidence remain outstanding. +The feature PR targets `dev` without CodeRabbit; CodeRabbit remains reserved for the later +`dev` to `main` promotion PR and is invoked once there. diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md new file mode 100644 index 00000000..b0564bbc --- /dev/null +++ b/docs/operations/iam-recovery-2026-08-03.md @@ -0,0 +1,33 @@ +# IAM recovery slice — 2026-08-03 + +This evidence records the partial account-recovery boundary delivered on `feat/iam-recovery`. +It does not claim that IAM-015 or the IAM plan is complete. + +## Scope + +- Validate a bounded recovery request and return the same accepted response for known and unknown email addresses. +- Generate a short-lived, single-use bearer, deliver the raw value only through the delivery port, and persist only keyed HMAC digests. +- Consume the bearer exactly once and atomically rotate the Argon2id credential, advance the user security epoch, revoke active sessions and MFA factors, and mark MFA re-enrollment required. +- Clear the re-enrollment gate in the same MFA transaction when a newly enrolled factor is successfully verified; failed proofs do not clear it. +- Carry the live gate through credential lookup, session lookup, protected request context, and sign-in/current-session projections without trusting client-supplied state. +- Apply a bounded, replaceable recovery-admission port before account lookup; unknown and throttled addresses receive the same generic response. +- The `RedisRecoveryAdmissionAdapter` implements that port for horizontally scaled deployments. It accepts only keyed digests, namespaces counter keys, requires an injected atomic `INCR`/`PEXPIRE` implementation, and fails closed on malformed input or counter failure. The in-memory adapter remains the alpha default until a Redis client is provisioned. +- Completion attempts use a separate admission port and, when Redis is configured, a distinct `databreeze:iam:recovery:completion:v1:` namespace so email-request and token-brute-force limits cannot collide. +- Keep the public completion response free of bearer material; no session is automatically created. +- Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing. + +## Evidence + +- Recovery state-machine tests: `packages/domain/test/recovery-v1.test.mjs`. +- Abuse-control tests: `services/api/test/features/iam/recovery-admission.test.ts` and `redis-recovery-admission.adapter.test.ts`. +- In-memory transaction/service tests: `services/api/test/features/iam/recovery.service.test.ts`. +- Durable schema adapter and atomic side-effect tests: `services/api/test/features/iam/prisma-recovery-repository.test.ts`. +- MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`. +- Live principal/context propagation tests: `services/api/test/features/iam/prisma-credential-lookup.test.ts`, `services/api/test/features/iam/prisma-session-lifecycle.test.ts`, and `services/api/test/platform/http/session-tenant-context.test.ts`. +- Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`. +- Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`). +- Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. + +## Verification + +The scoped API TypeScript build, recovery tests, i18n tests, OpenAPI generation/check, and Prisma validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until authenticated MFA re-enrollment enforcement, audit events, rate limits, abuse monitoring, restoration drills, and the complete IAM release gates are delivered. diff --git a/docs/operations/iam-registration-2026-08-03.md b/docs/operations/iam-registration-2026-08-03.md new file mode 100644 index 00000000..68c8028c --- /dev/null +++ b/docs/operations/iam-registration-2026-08-03.md @@ -0,0 +1,26 @@ +# IAM registration slice — 2026-08-03 + +This evidence records the partial account-registration boundary delivered on `feat/iam-registration`. +It does not claim that IAM-001 or the IAM plan is complete. + +## Scope + +- Normalize and validate the email, display name, locale, and password at the application boundary. +- Hash the password through the existing Argon2id password port; raw passwords never enter persistence. +- Create the user, credential, personal organization, workspace, internal project, and owner membership in one transaction. +- Keep duplicate-email responses generic and map persistence races to a safe rejection. +- Return only hierarchy identifiers and locale from `POST /v1/auth/register`; the endpoint never returns bearer material or automatically creates a session. +- Select the Prisma registration adapter only when durable registration storage and the password boundary are configured; otherwise the endpoint fails closed. + +## Evidence + +- Service and in-memory transaction tests: `services/api/test/features/iam/registration.service.test.ts`. +- Durable adapter and rollback tests: `services/api/test/features/iam/prisma-registration-repository.test.ts`. +- Composition and controller tests: `services/api/test/features/iam/registration-composition.test.ts` and `services/api/test/features/iam/registration-controller.test.ts`. +- HTTP and problem-details tests: `services/api/test/features/iam/registration-http.test.ts`. +- OpenAPI route: `services/api/openapi/v1.json` (`POST /v1/auth/register`). +- Bilingual error catalog coverage: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`. + +## Verification + +The scoped API TypeScript build, registration tests, i18n tests, OpenAPI generation/check, and Redocly validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until the complete IAM release gates, audit integration, recovery, MFA, and restoration evidence are delivered. diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index bb04f2d5..181d039e 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -1085,15 +1085,20 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.service.test.ts", + "services/api/test/features/aud/prisma-audit-attestation-repository.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], "status": "partial", "coverage": "partial", @@ -1110,20 +1115,24 @@ "codePaths": [ "packages/domain/src/audit/v1.ts", "services/api/src/features/aud/", - "services/api/prisma/schema/aud.prisma" + "services/api/prisma/schema/aud.prisma", + "services/api/src/features/aud/application/audit-attestation.service.ts", + "services/api/src/features/aud/api/audit-attestation.controller.ts", + "services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts", + "services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql" ], "testPaths": [ "packages/domain/test/audit-v1.test.mjs", "services/api/test/features/aud/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/aud/audit-attestation.controller.test.ts", + "services/api/test/features/aud/audit-attestation-repository.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -1763,20 +1772,23 @@ "codePaths": [ "packages/domain/src/entitlements/v1.ts", "services/api/src/features/bua/", - "services/api/prisma/schema/bua.prisma" + "services/api/prisma/schema/bua.prisma", + "services/api/src/features/bua/application/entitlement-lease.service.ts", + "services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts", + "services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql" ], "testPaths": [ "packages/domain/test/entitlements-v1.test.mjs", "services/api/test/features/bua/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/bua/entitlement-lease.service.test.ts", + "services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -1790,20 +1802,23 @@ "codePaths": [ "packages/domain/src/entitlements/v1.ts", "services/api/src/features/bua/", - "services/api/prisma/schema/bua.prisma" + "services/api/prisma/schema/bua.prisma", + "services/api/src/features/bua/api/entitlement.controller.ts", + "services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts", + "services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql" ], "testPaths": [ "packages/domain/test/entitlements-v1.test.mjs", "services/api/test/features/bua/", - "services/api/test/http-contract.test.ts" + "services/api/test/http-contract.test.ts", + "services/api/test/features/bua/entitlement.controller.test.ts", + "services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" @@ -8985,7 +9000,10 @@ "priority": "P0", "primaryPlan": "020-identity-audit-entitlements.md", "primaryTask": "Task 1: IAM identity and permissions", - "supportingTasks": [], + "supportingTasks": [ + "IAM-010 durable invitation token persistence slice", + "IAM registration and personal-tenant persistence slice" + ], "codePaths": [ "packages/domain/src/identity/v1.ts", "packages/domain/src/permissions/v1.ts", @@ -8993,17 +9011,26 @@ "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts", + "services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts", + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", "services/api/test/features/iam/", + "services/api/test/features/iam/prisma-iam-invitation-repository.test.ts", + "services/api/test/features/iam/prisma-registration-repository.test.ts", + "services/api/test/features/iam/registration.service.test.ts", + "services/api/test/features/iam/registration-http.test.ts", + "services/api/test/prisma-foundation.test.mjs", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md", + "docs/operations/iam-registration-2026-08-03.md" ], "status": "partial", "coverage": "partial", @@ -9283,11 +9310,12 @@ ], "releaseEvidence": [ "requirement-linked-tests", + "docs/operations/iam-010-invitation-token-2026-08-03.md", "security-and-tenant-gate", "release-manager-approval" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9366,23 +9394,26 @@ "packages/domain/src/authorization/v1.ts", "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", + "packages/domain/src/service-account/v1.ts", + "packages/domain/src/audit/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", "services/api/test/features/iam/", + "services/api/test/features/iam/service-account.service.test.ts", + "services/api/test/features/iam/prisma-service-account-repository.test.ts", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-bua-security-slice-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "p0-release-gate" @@ -9430,23 +9461,44 @@ "packages/domain/src/authorization/v1.ts", "packages/domain/src/mfa/v1.ts", "packages/domain/src/csrf/v1.ts", + "packages/domain/src/recovery/v1.ts", "services/api/src/features/iam/", - "services/api/prisma/schema/iam.prisma" + "services/api/prisma/schema/iam.prisma", + "services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql", + "services/api/src/features/iam/application/recovery.service.ts", + "services/api/src/features/iam/application/mfa.service.ts", + "services/api/src/features/iam/application/mfa-repository.port.ts", + "services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts", + "services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts", + "services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts", + "services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts", + "services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts", + "services/api/src/features/iam/application/recovery-repository.port.ts", + "services/api/src/features/iam/iam.module.ts", + "services/api/src/features/iam/api/recovery.controller.ts" ], "testPaths": [ "packages/domain/test/identity-v1.test.mjs", "packages/domain/test/permissions-v1.test.mjs", - "services/api/test/features/iam/", + "packages/domain/test/recovery-v1.test.mjs", + "services/api/test/features/iam/recovery.service.test.ts", + "services/api/test/features/iam/prisma-recovery-repository.test.ts", + "services/api/test/features/iam/recovery-crypto.test.ts", + "services/api/test/features/iam/recovery-admission.test.ts", + "services/api/test/features/iam/redis-recovery-admission.adapter.test.ts", + "services/api/test/features/iam/mfa.service.test.ts", + "services/api/test/features/iam/prisma-mfa-repository.test.ts", + "services/api/test/features/iam/recovery-composition.test.ts", + "services/api/test/features/iam/recovery-controller.test.ts", + "services/api/test/features/iam/recovery-http.test.ts", "services/api/test/platform/http/session-tenant-context.test.ts", "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "requirement-linked-tests", - "security-and-tenant-gate", - "release-manager-approval" + "docs/operations/iam-recovery-2026-08-03.md" ], - "status": "planned", - "coverage": "planned", + "status": "partial", + "coverage": "partial", "verificationStatus": "not-verified", "verifiedPaths": [], "releaseStatus": "ga-completion" diff --git a/packages/domain/package.json b/packages/domain/package.json index 3499f462..062856d2 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -28,6 +28,10 @@ "types": "./src/identity/v1.ts", "import": "./dist/identity/v1.js" }, + "./service-account/v1": { + "types": "./src/service-account/v1.ts", + "import": "./dist/service-account/v1.js" + }, "./entitlements/v1": { "types": "./src/entitlements/v1.ts", "import": "./dist/entitlements/v1.js" @@ -36,6 +40,14 @@ "types": "./src/mfa/v1.ts", "import": "./dist/mfa/v1.js" }, + "./invitation/v1": { + "types": "./src/invitation/v1.ts", + "import": "./dist/invitation/v1.js" + }, + "./recovery/v1": { + "types": "./src/recovery/v1.ts", + "import": "./dist/recovery/v1.js" + }, "./device-authorization/v1": { "types": "./src/device-authorization/v1.ts", "import": "./dist/device-authorization/v1.js" diff --git a/packages/domain/src/approval/v1.ts b/packages/domain/src/approval/v1.ts index 4192603c..3fe6b24e 100644 --- a/packages/domain/src/approval/v1.ts +++ b/packages/domain/src/approval/v1.ts @@ -73,6 +73,7 @@ export type ApprovalErrorCodeV1 = | 'SUBJECT_HASH_MISMATCH' | 'SELF_APPROVAL_FORBIDDEN' | 'MFA_REQUIRED' + | 'MFA_REENROLLMENT_REQUIRED' | 'REQUEST_NOT_OPEN'; export type ApprovalResultV1 = diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index ba3a4ce6..be01131c 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -25,6 +25,9 @@ export const AUDIT_ACTIONS_V1 = Object.freeze([ 'device.enrolled', 'device.activated', 'device.revoked', + 'service_account.created', + 'service_account.rotated', + 'service_account.revoked', 'entitlement.granted', 'entitlement.suspended', 'artifact.registered', @@ -99,6 +102,28 @@ export interface AuditSealV1 { readonly sealedAt: StrictUtcTimestampV1; } +/** AUD-015/016: an independently stored signature over an immutable seal range. */ +export const AUDIT_ATTESTATION_SCHEMA_VERSION_V1 = 1 as const; + +export interface AuditSealAttestationV1 { + readonly schemaVersion: typeof AUDIT_ATTESTATION_SCHEMA_VERSION_V1; + readonly attestationId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: StrictUtcTimestampV1; + readonly signerKeyId: string; + readonly payload: string; + readonly signature: string; +} + +export interface AuditSealAttestationSignerV1 { + sign(payload: string): string; + verify(payload: string, signature: string): boolean; +} + export type AuditErrorCodeV1 = | 'INVALID_IDENTIFIER' | 'INVALID_TIMESTAMP' @@ -185,6 +210,22 @@ function canonicalEvent(event: Omit): string { }); } +function canonicalAttestation( + input: Omit, +): string { + return JSON.stringify({ + schemaVersion: input.schemaVersion, + attestationId: input.attestationId, + tenantScope: input.tenantScope, + firstSequence: input.firstSequence, + lastSequence: input.lastSequence, + eventCount: input.eventCount, + rootDigest: input.rootDigest, + sealedAt: input.sealedAt, + signerKeyId: input.signerKeyId, + }); +} + export function sanitizeAuditSummaryV1(input: unknown): AuditResultV1 { if (input === undefined) return Object.freeze({ accepted: true, value: Object.freeze({}) }); if (typeof input !== 'object' || input === null || Array.isArray(input)) @@ -353,3 +394,52 @@ export function createAuditSealV1( }), }); } + +export function createAuditSealAttestationV1( + seal: AuditSealV1, + input: { readonly attestationId: unknown; readonly signerKeyId: unknown }, + signer: AuditSealAttestationSignerV1, +): AuditResultV1 { + const attestationId = stableId(input.attestationId); + const signerKeyId = text(input.signerKeyId, 200); + if (!attestationId || !signerKeyId) return rejected('INVALID_IDENTIFIER'); + const unsigned: Omit = { + schemaVersion: AUDIT_ATTESTATION_SCHEMA_VERSION_V1, + attestationId, + tenantScope: seal.tenantScope, + firstSequence: seal.firstSequence, + lastSequence: seal.lastSequence, + eventCount: seal.eventCount, + rootDigest: seal.rootDigest, + sealedAt: seal.sealedAt, + signerKeyId, + }; + const payload = canonicalAttestation(unsigned); + const signature = text(signer.sign(payload), 2048); + if (!signature) return rejected('INVALID_TEXT'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ ...unsigned, payload, signature }), + }); +} + +export function verifyAuditSealAttestationV1( + attestation: AuditSealAttestationV1, + seal: AuditSealV1, + signer: AuditSealAttestationSignerV1, +): AuditResultV1 { + if ( + attestation.schemaVersion !== AUDIT_ATTESTATION_SCHEMA_VERSION_V1 || + tenantScopeKeyV1(attestation.tenantScope) !== tenantScopeKeyV1(seal.tenantScope) || + attestation.firstSequence !== seal.firstSequence || + attestation.lastSequence !== seal.lastSequence || + attestation.eventCount !== seal.eventCount || + attestation.rootDigest !== seal.rootDigest || + attestation.sealedAt !== seal.sealedAt + ) + return rejected('CHAIN_INVALID'); + const { payload, signature, ...unsigned } = attestation; + if (canonicalAttestation(unsigned) !== payload || !signer.verify(payload, signature)) + return rejected('CHAIN_INVALID'); + return Object.freeze({ accepted: true, value: true }); +} diff --git a/packages/domain/src/authorization/v1.ts b/packages/domain/src/authorization/v1.ts index 0a73edf4..b0d98ecc 100644 --- a/packages/domain/src/authorization/v1.ts +++ b/packages/domain/src/authorization/v1.ts @@ -143,6 +143,7 @@ const resourceScopeTypes: Readonly { + const snapshotId = stableId(input.snapshotId); + const tenantScope = scope(input.tenantScope); + const plan = input.plan; + const revision = positiveInteger(input.revision); + const securityEpoch = positiveInteger(input.securityEpoch); + const effectiveAt = timestamp(input.effectiveAt); + const expiresAt = input.expiresAt === undefined ? undefined : timestamp(input.expiresAt); + if (!snapshotId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope || tenantScope.scopeType === 'project') return rejected('INVALID_SCOPE'); + if ( + typeof plan !== 'object' || + plan === null || + (plan as Partial).schemaVersion !== ENTITLEMENT_SCHEMA_VERSION_V1 || + (plan as Partial).providerIndependent !== true + ) + return rejected('INVALID_PLAN'); + const normalizedPlan = createPlanV1({ + planCode: (plan as Partial).planCode, + displayNameKey: (plan as Partial).displayNameKey, + features: (plan as Partial).features, + quotas: (plan as Partial).quotas, + }); + if (!normalizedPlan.accepted) return rejected('INVALID_PLAN'); + if (!validSnapshotStatus(input.status)) return rejected('INVALID_STATE'); + if (!revision || !securityEpoch) return rejected('INVALID_STATE'); + if (!effectiveAt || (input.expiresAt !== undefined && !expiresAt)) + return rejected('INVALID_TIMESTAMP'); + if (expiresAt && Date.parse(expiresAt) <= Date.parse(effectiveAt)) + return rejected('INVALID_TIMESTAMP'); + const typedPlan = normalizedPlan.value; + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + snapshotId, + organizationId: tenantScope.organizationId, + ...(tenantScope.scopeType === 'workspace' ? { workspaceId: tenantScope.workspaceId } : {}), + planCode: typedPlan.planCode, + status: input.status, + revision, + securityEpoch, + effectiveAt, + ...(expiresAt ? { expiresAt } : {}), + features: Object.freeze([...typedPlan.features]), + quotas: Object.freeze(typedPlan.quotas.map((quota) => Object.freeze({ ...quota }))), + }), + }); +} + +function canonicalLease(input: Omit): string { + return JSON.stringify({ + schemaVersion: input.schemaVersion, + leaseId: input.leaseId, + tenantScope: input.tenantScope, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + issuedAt: input.issuedAt, + expiresAt: input.expiresAt, + }); +} + +/** Issue a bounded, signed offline entitlement lease tied to a snapshot revision and epoch. */ +export function createEntitlementLeaseV1( + snapshot: EntitlementSnapshotV1, + input: { readonly leaseId: unknown; readonly issuedAt: unknown; readonly expiresAt: unknown }, + signer: LeaseSignatureIssuerV1, +): EntitlementResultV1 { + const leaseId = stableId(input.leaseId); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const snapshotScope: TenantScopeV1 = snapshot.workspaceId + ? { + scopeType: 'workspace', + organizationId: snapshot.organizationId, + workspaceId: snapshot.workspaceId, + } + : { scopeType: 'organization', organizationId: snapshot.organizationId }; + if (!leaseId) return rejected('INVALID_IDENTIFIER'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + const blocked = snapshotAllows(snapshot, issuedAt); + if (blocked) return rejected(blocked); + if ( + !Number.isFinite(Date.parse(issuedAt)) || + !Number.isFinite(Date.parse(expiresAt)) || + Date.parse(issuedAt) < Date.parse(snapshot.effectiveAt) || + Date.parse(expiresAt) <= Date.parse(issuedAt) || + Date.parse(expiresAt) - Date.parse(issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 || + (snapshot.expiresAt !== undefined && Date.parse(expiresAt) > Date.parse(snapshot.expiresAt)) + ) + return rejected('LEASE_INVALID'); + const unsigned: Omit = { + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + leaseId, + tenantScope: snapshotScope, + snapshotRevision: snapshot.revision, + securityEpoch: snapshot.securityEpoch, + issuedAt, + expiresAt, + }; + const payload = canonicalLease(unsigned); + const signature = text(signer.sign(payload), 2048); + if (!signature) return rejected('LEASE_INVALID'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ ...unsigned, payload, signature }), + }); +} + export function createPlanV1(input: { readonly planCode: unknown; readonly displayNameKey: unknown; @@ -465,20 +592,51 @@ export function acceptEntitlementLeaseV1( ): EntitlementResultV1 { const now = timestamp(input.now); const tenantScope = scope(input.tenantScope); + const leaseId = stableId(lease.leaseId); + const leaseScope = scope(lease.tenantScope); + const issuedAt = timestamp(lease.issuedAt); + const expiresAt = timestamp(lease.expiresAt); const snapshotRevision = positiveInteger(input.snapshotRevision); const securityEpoch = positiveInteger(input.securityEpoch); if (!now) return rejected('INVALID_TIMESTAMP'); if (!tenantScope) return rejected('INVALID_SCOPE'); if (!snapshotRevision || !securityEpoch) return rejected('INVALID_STATE'); - if (!sameScope(lease.tenantScope, tenantScope)) return rejected('LEASE_STALE'); - if (lease.snapshotRevision !== snapshotRevision || lease.securityEpoch !== securityEpoch) - return rejected('LEASE_STALE'); - if (!verifier.verify(lease.payload, lease.signature)) return rejected('LEASE_INVALID'); if ( - Date.parse(now) < Date.parse(lease.issuedAt) || - Date.parse(now) >= Date.parse(lease.expiresAt) || - Date.parse(lease.expiresAt) - Date.parse(lease.issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 + !leaseId || + !leaseScope || + leaseScope.scopeType === 'project' || + !issuedAt || + !expiresAt || + lease.schemaVersion !== ENTITLEMENT_SCHEMA_VERSION_V1 || + !positiveInteger(lease.snapshotRevision) || + !positiveInteger(lease.securityEpoch) || + !text(lease.payload, 10000) || + !text(lease.signature, 2048) || + Date.parse(expiresAt) <= Date.parse(issuedAt) || + Date.parse(expiresAt) - Date.parse(issuedAt) > OFFLINE_LEASE_MAX_SECONDS_V1 * 1_000 || + lease.payload !== + canonicalLease({ + schemaVersion: ENTITLEMENT_SCHEMA_VERSION_V1, + leaseId, + tenantScope: leaseScope, + snapshotRevision: lease.snapshotRevision, + securityEpoch: lease.securityEpoch, + issuedAt, + expiresAt, + }) ) return rejected('LEASE_INVALID'); + if (!sameScope(leaseScope, tenantScope)) return rejected('LEASE_STALE'); + if (lease.snapshotRevision !== snapshotRevision || lease.securityEpoch !== securityEpoch) + return rejected('LEASE_STALE'); + let signatureValid = false; + try { + signatureValid = verifier.verify(lease.payload, lease.signature); + } catch { + signatureValid = false; + } + if (!signatureValid) return rejected('LEASE_INVALID'); + if (Date.parse(now) < Date.parse(issuedAt) || Date.parse(now) >= Date.parse(expiresAt)) + return rejected('LEASE_INVALID'); return Object.freeze({ accepted: true, value: true }); } diff --git a/packages/domain/src/invitation/v1.ts b/packages/domain/src/invitation/v1.ts new file mode 100644 index 00000000..3b1f40c6 --- /dev/null +++ b/packages/domain/src/invitation/v1.ts @@ -0,0 +1,153 @@ +import { INVITATION_MAX_SECONDS_V1, type InitialRoleIdForIdentityV1 } from '../identity/v1.js'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; + +/** IAM-010: persisted invitation tokens never contain the raw bearer value. */ +export const INVITATION_TOKEN_SCHEMA_VERSION_V1 = 1 as const; +export { INVITATION_MAX_SECONDS_V1 } from '../identity/v1.js'; + +export type InvitationTokenStatusV1 = 'ACTIVE' | 'REDEEMED' | 'REVOKED'; + +export interface InvitationTokenV1 { + readonly schemaVersion: typeof INVITATION_TOKEN_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly principalId: StableIdentifierV1; + readonly scope: TenantScopeV1; + readonly roleId: InitialRoleIdForIdentityV1; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: StrictUtcTimestampV1; + readonly expiresAt: StrictUtcTimestampV1; + readonly status: InvitationTokenStatusV1; + readonly consumedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type InvitationTokenErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_ROLE' + | 'INVALID_DIGEST' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_STATE' + | 'ALREADY_CONSUMED' + | 'EXPIRED'; + +export type InvitationTokenResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: InvitationTokenErrorCodeV1 }; + +function accepted(value: TValue): InvitationTokenResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: InvitationTokenErrorCodeV1): InvitationTokenResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function role(input: unknown): input is InitialRoleIdForIdentityV1 { + return ( + input === 'owner' || + input === 'admin' || + input === 'analyst' || + input === 'operator' || + input === 'approver' || + input === 'viewer' + ); +} + +function positiveRevision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +export function createInvitationTokenV1(input: { + readonly id: unknown; + readonly membershipId: unknown; + readonly principalId: unknown; + readonly scope: unknown; + readonly roleId: unknown; + readonly tokenDigest: unknown; + readonly emailDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly revision?: unknown; +}): InvitationTokenResultV1 { + const id = stable(input.id); + const membershipId = stable(input.membershipId); + const principalId = stable(input.principalId); + const scope = parseTenantScopeV1(input.scope); + const tokenDigest = digest(input.tokenDigest); + const emailDigest = digest(input.emailDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const revision = input.revision === undefined ? 1 : positiveRevision(input.revision); + if (!id || !membershipId || !principalId) return rejected('INVALID_IDENTIFIER'); + if (!scope.accepted) return rejected('INVALID_SCOPE'); + if (!role(input.roleId)) return rejected('INVALID_ROLE'); + if (!tokenDigest || !emailDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + if (!revision) return rejected('INVALID_STATE'); + const duration = Date.parse(expiresAt) - Date.parse(issuedAt); + if (!Number.isFinite(duration) || duration <= 0 || duration > INVITATION_MAX_SECONDS_V1 * 1_000) + return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: INVITATION_TOKEN_SCHEMA_VERSION_V1, + id, + membershipId, + principalId, + scope: scope.value, + roleId: input.roleId, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, + status: 'ACTIVE' as const, + revision, + }), + ); +} + +export function consumeInvitationTokenV1( + token: InvitationTokenV1, + at: unknown, +): InvitationTokenResultV1 { + const timestampValue = timestamp(at); + if (!timestampValue) return rejected('INVALID_TIMESTAMP'); + if (token.status !== 'ACTIVE') return rejected('ALREADY_CONSUMED'); + const nowMs = Date.parse(timestampValue); + const issuedMs = Date.parse(token.issuedAt); + const expiresMs = Date.parse(token.expiresAt); + if (!Number.isFinite(nowMs) || nowMs < issuedMs) return rejected('INVALID_TIMESTAMP'); + if (nowMs >= expiresMs) return rejected('EXPIRED'); + return accepted( + Object.freeze({ + ...token, + status: 'REDEEMED' as const, + consumedAt: timestampValue, + revision: token.revision + 1, + }), + ); +} diff --git a/packages/domain/src/mfa/v1.ts b/packages/domain/src/mfa/v1.ts index 876b6017..de7d2e20 100644 --- a/packages/domain/src/mfa/v1.ts +++ b/packages/domain/src/mfa/v1.ts @@ -57,6 +57,7 @@ export type MfaErrorCodeV1 = | 'FACTOR_PROOF_INVALID' | 'RECOVERY_CODE_INVALID' | 'RECOVERY_CODE_USED' + | 'MFA_REENROLLMENT_REQUIRED' | 'STEP_UP_REQUIRED'; export type MfaResultV1 = @@ -216,8 +217,11 @@ export function requiresStepUpV1( assertion: StepUpAssertionV1 | undefined, principalId: StableIdentifierV1, now: unknown, + mfaReenrollmentRequired = false, ): MfaResultV1 { if (risk !== 'NORMAL' && risk !== 'HIGH' && risk !== 'CRITICAL') return rejected('INVALID_STATE'); + if (risk !== 'NORMAL' && mfaReenrollmentRequired === true) + return rejected('MFA_REENROLLMENT_REQUIRED'); if (risk === 'NORMAL') return Object.freeze({ accepted: true, value: true }); if (assertion && isFreshStepUpV1(assertion, principalId, now)) return Object.freeze({ accepted: true, value: true }); diff --git a/packages/domain/src/permissions/v1.ts b/packages/domain/src/permissions/v1.ts index 6ee72030..e5b181bd 100644 --- a/packages/domain/src/permissions/v1.ts +++ b/packages/domain/src/permissions/v1.ts @@ -27,6 +27,9 @@ export const PERMISSIONS_V1 = Object.freeze({ BILLING_ACCOUNT_MANAGE: 'billing.account.manage', DEVICE_IDENTITY_READ: 'device.identity.read', DEVICE_IDENTITY_REVOKE: 'device.identity.revoke', + SERVICE_ACCOUNT_READ: 'service.account.read', + SERVICE_ACCOUNT_MANAGE: 'service.account.manage', + SERVICE_ACCOUNT_REVOKE: 'service.account.revoke', } as const); export type PermissionV1 = (typeof PERMISSIONS_V1)[keyof typeof PERMISSIONS_V1]; @@ -49,6 +52,7 @@ export const RESOURCE_TYPES_V1 = Object.freeze([ 'artifact', 'billing-account', 'device', + 'service-account', 'job', 'organization', 'project', @@ -147,6 +151,9 @@ export const PERMISSION_APPLICABILITY_V1: Readonly< 'billing.account.manage': immutableApplicability('billing-account', ['api', 'web']), 'device.identity.read': immutableApplicability('device', ['api', 'web']), 'device.identity.revoke': immutableApplicability('device', ['api', 'web']), + 'service.account.read': immutableApplicability('service-account', ['api', 'web']), + 'service.account.manage': immutableApplicability('service-account', ['api', 'web']), + 'service.account.revoke': immutableApplicability('service-account', ['api', 'web']), }); export const INITIAL_ROLE_IDS_V1 = Object.freeze([ @@ -190,6 +197,9 @@ const adminPermissions = [ PERMISSIONS_V1.JOB_EXECUTION_READ, PERMISSIONS_V1.DEVICE_IDENTITY_READ, PERMISSIONS_V1.DEVICE_IDENTITY_REVOKE, + PERMISSIONS_V1.SERVICE_ACCOUNT_READ, + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, ] as const; const ownerPermissionSet = new Set([ diff --git a/packages/domain/src/recovery/v1.ts b/packages/domain/src/recovery/v1.ts new file mode 100644 index 00000000..eb2fe570 --- /dev/null +++ b/packages/domain/src/recovery/v1.ts @@ -0,0 +1,145 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, +} from '../tenant-scope/v1.js'; + +/** IAM-015: short-lived recovery bearers are hashed and single-use at rest. */ +export const RECOVERY_CHALLENGE_SCHEMA_VERSION_V1 = 1 as const; +export const RECOVERY_CHALLENGE_MAX_SECONDS_V1 = 60 * 60; + +export type RecoveryChallengeStatusV1 = 'ACTIVE' | 'CONSUMED' | 'REVOKED'; + +export interface RecoveryChallengeV1 { + readonly schemaVersion: typeof RECOVERY_CHALLENGE_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly userId: StableIdentifierV1; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: string; + readonly expiresAt: string; + readonly status: RecoveryChallengeStatusV1; + readonly consumedAt?: string; + readonly revokedAt?: string; + readonly revision: number; +} + +export type RecoveryChallengeErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_DIGEST' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_STATE' + | 'ALREADY_TERMINAL' + | 'EXPIRED'; + +export type RecoveryChallengeResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: RecoveryChallengeErrorCodeV1 }; + +function accepted(value: TValue): RecoveryChallengeResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: RecoveryChallengeErrorCodeV1): RecoveryChallengeResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): string | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function revision(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +export function createRecoveryChallengeV1(input: { + readonly id: unknown; + readonly userId: unknown; + readonly tokenDigest: unknown; + readonly emailDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt: unknown; + readonly revision?: unknown; +}): RecoveryChallengeResultV1 { + const id = stable(input.id); + const userId = stable(input.userId); + const tokenDigest = digest(input.tokenDigest); + const emailDigest = digest(input.emailDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = timestamp(input.expiresAt); + const currentRevision = input.revision === undefined ? 1 : revision(input.revision); + if (!id || !userId) return rejected('INVALID_IDENTIFIER'); + if (!tokenDigest || !emailDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || !expiresAt) return rejected('INVALID_TIMESTAMP'); + if (!currentRevision) return rejected('INVALID_STATE'); + const duration = Date.parse(expiresAt) - Date.parse(issuedAt); + if ( + !Number.isFinite(duration) || + duration <= 0 || + duration > RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000 + ) + return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: RECOVERY_CHALLENGE_SCHEMA_VERSION_V1, + id, + userId, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, + status: 'ACTIVE' as const, + revision: currentRevision, + }), + ); +} + +export function consumeRecoveryChallengeV1( + challenge: RecoveryChallengeV1, + at: unknown, +): RecoveryChallengeResultV1 { + const current = timestamp(at); + if (!current) return rejected('INVALID_TIMESTAMP'); + if (challenge.status !== 'ACTIVE') return rejected('ALREADY_TERMINAL'); + const now = Date.parse(current); + const issued = Date.parse(challenge.issuedAt); + const expires = Date.parse(challenge.expiresAt); + if (!Number.isFinite(now) || now < issued) return rejected('INVALID_TIMESTAMP'); + if (now >= expires) return rejected('EXPIRED'); + return accepted( + Object.freeze({ + ...challenge, + status: 'CONSUMED' as const, + consumedAt: current, + revision: challenge.revision + 1, + }), + ); +} + +export function revokeRecoveryChallengeV1( + challenge: RecoveryChallengeV1, + at: unknown, +): RecoveryChallengeResultV1 { + const current = timestamp(at); + if (!current) return rejected('INVALID_TIMESTAMP'); + if (challenge.status !== 'ACTIVE') return rejected('ALREADY_TERMINAL'); + return accepted( + Object.freeze({ + ...challenge, + status: 'REVOKED' as const, + revokedAt: current, + revision: challenge.revision + 1, + }), + ); +} diff --git a/packages/domain/src/service-account/v1.ts b/packages/domain/src/service-account/v1.ts new file mode 100644 index 00000000..bfe9fb59 --- /dev/null +++ b/packages/domain/src/service-account/v1.ts @@ -0,0 +1,268 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '../tenant-scope/v1.js'; +import { isPermissionV1, type PermissionV1 } from '../permissions/v1.js'; + +/** IAM-013: organization-owned, non-interactive, action-scoped service identities. */ +export const SERVICE_ACCOUNT_SCHEMA_VERSION_V1 = 1 as const; +export const SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1 = 64 as const; +export const SERVICE_ACCOUNT_MAX_LIFETIME_SECONDS_V1 = 365 * 24 * 60 * 60; + +export type ServiceAccountStatusV1 = 'ACTIVE' | 'REVOKED'; + +export interface ServiceAccountV1 { + readonly schemaVersion: typeof SERVICE_ACCOUNT_SCHEMA_VERSION_V1; + readonly id: StableIdentifierV1; + readonly organizationId: StableIdentifierV1; + readonly workspaceId?: StableIdentifierV1; + readonly name: string; + readonly permissions: readonly PermissionV1[]; + readonly status: ServiceAccountStatusV1; + readonly secretDigest: string; + readonly secretVersion: number; + readonly secretIssuedAt: StrictUtcTimestampV1; + readonly secretExpiresAt?: StrictUtcTimestampV1; + readonly lastUsedAt?: StrictUtcTimestampV1; + readonly createdAt: StrictUtcTimestampV1; + readonly revokedAt?: StrictUtcTimestampV1; + readonly revision: number; +} + +export type ServiceAccountErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_TEXT' + | 'INVALID_TIMESTAMP' + | 'INVALID_LIFETIME' + | 'INVALID_PERMISSION' + | 'INVALID_DIGEST' + | 'INVALID_REVISION' + | 'INVALID_STATE' + | 'SECRET_REVOKED' + | 'SECRET_EXPIRED' + | 'REVISION_CONFLICT'; + +export type ServiceAccountResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ServiceAccountErrorCodeV1 }; + +function accepted(value: TValue): ServiceAccountResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ServiceAccountErrorCodeV1): ServiceAccountResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function boundedText(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function digest(input: unknown): string | undefined { + return typeof input === 'string' && /^[a-f0-9]{64}$/u.test(input) ? input : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function lifetimeWithin(issuedAt: StrictUtcTimestampV1, expiresAt: StrictUtcTimestampV1): boolean { + const issued = Date.parse(issuedAt); + const expires = Date.parse(expiresAt); + return ( + Number.isFinite(issued) && + Number.isFinite(expires) && + expires > issued && + expires - issued <= SERVICE_ACCOUNT_MAX_LIFETIME_SECONDS_V1 * 1_000 + ); +} + +function permissions(input: unknown): readonly PermissionV1[] | undefined { + if ( + !Array.isArray(input) || + input.length === 0 || + input.length > SERVICE_ACCOUNT_MAX_PERMISSION_COUNT_V1 + ) + return undefined; + const values = input.filter((permission): permission is PermissionV1 => + isPermissionV1(permission), + ); + if (values.length !== input.length) return undefined; + return Object.freeze([...new Set(values)]); +} + +function validSecretWindow( + issuedAt: StrictUtcTimestampV1, + expiresAt: StrictUtcTimestampV1 | undefined, +): boolean { + return expiresAt === undefined || lifetimeWithin(issuedAt, expiresAt); +} + +/** Create a service account record from a keyed digest; raw credentials never enter this value. */ +export function createServiceAccountV1(input: { + readonly id: unknown; + readonly organizationId: unknown; + readonly workspaceId?: unknown; + readonly name: unknown; + readonly permissions: unknown; + readonly secretDigest: unknown; + readonly secretIssuedAt: unknown; + readonly secretExpiresAt?: unknown; + readonly createdAt: unknown; +}): ServiceAccountResultV1 { + const id = stableId(input.id); + const organizationId = stableId(input.organizationId); + const workspaceId = input.workspaceId === undefined ? undefined : stableId(input.workspaceId); + const name = boundedText(input.name, 200); + const permissionValues = permissions(input.permissions); + const secretDigest = digest(input.secretDigest); + const secretIssuedAt = timestamp(input.secretIssuedAt); + const secretExpiresAt = + input.secretExpiresAt === undefined ? undefined : timestamp(input.secretExpiresAt); + const createdAt = timestamp(input.createdAt); + if (!id || !organizationId || (input.workspaceId !== undefined && !workspaceId)) + return rejected('INVALID_IDENTIFIER'); + if (!name) return rejected('INVALID_TEXT'); + if (!permissionValues) return rejected('INVALID_PERMISSION'); + if (!secretDigest) return rejected('INVALID_DIGEST'); + if (!secretIssuedAt || !createdAt) return rejected('INVALID_TIMESTAMP'); + if (input.secretExpiresAt !== undefined && !secretExpiresAt) return rejected('INVALID_TIMESTAMP'); + if (!validSecretWindow(secretIssuedAt, secretExpiresAt)) return rejected('INVALID_LIFETIME'); + return accepted( + Object.freeze({ + schemaVersion: SERVICE_ACCOUNT_SCHEMA_VERSION_V1, + id, + organizationId, + ...(workspaceId === undefined ? {} : { workspaceId }), + name, + permissions: permissionValues, + status: 'ACTIVE' as const, + secretDigest, + secretVersion: 1, + secretIssuedAt, + ...(secretExpiresAt === undefined ? {} : { secretExpiresAt }), + createdAt, + revision: 1, + }), + ); +} + +/** Rotate the stored digest atomically; the old secret must be rejected after this successor version. */ +export function rotateServiceAccountSecretV1( + current: ServiceAccountV1, + input: { + readonly secretDigest: unknown; + readonly issuedAt: unknown; + readonly expiresAt?: unknown; + readonly expectedRevision: unknown; + }, +): ServiceAccountResultV1 { + const secretDigest = digest(input.secretDigest); + const issuedAt = timestamp(input.issuedAt); + const expiresAt = input.expiresAt === undefined ? undefined : timestamp(input.expiresAt); + const expectedRevision = positiveInteger(input.expectedRevision); + if (!secretDigest) return rejected('INVALID_DIGEST'); + if (!issuedAt || (input.expiresAt !== undefined && !expiresAt)) + return rejected('INVALID_TIMESTAMP'); + if (!expectedRevision || expectedRevision !== current.revision) + return rejected('REVISION_CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (!validSecretWindow(issuedAt, expiresAt)) return rejected('INVALID_LIFETIME'); + if (Date.parse(issuedAt) < Date.parse(current.secretIssuedAt)) + return rejected('INVALID_TIMESTAMP'); + if (expiresAt === undefined) { + const { secretExpiresAt: _previousExpiry, ...withoutExpiry } = current; + void _previousExpiry; + return accepted( + Object.freeze({ + ...withoutExpiry, + secretDigest, + secretVersion: current.secretVersion + 1, + secretIssuedAt: issuedAt, + revision: current.revision + 1, + }), + ); + } + return accepted( + Object.freeze({ + ...current, + secretDigest, + secretVersion: current.secretVersion + 1, + secretIssuedAt: issuedAt, + secretExpiresAt: expiresAt, + revision: current.revision + 1, + }), + ); +} + +/** Mark use without changing permissions or secret material; timestamps may only move forward. */ +export function markServiceAccountUsedV1( + current: ServiceAccountV1, + usedAtInput: unknown, +): ServiceAccountResultV1 { + const usedAt = timestamp(usedAtInput); + if (!usedAt) return rejected('INVALID_TIMESTAMP'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (current.secretExpiresAt && Date.parse(usedAt) >= Date.parse(current.secretExpiresAt)) + return rejected('SECRET_EXPIRED'); + if (Date.parse(usedAt) < Date.parse(current.secretIssuedAt)) return rejected('INVALID_TIMESTAMP'); + if (current.lastUsedAt && Date.parse(usedAt) < Date.parse(current.lastUsedAt)) + return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...current, + lastUsedAt: usedAt, + revision: current.revision + 1, + }), + ); +} + +/** Revocation is permanent; callers must create a new account instead of reactivating this identity. */ +export function revokeServiceAccountV1( + current: ServiceAccountV1, + revokedAtInput: unknown, + expectedRevisionInput: unknown, +): ServiceAccountResultV1 { + const revokedAt = timestamp(revokedAtInput); + const expectedRevision = positiveInteger(expectedRevisionInput); + if (!revokedAt) return rejected('INVALID_TIMESTAMP'); + if (!expectedRevision || expectedRevision !== current.revision) + return rejected('REVISION_CONFLICT'); + if (current.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (Date.parse(revokedAt) < Date.parse(current.createdAt)) return rejected('INVALID_TIMESTAMP'); + return accepted( + Object.freeze({ + ...current, + status: 'REVOKED' as const, + revokedAt, + revision: current.revision + 1, + }), + ); +} + +export function isServiceAccountSecretUsableV1( + account: ServiceAccountV1, + nowInput: unknown, +): ServiceAccountResultV1 { + const now = timestamp(nowInput); + if (!now) return rejected('INVALID_TIMESTAMP'); + if (account.status !== 'ACTIVE') return rejected('SECRET_REVOKED'); + if (account.secretExpiresAt && Date.parse(now) >= Date.parse(account.secretExpiresAt)) + return rejected('SECRET_EXPIRED'); + return Object.freeze({ accepted: true, value: true }); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index 383101b9..cbd712b3 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -25,8 +25,10 @@ export * from './mapping/v1.js'; export * from './rule-set/v1.js'; export * from './evidence-grant/v1.js'; export * from './identity/v1.js'; +export * from './service-account/v1.js'; export * from './entitlements/v1.js'; export * from './mfa/v1.js'; +export * from './invitation/v1.js'; export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; diff --git a/packages/domain/test/audit-seal-attestation-v1.test.mjs b/packages/domain/test/audit-seal-attestation-v1.test.mjs new file mode 100644 index 00000000..ddb9d190 --- /dev/null +++ b/packages/domain/test/audit-seal-attestation-v1.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; + +import { + appendAuditEventV1, + createAuditSealAttestationV1, + createAuditSealV1, + verifyAuditSealAttestationV1, +} from '../dist/audit/v1.js'; + +const digest = { digest: (value) => createHash('sha256').update(value, 'utf8').digest('hex') }; +const scope = { scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000741' }; + +function event() { + const result = appendAuditEventV1( + { events: [] }, + { + eventId: '00000000-0000-4000-8000-000000000742', + action: 'service_account.created', + tenantScope: scope, + actor: { actorType: 'USER', actorId: '00000000-0000-4000-8000-000000000743' }, + entityType: 'service-account', + entityId: '00000000-0000-4000-8000-000000000744', + entityRevision: 1, + occurredAt: '2026-01-01T00:00:00.000Z', + correlationId: '00000000-0000-4000-8000-000000000745', + idempotencyKey: 'attestation', + }, + digest, + ); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid event'); + return result.value.event; +} + +void test('[AUD-015, AUD-016] attestations bind an immutable seal range and signer key', () => { + const sealResult = createAuditSealV1([event()], scope, '2026-01-01T00:01:00.000Z', digest); + assert.equal(sealResult.accepted, true); + if (!sealResult.accepted) return; + const signer = { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }; + const attestation = createAuditSealAttestationV1( + sealResult.value, + { attestationId: '00000000-0000-4000-8000-000000000746', signerKeyId: 'audit-key-1' }, + signer, + ); + assert.equal(attestation.accepted, true); + if (!attestation.accepted) return; + assert.deepEqual(verifyAuditSealAttestationV1(attestation.value, sealResult.value, signer), { + accepted: true, + value: true, + }); + assert.deepEqual( + verifyAuditSealAttestationV1( + { + ...attestation.value, + tenantScope: { ...scope, organizationId: '00000000-0000-4000-8000-000000000747' }, + }, + sealResult.value, + signer, + ), + { accepted: false, code: 'CHAIN_INVALID' }, + ); +}); diff --git a/packages/domain/test/audit-service-account-actions-v1.test.mjs b/packages/domain/test/audit-service-account-actions-v1.test.mjs new file mode 100644 index 00000000..5a1d3d59 --- /dev/null +++ b/packages/domain/test/audit-service-account-actions-v1.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as audit from '../dist/audit/v1.js'; + +void test('[IAM-013, AUD-002] service-account lifecycle actions are part of the closed audit vocabulary', () => { + assert.deepEqual( + ['service_account.created', 'service_account.rotated', 'service_account.revoked'].map( + (action) => audit.AUDIT_ACTIONS_V1.includes(action), + ), + [true, true, true], + ); +}); diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 06821858..007640de 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -12,6 +12,7 @@ const [ artifactExport, artifactUpload, protectedDocument, + serviceAccount, dataset, datasetGovernance, datasetQuality, @@ -30,6 +31,7 @@ const [ mapping, ruleSet, evidenceGrant, + recovery, ] = await Promise.all([ import('@databreeze/domain/v1'), import('@databreeze/domain/permissions/v1'), @@ -42,6 +44,7 @@ const [ import('@databreeze/domain/artifact-export/v1'), import('@databreeze/domain/artifact-upload/v1'), import('@databreeze/domain/protected-document/v1'), + import('@databreeze/domain/service-account/v1'), import('@databreeze/domain/dataset/v1'), import('@databreeze/domain/dataset-governance/v1'), import('@databreeze/domain/dataset-quality/v1'), @@ -60,6 +63,7 @@ const [ import('@databreeze/domain/mapping/v1'), import('@databreeze/domain/rule-set/v1'), import('@databreeze/domain/evidence-grant/v1'), + import('@databreeze/domain/recovery/v1'), ]); assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); @@ -94,4 +98,6 @@ assert.equal(referenceEntity.REFERENCE_ENTITY_SCHEMA_VERSION_V1, 1); assert.equal(mapping.MAPPING_SCHEMA_VERSION_V1, 1); assert.equal(ruleSet.RULE_SET_SCHEMA_VERSION_V1, 1); assert.equal(evidenceGrant.EVIDENCE_GRANT_SCHEMA_VERSION_V1, 1); +assert.equal(recovery.RECOVERY_CHALLENGE_SCHEMA_VERSION_V1, 1); +assert.equal(serviceAccount.SERVICE_ACCOUNT_SCHEMA_VERSION_V1, 1); await assert.rejects(import('@databreeze/domain'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); diff --git a/packages/domain/test/entitlement-lease-issuance-v1.test.mjs b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs new file mode 100644 index 00000000..49121ee9 --- /dev/null +++ b/packages/domain/test/entitlement-lease-issuance-v1.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + acceptEntitlementLeaseV1, + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, +} from '../dist/entitlements/v1.js'; + +const scope = { scopeType: 'organization', organizationId: '00000000-0000-4000-8000-000000000751' }; +const signer = { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, +}; + +function snapshot(status = 'ACTIVE') { + const plan = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['spreadsheet.audit'], + quotas: [{ metric: 'job_count', limit: 20 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const created = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000752', + tenantScope: scope, + plan: plan.value, + status, + revision: 3, + securityEpoch: 2, + effectiveAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid snapshot'); + return created.value; +} + +void test('[BUA-001, BUA-017, BUA-018] snapshots are immutable plan projections and leases are signed and bounded', () => { + const lease = createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000753', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T12:00:00.000Z', + }, + signer, + ); + assert.equal(lease.accepted, true); + if (!lease.accepted) return; + assert.deepEqual( + acceptEntitlementLeaseV1( + lease.value, + { + now: '2026-01-01T01:00:00.000Z', + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 2, + }, + signer, + ), + { accepted: true, value: true }, + ); +}); + +void test('[BUA-017, BUA-018] suspended snapshots and overlong leases fail closed', () => { + assert.deepEqual( + createEntitlementLeaseV1( + snapshot('SUSPENDED'), + { + leaseId: '00000000-0000-4000-8000-000000000754', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'ENTITLEMENT_SUSPENDED' }, + ); + assert.deepEqual( + createEntitlementLeaseV1( + { ...snapshot(), expiresAt: '2026-01-10T00:00:00.000Z' }, + { + leaseId: '00000000-0000-4000-8000-000000000755', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-03T00:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); + assert.deepEqual( + createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000756', + issuedAt: '2025-12-31T23:59:59.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); +}); + +void test('[BUA-018] acceptance rejects payloads that do not canonically bind lease fields', () => { + const lease = createEntitlementLeaseV1( + snapshot(), + { + leaseId: '00000000-0000-4000-8000-000000000757', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + }, + signer, + ); + assert.equal(lease.accepted, true); + if (!lease.accepted) return; + assert.deepEqual( + acceptEntitlementLeaseV1( + { ...lease.value, payload: `${lease.value.payload} ` }, + { + now: '2026-01-01T00:15:00.000Z', + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 2, + }, + signer, + ), + { accepted: false, code: 'LEASE_INVALID' }, + ); +}); + +void test('[BUA-001] snapshots reject malformed plan projections instead of trusting caller fields', () => { + const plan = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 20 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) return; + assert.deepEqual( + createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000758', + tenantScope: scope, + plan: { ...plan.value, quotas: [{ metric: 'unknown', limit: 20 }] }, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }), + { accepted: false, code: 'INVALID_PLAN' }, + ); +}); diff --git a/packages/domain/test/entitlements-v1.test.mjs b/packages/domain/test/entitlements-v1.test.mjs index 7c883293..aa979a92 100644 --- a/packages/domain/test/entitlements-v1.test.mjs +++ b/packages/domain/test/entitlements-v1.test.mjs @@ -133,11 +133,19 @@ test('[BUA-017, BUA-018] offline leases require a valid signature, current epoch securityEpoch: 4, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-02T00:00:00.000Z', - payload: 'signed-payload', + payload: JSON.stringify({ + schemaVersion: 1, + leaseId: id('30'), + tenantScope: scope, + snapshotRevision: 3, + securityEpoch: 4, + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + }), signature: 'signature', }; const verifier = { - verify: (payload, signature) => payload === 'signed-payload' && signature === 'signature', + verify: (payload, signature) => payload === lease.payload && signature === 'signature', }; assert.deepEqual( acceptEntitlementLeaseV1( diff --git a/packages/domain/test/invitation-v1.test.mjs b/packages/domain/test/invitation-v1.test.mjs new file mode 100644 index 00000000..a9e56825 --- /dev/null +++ b/packages/domain/test/invitation-v1.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + INVITATION_MAX_SECONDS_V1, + consumeInvitationTokenV1, + createInvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; + +const ids = { + invitation: '00000000-0000-4000-8000-000000000301', + membership: '00000000-0000-4000-8000-000000000302', + principal: '00000000-0000-4000-8000-000000000303', + organization: '00000000-0000-4000-8000-000000000304', +}; +const scope = { scopeType: 'organization', organizationId: ids.organization }; +const issuedAt = '2026-08-03T00:00:00.000Z'; +const expiresAt = new Date(Date.parse(issuedAt) + INVITATION_MAX_SECONDS_V1 * 1_000).toISOString(); + +function input(overrides = {}) { + return { + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.principal, + scope, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt, + expiresAt, + ...overrides, + }; +} + +void test('[IAM-010] invitation token binds exact scope, role, principal, and recipient digest', () => { + const result = createInvitationTokenV1(input()); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.status, 'ACTIVE'); + assert.equal(result.value.revision, 1); + assert.equal(result.value.scope.scopeType, 'organization'); + assert.equal(result.value.roleId, 'viewer'); + assert.equal(result.value.emailDigest, 'b'.repeat(64)); +}); + +void test('[IAM-010] invitation token cannot exceed seven days or carry invalid digests', () => { + assert.deepEqual( + createInvitationTokenV1({ + ...input(), + tokenDigest: 'not-a-digest', + }), + { accepted: false, code: 'INVALID_DIGEST' }, + ); + assert.deepEqual( + createInvitationTokenV1({ + ...input(), + expiresAt: new Date( + Date.parse(issuedAt) + (INVITATION_MAX_SECONDS_V1 + 1) * 1_000, + ).toISOString(), + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-010] consuming an active token is one-time and revisioned', () => { + const created = createInvitationTokenV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const consumed = consumeInvitationTokenV1(created.value, issuedAt); + assert.equal(consumed.accepted, true); + if (!consumed.accepted) return; + assert.equal(consumed.value.status, 'REDEEMED'); + assert.equal(consumed.value.revision, 2); + assert.deepEqual(consumeInvitationTokenV1(consumed.value, issuedAt), { + accepted: false, + code: 'ALREADY_CONSUMED', + }); +}); + +void test('[IAM-010] consuming after expiry or before issue fails closed', () => { + const created = createInvitationTokenV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual( + consumeInvitationTokenV1(created.value, new Date(Date.parse(expiresAt) + 1).toISOString()), + { accepted: false, code: 'EXPIRED' }, + ); + assert.deepEqual( + consumeInvitationTokenV1(created.value, new Date(Date.parse(issuedAt) - 1).toISOString()), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); diff --git a/packages/domain/test/mfa-v1.test.mjs b/packages/domain/test/mfa-v1.test.mjs index 3eb02b07..1e2c74b3 100644 --- a/packages/domain/test/mfa-v1.test.mjs +++ b/packages/domain/test/mfa-v1.test.mjs @@ -90,4 +90,8 @@ test('[IAM-012] high-risk operations require a fresh, principal-bound step-up as accepted: false, code: 'STEP_UP_REQUIRED', }); + assert.deepEqual(requiresStepUpV1('HIGH', assertion, id('2'), '2026-01-01T00:05:00.000Z', true), { + accepted: false, + code: 'MFA_REENROLLMENT_REQUIRED', + }); }); diff --git a/packages/domain/test/permission-applicability-v1.test.mjs b/packages/domain/test/permission-applicability-v1.test.mjs index 0183c95c..d6829b60 100644 --- a/packages/domain/test/permission-applicability-v1.test.mjs +++ b/packages/domain/test/permission-applicability-v1.test.mjs @@ -26,6 +26,9 @@ const expectedChannels = Object.freeze({ 'billing.account.manage': ['api', 'web'], 'device.identity.read': ['api', 'web'], 'device.identity.revoke': ['api', 'web'], + 'service.account.read': ['api', 'web'], + 'service.account.manage': ['api', 'web'], + 'service.account.revoke': ['api', 'web'], }); test('[IAM-002, IAM-003] every permission has an explicit closed channel policy', () => { @@ -61,6 +64,8 @@ test('[IAM-002, IAM-003] sensitive actions are closed to shared-link, stream, an 'approval.decision.create', 'billing.account.manage', 'device.identity.revoke', + 'service.account.manage', + 'service.account.revoke', ]; for (const permission of sensitive) { diff --git a/packages/domain/test/permissions-v1.test.mjs b/packages/domain/test/permissions-v1.test.mjs index 4c09180d..cb4649bc 100644 --- a/packages/domain/test/permissions-v1.test.mjs +++ b/packages/domain/test/permissions-v1.test.mjs @@ -35,6 +35,9 @@ test('[IAM-004] publishes a closed versioned permission vocabulary', async () => 'billing.account.manage', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ]); assert.ok(Object.isFrozen(api.PERMISSIONS_V1)); }); @@ -66,6 +69,9 @@ test('[IAM-004] maps exactly six immutable initial role bundles', async () => { 'billing.account.manage', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ], admin: [ 'organization.profile.read', @@ -77,6 +83,9 @@ test('[IAM-004] maps exactly six immutable initial role bundles', async () => { 'job.execution.read', 'device.identity.read', 'device.identity.revoke', + 'service.account.read', + 'service.account.manage', + 'service.account.revoke', ], analyst: [ 'organization.profile.read', diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 49613204..96d906fc 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -15,8 +15,11 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './authorization/v1', './audit/v1', './identity/v1', + './service-account/v1', './entitlements/v1', './mfa/v1', + './invitation/v1', + './recovery/v1', './device-authorization/v1', './device-sync/v1', './device-capability/v1', @@ -64,8 +67,10 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.PERMISSION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.AUTHORIZATION_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.IDENTITY_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.SERVICE_ACCOUNT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.ENTITLEMENT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.MFA_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.INVITATION_TOKEN_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.PKCE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.CSRF_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DEVICE_AUTHORIZATION_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/recovery-v1.test.mjs b/packages/domain/test/recovery-v1.test.mjs new file mode 100644 index 00000000..4ecf05fa --- /dev/null +++ b/packages/domain/test/recovery-v1.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + RECOVERY_CHALLENGE_MAX_SECONDS_V1, + consumeRecoveryChallengeV1, + createRecoveryChallengeV1, + revokeRecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; + +const ids = { + challenge: '00000000-0000-4000-8000-000000000001', + user: '00000000-0000-4000-8000-000000000002', +}; +const issuedAt = '2026-08-03T00:00:00.000Z'; +const expiresAt = new Date( + Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, +).toISOString(); + +function input(overrides = {}) { + return { + id: ids.challenge, + userId: ids.user, + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt, + expiresAt, + ...overrides, + }; +} + +void test('[IAM-015] recovery challenge stores only bounded digests and expires within one hour', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.equal(created.value.status, 'ACTIVE'); + assert.equal('rawToken' in created.value, false); + assert.equal(created.value.revision, 1); + assert.deepEqual(createRecoveryChallengeV1(input({ tokenDigest: 'raw-token' })), { + accepted: false, + code: 'INVALID_DIGEST', + }); + assert.deepEqual( + createRecoveryChallengeV1({ + ...input(), + expiresAt: new Date( + Date.parse(issuedAt) + (RECOVERY_CHALLENGE_MAX_SECONDS_V1 + 1) * 1_000, + ).toISOString(), + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-015] recovery challenge consumption is single-use, time-bounded, and revisioned', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const consumed = consumeRecoveryChallengeV1(created.value, '2026-08-03T00:30:00.000Z'); + assert.equal(consumed.accepted, true); + if (!consumed.accepted) return; + assert.equal(consumed.value.status, 'CONSUMED'); + assert.equal(consumed.value.revision, 2); + assert.deepEqual(consumeRecoveryChallengeV1(consumed.value, '2026-08-03T00:31:00.000Z'), { + accepted: false, + code: 'ALREADY_TERMINAL', + }); + assert.deepEqual(consumeRecoveryChallengeV1(created.value, expiresAt), { + accepted: false, + code: 'EXPIRED', + }); +}); + +void test('[IAM-015] recovery challenge revocation is terminal and does not return bearer data', () => { + const created = createRecoveryChallengeV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const revoked = revokeRecoveryChallengeV1(created.value, '2026-08-03T00:01:00.000Z'); + assert.equal(revoked.accepted, true); + if (!revoked.accepted) return; + assert.equal(revoked.value.status, 'REVOKED'); + assert.equal(revoked.value.revokedAt, '2026-08-03T00:01:00.000Z'); + assert.deepEqual(revokeRecoveryChallengeV1(revoked.value, '2026-08-03T00:02:00.000Z'), { + accepted: false, + code: 'ALREADY_TERMINAL', + }); +}); diff --git a/packages/domain/test/service-account-v1.test.mjs b/packages/domain/test/service-account-v1.test.mjs new file mode 100644 index 00000000..83fb06ac --- /dev/null +++ b/packages/domain/test/service-account-v1.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createServiceAccountV1, + isServiceAccountSecretUsableV1, + markServiceAccountUsedV1, + revokeServiceAccountV1, + rotateServiceAccountSecretV1, +} from '@databreeze/domain/service-account/v1'; + +const ids = { + account: '00000000-0000-4000-8000-000000000601', + organization: '00000000-0000-4000-8000-000000000602', + workspace: '00000000-0000-4000-8000-000000000603', +}; +const createdAt = '2026-08-03T00:00:00.000Z'; + +function input(overrides = {}) { + return { + id: ids.account, + organizationId: ids.organization, + workspaceId: ids.workspace, + name: 'Import worker', + permissions: ['artifact.record.read', 'job.execution.create'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: createdAt, + createdAt, + ...overrides, + }; +} + +void test('[IAM-013] service account stores only bounded scoped permissions and a digest', () => { + const result = createServiceAccountV1(input()); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.status, 'ACTIVE'); + assert.equal(result.value.secretVersion, 1); + assert.deepEqual(result.value.permissions, ['artifact.record.read', 'job.execution.create']); + assert.equal(Object.hasOwn(result.value, 'secret'), false); +}); + +void test('[IAM-013] invalid permissions, wildcard, digest, and lifetime fail closed', () => { + assert.deepEqual(createServiceAccountV1(input({ permissions: ['*'] })), { + accepted: false, + code: 'INVALID_PERMISSION', + }); + assert.deepEqual(createServiceAccountV1(input({ secretDigest: 'secret' })), { + accepted: false, + code: 'INVALID_DIGEST', + }); + assert.deepEqual( + createServiceAccountV1({ + ...input(), + secretExpiresAt: '2027-08-04T00:00:00.000Z', + }), + { accepted: false, code: 'INVALID_LIFETIME' }, + ); +}); + +void test('[IAM-013] secret rotation requires the current revision and increments the version', () => { + const created = createServiceAccountV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const rotated = rotateServiceAccountSecretV1(created.value, { + secretDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:01:00.000Z', + expectedRevision: 1, + }); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.equal(rotated.value.secretVersion, 2); + assert.equal(rotated.value.revision, 2); + assert.deepEqual( + rotateServiceAccountSecretV1(created.value, { + secretDigest: 'c'.repeat(64), + issuedAt: '2026-08-03T00:01:00.000Z', + expectedRevision: 2, + }), + { accepted: false, code: 'REVISION_CONFLICT' }, + ); +}); + +void test('[IAM-013] last-use is monotonic and unusable secrets fail closed', () => { + const created = createServiceAccountV1(input({ secretExpiresAt: '2026-08-03T01:00:00.000Z' })); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const used = markServiceAccountUsedV1(created.value, '2026-08-03T00:10:00.000Z'); + assert.equal(used.accepted, true); + if (!used.accepted) return; + assert.deepEqual(markServiceAccountUsedV1(used.value, '2026-08-03T00:09:00.000Z'), { + accepted: false, + code: 'INVALID_TIMESTAMP', + }); + assert.deepEqual(isServiceAccountSecretUsableV1(used.value, '2026-08-03T01:00:00.000Z'), { + accepted: false, + code: 'SECRET_EXPIRED', + }); +}); + +void test('[IAM-013] revocation is permanent and revision guarded', () => { + const created = createServiceAccountV1(input()); + assert.equal(created.accepted, true); + if (!created.accepted) return; + const revoked = revokeServiceAccountV1(created.value, '2026-08-03T00:02:00.000Z', 1); + assert.equal(revoked.accepted, true); + if (!revoked.accepted) return; + assert.equal(revoked.value.status, 'REVOKED'); + assert.deepEqual(revokeServiceAccountV1(revoked.value, '2026-08-03T00:03:00.000Z', 2), { + accepted: false, + code: 'SECRET_REVOKED', + }); +}); diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 550bb866..5676d9be 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -131,10 +131,23 @@ const vietnameseCatalogV1 = { 'api.error.device_request_rejected': entry('Thiết bị đã từ chối yêu cầu.'), 'api.error.device_revision_conflict': entry('Thiết bị đã thay đổi. Hãy tải lại và thử lại.'), 'api.error.device_scope_denied': entry('Bạn không có quyền truy cập thiết bị này.'), + 'api.error.invitation_request_rejected': entry('Lời mời không hợp lệ hoặc đã bị từ chối.'), + 'api.error.invitation_scope_denied': entry('Bạn không có quyền quản lý lời mời này.'), + 'api.error.invitation_not_found': entry( + 'Không tìm thấy lời mời hoặc bạn không có quyền truy cập.', + ), + 'api.error.invitation_conflict': entry('Lời mời đã thay đổi hoặc đã được sử dụng.'), + 'api.error.invitation_delivery_unavailable': entry('Không thể gửi lời mời lúc này.'), + 'api.error.invitation_unavailable': entry('Dịch vụ lời mời hiện không khả dụng.'), 'retry.now': entry('Thử lại ngay'), 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), 'retry.afterSeconds.other': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), + 'api.error.registration_request_rejected': entry('Yêu cầu đăng ký không được chấp nhận.'), + 'api.error.registration_unavailable': entry('Dịch vụ đăng ký hiện không khả dụng.'), + 'api.error.recovery_request_rejected': entry('Yêu cầu khôi phục không hợp lệ.'), + 'api.error.recovery_token_invalid': entry('Liên kết khôi phục không hợp lệ hoặc đã hết hạn.'), + 'api.error.recovery_unavailable': entry('Dịch vụ khôi phục hiện không khả dụng.'), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), @@ -276,10 +289,27 @@ const englishCatalogV1: MessageCatalogV1 = { 'api.error.device_request_rejected': entry('The device rejected the request.'), 'api.error.device_revision_conflict': entry('The device changed. Reload and try again.'), 'api.error.device_scope_denied': entry('You do not have access to this device.'), + 'api.error.invitation_request_rejected': entry('The invitation is invalid or was rejected.'), + 'api.error.invitation_scope_denied': entry( + 'You do not have permission to manage this invitation.', + ), + 'api.error.invitation_not_found': entry('The invitation was not found or is not accessible.'), + 'api.error.invitation_conflict': entry('The invitation changed or has already been used.'), + 'api.error.invitation_delivery_unavailable': entry( + 'The invitation could not be delivered right now.', + ), + 'api.error.invitation_unavailable': entry('The invitation service is temporarily unavailable.'), 'retry.now': entry('Try again now'), 'retry.later': entry('Try again later. Your entered data has been preserved.'), 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), 'retry.afterSeconds.other': entry('Try again in {seconds} seconds.', { seconds: 'number' }), + 'api.error.registration_request_rejected': entry('The registration request was rejected.'), + 'api.error.registration_unavailable': entry( + 'The registration service is temporarily unavailable.', + ), + 'api.error.recovery_request_rejected': entry('The recovery request was rejected.'), + 'api.error.recovery_token_invalid': entry('The recovery link is invalid or has expired.'), + 'api.error.recovery_unavailable': entry('The recovery service is temporarily unavailable.'), 'module.folderAutopilot': entry('Folder Autopilot'), 'module.spreadsheetAuditor': entry('Spreadsheet Auditor'), 'module.quoteIntelligence': entry('Quote Intelligence'), diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index 10a601c9..35eae31e 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -102,10 +102,21 @@ const REQUIRED_KEYS = Object.freeze([ 'api.error.device_request_rejected', 'api.error.device_revision_conflict', 'api.error.device_scope_denied', + 'api.error.invitation_request_rejected', + 'api.error.invitation_scope_denied', + 'api.error.invitation_not_found', + 'api.error.invitation_conflict', + 'api.error.invitation_delivery_unavailable', + 'api.error.invitation_unavailable', 'retry.now', 'retry.later', 'retry.afterSeconds.one', 'retry.afterSeconds.other', + 'api.error.registration_request_rejected', + 'api.error.registration_unavailable', + 'api.error.recovery_request_rejected', + 'api.error.recovery_token_invalid', + 'api.error.recovery_unavailable', 'module.folderAutopilot', 'module.spreadsheetAuditor', 'module.quoteIntelligence', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43cd5809..26526faf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@fastify/ajv-compiler>fast-uri': 3.1.5 find-my-way: 9.7.0 js-yaml: 5.2.2 react-router: 8.3.0 @@ -1896,8 +1897,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-uri@4.1.2: resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} @@ -3309,7 +3310,7 @@ snapshots: dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.4 + fast-uri: 3.1.5 '@fastify/cors@11.2.0': dependencies: @@ -4195,7 +4196,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4579,7 +4580,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fast-uri@4.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 35d4c1a0..e13b0d5b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ engineStrict: true pmOnFail: download overrides: + '@fastify/ajv-compiler>fast-uri': 3.1.5 find-my-way: 9.7.0 js-yaml: 5.2.2 react-router: 8.3.0 diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 98362cd8..67792751 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -2771,10 +2771,1098 @@ "tags": ["identity"] } }, + "/v1/invitations": { + "post": { + "operationId": "IamInvitationController.issue", + "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/IssueInvitationDto" } } + } + }, + "responses": { + "200": { + "description": "Invitation metadata without bearer material.", + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "Deliver a single-use invitation token to an existing principal", + "tags": ["identity"] + } + }, + "/v1/invitations/accept": { + "post": { + "operationId": "IamInvitationController.accept", + "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/AcceptInvitationDto" } } + } + }, + "responses": { + "200": { + "description": "Activated membership 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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "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": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/InvitationRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Redeem a single-use invitation token for the authenticated principal", + "tags": ["identity"] + } + }, + "/v1/auth/register": { + "post": { + "description": "Registration does not return bearer material; sign in separately after creation.", + "operationId": "RegistrationController.register", + "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/RegistrationDto" } } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegistrationResponseDto" } + } + }, + "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 registration request was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Registration persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Create an account and personal organization hierarchy", + "tags": ["auth"] + } + }, + "/v1/auth/recovery": { + "post": { + "description": "The accepted response is intentionally identical for known and unknown emails.", + "operationId": "RecoveryController.request", + "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/RecoveryRequestDto" } } + } + }, + "responses": { + "202": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecoveryRequestResponseDto" } + } + }, + "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 recovery request was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Recovery delivery is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Request an account recovery link", + "tags": ["auth"] + } + }, + "/v1/auth/recovery/complete": { + "post": { + "description": "Consumes a single-use link, revokes sessions, and requires MFA re-enrollment.", + "operationId": "RecoveryController.complete", + "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/RecoveryCompleteDto" } } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecoveryCompleteResponseDto" } + } + }, + "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 recovery token or password was rejected.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "Recovery persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "summary": "Complete account recovery", + "tags": ["auth"] + } + }, "/v1/me/bootstrap": { "get": { - "operationId": "IamBootstrapController.bootstrap", + "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/organizations/{organizationId}/service-accounts": { + "get": { + "operationId": "ServiceAccountController.list", + "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": "", + "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 content-free service-account identities in an organization scope", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts": { + "post": { + "operationId": "ServiceAccountController.create", + "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/CreateServiceAccountDto" } + } + } + }, + "responses": { + "201": { + "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 action-scoped service account and return its one-time secret", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts/{serviceAccountId}/rotate": { + "post": { + "operationId": "ServiceAccountController.rotate", + "parameters": [ + { + "name": "serviceAccountId", + "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/ServiceAccountRevisionDto" } + } + } + }, + "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": "Rotate a service-account secret and return the successor once", + "tags": ["service-accounts"] + } + }, + "/v1/service-accounts/{serviceAccountId}/revoke": { + "post": { + "operationId": "ServiceAccountController.revoke", + "parameters": [ + { + "name": "serviceAccountId", + "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/ServiceAccountRevisionDto" } + } + } + }, + "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": "Permanently revoke a service-account identity", + "tags": ["service-accounts"] + } + }, + "/v1/artifacts/inbox": { + "post": { + "operationId": "InboxController.create", + "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/CreateInboxItemDto" } } + } + }, + "responses": { + "201": { + "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": "Register a content-free artifact intake item", + "tags": ["artifacts"] + }, + "get": { + "operationId": "InboxController.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": "", + "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 content-free artifact intake items visible to the caller", + "tags": ["artifacts"] + } + }, + "/v1/artifacts/inbox/{inboxItemId}": { + "patch": { + "operationId": "InboxController.updateMetadata", "parameters": [ + { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "If-Match", + "in": "header", + "description": "Expected inbox revision, for example 3 or \"3\".", + "required": false, + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -2783,14 +3871,17 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateInboxMetadataDto" } + } + } + }, "responses": { "200": { "description": "", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/BootstrapResponseDto" } - } - }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -2840,14 +3931,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Load safe identity and personal-tenant bootstrap state", - "tags": ["identity"] + "summary": "Update revisioned, content-free inbox triage metadata", + "tags": ["artifacts"] } }, - "/v1/artifacts/inbox": { + "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { "post": { - "operationId": "InboxController.create", + "operationId": "EvidenceGrantController.issue", "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2859,7 +3952,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateInboxItemDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } + } } }, "responses": { @@ -2914,12 +4009,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register a content-free artifact intake item", + "summary": "Issue a short-lived exact-evidence access grant", "tags": ["artifacts"] - }, - "get": { - "operationId": "InboxController.list", + } + }, + "/v1/artifacts/evidence-grants/{grantId}": { + "delete": { + "operationId": "EvidenceGrantController.revoke", "parameters": [ + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -2980,22 +4078,84 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free artifact intake items visible to the caller", + "summary": "Revoke an evidence access grant", "tags": ["artifacts"] } }, - "/v1/artifacts/inbox/{inboxItemId}": { - "patch": { - "operationId": "InboxController.updateMetadata", + "/v1/artifact-versions/{versionId}": { + "get": { + "operationId": "ArtifactReadController.get", "parameters": [ - { "name": "inboxItemId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { - "name": "If-Match", + "name": "X-Correlation-Id", "in": "header", - "description": "Expected inbox revision, for example 3 or \"3\".", "required": false, - "schema": { "type": "string" } + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read immutable artifact-version metadata and placements", + "tags": ["artifacts"] + } + }, + "/v1/artifact-versions/{versionId}/evidence": { + "get": { + "operationId": "ArtifactReadController.evidence", + "parameters": [ + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3004,14 +4164,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateInboxMetadataDto" } - } - } - }, "responses": { "200": { "description": "", @@ -3064,13 +4216,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Update revisioned, content-free inbox triage metadata", + "summary": "List typed evidence references for one immutable version", "tags": ["artifacts"] } }, - "/v1/artifacts/{versionId}/evidence/{evidenceId}/grants": { - "post": { - "operationId": "EvidenceGrantController.issue", + "/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve": { + "get": { + "operationId": "ArtifactReadController.resolveEvidence", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, @@ -3082,16 +4234,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateEvidenceGrantDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -3142,15 +4286,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a short-lived exact-evidence access grant", + "summary": "Resolve one exact evidence reference to a safe opaque action", "tags": ["artifacts"] } }, - "/v1/artifacts/evidence-grants/{grantId}": { - "delete": { - "operationId": "EvidenceGrantController.revoke", + "/v1/artifact-versions/{versionId}/lineage": { + "get": { + "operationId": "ArtifactLineageController.forDerived", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3211,13 +4355,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Revoke an evidence access grant", + "summary": "Read lineage for an exact derived artifact version", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}": { + "/v1/artifact-versions/{versionId}/derived-lineage": { "get": { - "operationId": "ArtifactReadController.get", + "operationId": "ArtifactLineageController.forSource", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -3280,15 +4424,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read immutable artifact-version metadata and placements", + "summary": "List derived versions that use an exact source version", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/evidence": { - "get": { - "operationId": "ArtifactReadController.evidence", + "/v1/artifact-versions/{versionId}/placements/{placementId}": { + "patch": { + "operationId": "ContentPlacementController.update", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "placementId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3297,6 +4442,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateContentPlacementDto" } + } + } + }, "responses": { "200": { "description": "", @@ -3349,16 +4502,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List typed evidence references for one immutable version", + "summary": "Update verified placement availability with a revision precondition", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve": { + "/v1/artifact-deletion-requests/{requestId}": { "get": { - "operationId": "ArtifactReadController.resolveEvidence", + "operationId": "ArtifactRetentionController.find", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "evidenceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3419,13 +4571,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Resolve one exact evidence reference to a safe opaque action", + "summary": "Read one governed artifact deletion request", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/lineage": { - "get": { - "operationId": "ArtifactLineageController.forDerived", + "/v1/artifact-versions/{versionId}/deletion-requests": { + "post": { + "operationId": "ArtifactRetentionController.request", "parameters": [ { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -3436,8 +4588,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateArtifactDeletionRequestDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3488,15 +4648,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read lineage for an exact derived artifact version", + "summary": "Request governed deletion of an exact artifact version", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/derived-lineage": { - "get": { - "operationId": "ArtifactLineageController.forSource", + "/v1/artifact-deletion-requests/{requestId}/authorize": { + "post": { + "operationId": "ArtifactRetentionController.authorize", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3505,8 +4665,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthorizeArtifactDeletionRequestDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3557,16 +4725,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List derived versions that use an exact source version", + "summary": "Authorize an eligible deletion request after MFA step-up", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/placements/{placementId}": { - "patch": { - "operationId": "ContentPlacementController.update", + "/v1/artifacts/exports": { + "post": { + "operationId": "ArtifactExportController.create", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "placementId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3579,12 +4745,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateContentPlacementDto" } + "schema": { "$ref": "#/components/schemas/CreateArtifactExportDto" } } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -3635,15 +4801,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Update verified placement availability with a revision precondition", + "summary": "Create an immutable artifact verification manifest", "tags": ["artifacts"] } }, - "/v1/artifact-deletion-requests/{requestId}": { + "/v1/artifacts/exports/{manifestId}": { "get": { - "operationId": "ArtifactRetentionController.find", + "operationId": "ArtifactExportController.get", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3704,15 +4870,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one governed artifact deletion request", + "summary": "Read an immutable artifact verification manifest", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/deletion-requests": { + "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { "post": { - "operationId": "ArtifactRetentionController.request", + "operationId": "ArtifactUploadController.issuePartTransfer", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3725,7 +4891,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactDeletionRequestDto" } + "schema": { "$ref": "#/components/schemas/IssueArtifactUploadTransferDto" } } } }, @@ -3781,15 +4947,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Request governed deletion of an exact artifact version", + "summary": "Issue one opaque upload-part transfer grant", "tags": ["artifacts"] } }, - "/v1/artifact-deletion-requests/{requestId}/authorize": { + "/v1/artifact-upload-sessions": { "post": { - "operationId": "ArtifactRetentionController.authorize", + "operationId": "ArtifactUploadController.create", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3802,7 +4967,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/AuthorizeArtifactDeletionRequestDto" } + "schema": { "$ref": "#/components/schemas/CreateArtifactUploadSessionDto" } } } }, @@ -3858,14 +5023,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Authorize an eligible deletion request after MFA step-up", + "summary": "Create a bounded resumable artifact upload session", "tags": ["artifacts"] } }, - "/v1/artifacts/exports": { - "post": { - "operationId": "ArtifactExportController.create", + "/v1/artifact-upload-sessions/{sessionId}": { + "get": { + "operationId": "ArtifactUploadController.find", "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3874,16 +5040,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactExportDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -3934,15 +5092,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable artifact verification manifest", + "summary": "Read upload session metadata and completed part digests", "tags": ["artifacts"] } }, - "/v1/artifacts/exports/{manifestId}": { - "get": { - "operationId": "ArtifactExportController.get", + "/v1/artifact-upload-sessions/{sessionId}/parts": { + "post": { + "operationId": "ArtifactUploadController.part", "parameters": [ - { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -3951,8 +5109,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecordArtifactUploadPartDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -4003,13 +5169,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an immutable artifact verification manifest", + "summary": "Record one verified upload part digest", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/parts/transfer": { + "/v1/artifact-upload-sessions/{sessionId}/complete": { "post": { - "operationId": "ArtifactUploadController.issuePartTransfer", + "operationId": "ArtifactUploadController.complete", "parameters": [ { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -4024,7 +5190,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/IssueArtifactUploadTransferDto" } + "schema": { "$ref": "#/components/schemas/CompleteArtifactUploadDto" } } } }, @@ -4080,14 +5246,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue one opaque upload-part transfer grant", + "summary": "Finalize an upload after all part digests and the assembled hash match", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions": { + "/v1/artifact-upload-sessions/{sessionId}/abort": { "post": { - "operationId": "ArtifactUploadController.create", + "operationId": "ArtifactUploadController.abort", "parameters": [ + { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4100,7 +5267,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateArtifactUploadSessionDto" } + "schema": { "$ref": "#/components/schemas/AbortArtifactUploadDto" } } } }, @@ -4156,15 +5323,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a bounded resumable artifact upload session", + "summary": "Abort an open upload session", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}": { - "get": { - "operationId": "ArtifactUploadController.find", + "/v1/artifact-versions/{versionId}/admit": { + "post": { + "operationId": "ArtifactAdmissionController.admit", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4173,8 +5340,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AdmitArtifactDto" } } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -4225,15 +5398,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read upload session metadata and completed part digests", + "summary": "Admit an exact artifact version after digest, media, size, and scan checks", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/parts": { + "/v1/protected-document-unlocks": { "post": { - "operationId": "ArtifactUploadController.part", + "operationId": "ProtectedDocumentUnlockController.create", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4246,7 +5418,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/RecordArtifactUploadPartDto" } + "schema": { "$ref": "#/components/schemas/CreateProtectedDocumentUnlockDto" } } } }, @@ -4302,15 +5474,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record one verified upload part digest", + "summary": "Create a secret-free protected-document unlock request", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/complete": { - "post": { - "operationId": "ArtifactUploadController.complete", + "/v1/protected-document-unlocks/{requestId}": { + "get": { + "operationId": "ProtectedDocumentUnlockController.find", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4319,16 +5491,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CompleteArtifactUploadDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4379,15 +5543,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Finalize an upload after all part digests and the assembled hash match", + "summary": "Read protected-document unlock state without credentials", "tags": ["artifacts"] } }, - "/v1/artifact-upload-sessions/{sessionId}/abort": { + "/v1/protected-document-unlocks/{requestId}/handle": { "post": { - "operationId": "ArtifactUploadController.abort", + "operationId": "ProtectedDocumentUnlockController.issueHandle", "parameters": [ - { "name": "sessionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4396,14 +5560,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/AbortArtifactUploadDto" } - } - } - }, "responses": { "201": { "description": "", @@ -4456,15 +5612,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Abort an open upload session", + "summary": "Issue a one-shot local secret-input handle", "tags": ["artifacts"] } }, - "/v1/artifact-versions/{versionId}/admit": { + "/v1/protected-document-unlocks/{requestId}/outcome": { "post": { - "operationId": "ArtifactAdmissionController.admit", + "operationId": "ProtectedDocumentUnlockController.recordOutcome", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4476,7 +5632,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/AdmitArtifactDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/RecordProtectedDocumentUnlockOutcomeDto" } + } } }, "responses": { @@ -4531,14 +5689,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Admit an exact artifact version after digest, media, size, and scan checks", + "summary": "Record a local unlock outcome using an opaque handle", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks": { + "/v1/protected-document-unlocks/{requestId}/expire": { "post": { - "operationId": "ProtectedDocumentUnlockController.create", + "operationId": "ProtectedDocumentUnlockController.expire", "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4551,7 +5710,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateProtectedDocumentUnlockDto" } + "schema": { "$ref": "#/components/schemas/ExpireProtectedDocumentUnlockDto" } } } }, @@ -4607,15 +5766,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a secret-free protected-document unlock request", + "summary": "Expire an open unlock request and release local handles", "tags": ["artifacts"] } }, - "/v1/protected-document-unlocks/{requestId}": { - "get": { - "operationId": "ProtectedDocumentUnlockController.find", + "/v1/datasets": { + "post": { + "operationId": "GovernedDatasetController.create", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4624,8 +5782,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -4676,15 +5842,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read protected-document unlock state without credentials", - "tags": ["artifacts"] + "summary": "Create an immutable governed dataset definition draft", + "tags": ["datasets"] } }, - "/v1/protected-document-unlocks/{requestId}/handle": { - "post": { - "operationId": "ProtectedDocumentUnlockController.issueHandle", + "/v1/datasets/{datasetId}/versions": { + "get": { + "operationId": "GovernedDatasetController.list", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4694,7 +5860,7 @@ } ], "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4745,15 +5911,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a one-shot local secret-input handle", - "tags": ["artifacts"] + "summary": "List governed dataset versions visible to the caller", + "tags": ["datasets"] } }, - "/v1/protected-document-unlocks/{requestId}/outcome": { - "post": { - "operationId": "ProtectedDocumentUnlockController.recordOutcome", + "/v1/datasets/{datasetId}/versions/{versionId}": { + "get": { + "operationId": "GovernedDatasetController.getVersion", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4762,16 +5929,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RecordProtectedDocumentUnlockOutcomeDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4822,15 +5981,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a local unlock outcome using an opaque handle", - "tags": ["artifacts"] + "summary": "Read one exact immutable governed dataset definition", + "tags": ["datasets"] } }, - "/v1/protected-document-unlocks/{requestId}/expire": { + "/v1/datasets/{datasetId}/versions/{versionId}/publish": { "post": { - "operationId": "ProtectedDocumentUnlockController.expire", + "operationId": "GovernedDatasetController.publish", "parameters": [ - { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -4843,12 +6003,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ExpireProtectedDocumentUnlockDto" } + "schema": { "$ref": "#/components/schemas/PublishGovernedDatasetDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4899,14 +6059,27 @@ } }, "security": [{ "bearer": [] }], - "summary": "Expire an open unlock request and release local handles", - "tags": ["artifacts"] + "summary": "Publish a governed dataset definition as a new immutable version", + "tags": ["datasets"] } }, - "/v1/datasets": { - "post": { - "operationId": "GovernedDatasetController.create", + "/v1/datasets/{datasetId}/compatibility": { + "get": { + "operationId": "GovernedDatasetController.compare", "parameters": [ + { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "previousVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "nextVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -4915,16 +6088,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateGovernedDatasetDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -4975,13 +6140,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable governed dataset definition draft", + "summary": "Classify compatibility between two exact schema versions", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/versions": { - "get": { - "operationId": "GovernedDatasetController.list", + "/v1/datasets/{datasetId}/mappings": { + "post": { + "operationId": "MappingController.create", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -4992,8 +6157,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5044,16 +6215,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List governed dataset versions visible to the caller", + "summary": "Create an immutable mapping definition draft", "tags": ["datasets"] - } - }, - "/v1/datasets/{datasetId}/versions/{versionId}": { + }, "get": { - "operationId": "GovernedDatasetController.getVersion", + "operationId": "MappingController.list", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5114,13 +6282,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one exact immutable governed dataset definition", + "summary": "List immutable mapping versions", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/versions/{versionId}/publish": { + "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { "post": { - "operationId": "GovernedDatasetController.publish", + "operationId": "MappingController.publish", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, @@ -5136,7 +6304,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishGovernedDatasetDto" } + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } } } }, @@ -5192,27 +6360,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a governed dataset definition as a new immutable version", + "summary": "Publish a mapping definition as a new immutable version", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/compatibility": { - "get": { - "operationId": "GovernedDatasetController.compare", + "/v1/datasets/{datasetId}/rules": { + "post": { + "operationId": "RuleSetController.create", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { - "name": "previousVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, - { - "name": "nextVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -5221,8 +6377,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5273,13 +6435,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "Classify compatibility between two exact schema versions", + "summary": "Create an immutable quality rule-set draft", "tags": ["datasets"] - } - }, - "/v1/datasets/{datasetId}/mappings": { - "post": { - "operationId": "MappingController.create", + }, + "get": { + "operationId": "RuleSetController.list", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -5290,14 +6450,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateMappingDto" } } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -5348,13 +6502,16 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable mapping definition draft", + "summary": "List immutable quality rule-set versions", "tags": ["datasets"] - }, - "get": { - "operationId": "MappingController.list", + } + }, + "/v1/datasets/{datasetId}/rules/{versionId}/publish": { + "post": { + "operationId": "RuleSetController.publish", "parameters": [ { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5363,6 +6520,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } + } + } + }, "responses": { "200": { "description": "", @@ -5415,16 +6580,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable mapping versions", + "summary": "Publish a quality rule set as a new immutable version", "tags": ["datasets"] } }, - "/v1/datasets/{datasetId}/mappings/{versionId}/publish": { + "/v1/reference-entities": { "post": { - "operationId": "MappingController.publish", + "operationId": "ReferenceEntityController.create", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5437,12 +6600,12 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } + "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } } } }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -5493,15 +6656,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a mapping definition as a new immutable version", - "tags": ["datasets"] + "summary": "Create an immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/rules": { + "/v1/reference-entities/merge": { "post": { - "operationId": "RuleSetController.create", + "operationId": "ReferenceEntityController.merge", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5513,7 +6675,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/CreateRuleSetDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } + } } }, "responses": { @@ -5568,13 +6732,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable quality rule-set draft", - "tags": ["datasets"] - }, + "summary": "Record an explicit business-party merge resolution", + "tags": ["reference-entities"] + } + }, + "/v1/reference-entities/{entityId}/versions": { "get": { - "operationId": "RuleSetController.list", + "operationId": "ReferenceEntityController.list", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5635,15 +6801,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable quality rule-set versions", - "tags": ["datasets"] + "summary": "List immutable business-party versions", + "tags": ["reference-entities"] } }, - "/v1/datasets/{datasetId}/rules/{versionId}/publish": { - "post": { - "operationId": "RuleSetController.publish", + "/v1/reference-entities/{entityId}/versions/{versionId}": { + "get": { + "operationId": "ReferenceEntityController.getVersion", "parameters": [ - { "name": "datasetId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", @@ -5653,14 +6819,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDefinitionDto" } - } - } - }, "responses": { "200": { "description": "", @@ -5713,14 +6871,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Publish a quality rule set as a new immutable version", - "tags": ["datasets"] + "summary": "Read one exact immutable business-party version", + "tags": ["reference-entities"] } }, - "/v1/reference-entities": { - "post": { - "operationId": "ReferenceEntityController.create", + "/v1/reference-entities/{entityId}/resolutions": { + "get": { + "operationId": "ReferenceEntityController.resolutions", "parameters": [ + { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5729,16 +6888,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReferenceEntityDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -5789,13 +6940,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create an immutable business-party version", + "summary": "List immutable merge and resolution history", "tags": ["reference-entities"] } }, - "/v1/reference-entities/merge": { + "/v1/dataset-versions": { "post": { - "operationId": "ReferenceEntityController.merge", + "operationId": "DatasetVersionController.register", "parameters": [ { "name": "X-Correlation-Id", @@ -5809,7 +6960,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/MergeReferenceEntityDto" } + "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } } } }, @@ -5865,15 +7016,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record an explicit business-party merge resolution", - "tags": ["reference-entities"] - } - }, - "/v1/reference-entities/{entityId}/versions": { + "summary": "Register an immutable dataset result manifest", + "tags": ["datasets"] + }, "get": { - "operationId": "ReferenceEntityController.list", + "operationId": "DatasetVersionController.list", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -5934,15 +7083,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable business-party versions", - "tags": ["reference-entities"] + "summary": "List exact dataset result manifests for one governed dataset", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/versions/{versionId}": { + "/v1/dataset-versions/{versionId}": { "get": { - "operationId": "ReferenceEntityController.getVersion", + "operationId": "DatasetVersionController.get", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", @@ -6004,15 +7152,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one exact immutable business-party version", - "tags": ["reference-entities"] + "summary": "Read an exact immutable dataset result manifest", + "tags": ["datasets"] } }, - "/v1/reference-entities/{entityId}/resolutions": { - "get": { - "operationId": "ReferenceEntityController.resolutions", + "/v1/dataset-quality-results": { + "post": { + "operationId": "DatasetQualityController.register", "parameters": [ - { "name": "entityId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6021,8 +7168,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetQualityResultDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -6073,14 +7228,18 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable merge and resolution history", - "tags": ["reference-entities"] - } - }, - "/v1/dataset-versions": { - "post": { - "operationId": "DatasetVersionController.register", + "summary": "Register an immutable, value-free dataset quality result", + "tags": ["datasets"] + }, + "get": { + "operationId": "DatasetQualityController.list", "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -6089,16 +7248,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetVersionDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6149,13 +7300,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable dataset result manifest", + "summary": "List quality results for one exact dataset version", "tags": ["datasets"] - }, + } + }, + "/v1/dataset-quality-results/{resultId}": { "get": { - "operationId": "DatasetVersionController.list", + "operationId": "DatasetQualityController.get", "parameters": [ - { "name": "datasetId", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "resultId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6216,15 +7369,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List exact dataset result manifests for one governed dataset", + "summary": "Read an exact immutable dataset quality result", "tags": ["datasets"] } }, - "/v1/dataset-versions/{versionId}": { - "get": { - "operationId": "DatasetVersionController.get", + "/v1/dataset-profiles": { + "post": { + "operationId": "DatasetProfileController.register", "parameters": [ - { "name": "versionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6233,8 +7385,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterDatasetProfileDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "headers": { "X-Correlation-Id": { @@ -6285,14 +7445,18 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset result manifest", + "summary": "Register an immutable, value-free dataset profile disclosure", "tags": ["datasets"] - } - }, - "/v1/dataset-quality-results": { - "post": { - "operationId": "DatasetQualityController.register", + }, + "get": { + "operationId": "DatasetProfileController.list", "parameters": [ + { + "name": "datasetVersionId", + "required": true, + "in": "query", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -6301,16 +7465,8 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetQualityResultDto" } - } - } - }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6361,11 +7517,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable, value-free dataset quality result", + "summary": "List profile disclosures for one exact dataset version", "tags": ["datasets"] - }, + } + }, + "/v1/dataset-profiles/page": { "get": { - "operationId": "DatasetQualityController.list", + "operationId": "DatasetProfileController.page", "parameters": [ { "name": "datasetVersionId", @@ -6373,6 +7531,8 @@ "in": "query", "schema": { "type": "string" } }, + { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, + { "name": "cursor", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6433,15 +7593,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "List quality results for one exact dataset version", + "summary": "List dataset profiles with a stable scoped cursor", "tags": ["datasets"] } }, - "/v1/dataset-quality-results/{resultId}": { + "/v1/dataset-profiles/{profileId}": { "get": { - "operationId": "DatasetQualityController.get", + "operationId": "DatasetProfileController.get", "parameters": [ - { "name": "resultId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6502,13 +7662,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset quality result", + "summary": "Read an exact immutable dataset profile disclosure", "tags": ["datasets"] } }, - "/v1/dataset-profiles": { + "/v1/dataset-exports": { "post": { - "operationId": "DatasetProfileController.register", + "operationId": "DatasetExportController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -6522,7 +7682,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/RegisterDatasetProfileDto" } + "schema": { "$ref": "#/components/schemas/CreateDatasetExportManifestDto" } } } }, @@ -6578,18 +7738,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Register an immutable, value-free dataset profile disclosure", + "summary": "Create a governed dataset export verification manifest", "tags": ["datasets"] - }, + } + }, + "/v1/dataset-exports/{manifestId}": { "get": { - "operationId": "DatasetProfileController.list", + "operationId": "DatasetExportController.find", "parameters": [ - { - "name": "datasetVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, + { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6650,22 +7807,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List profile disclosures for one exact dataset version", + "summary": "Read an immutable governed dataset export manifest", "tags": ["datasets"] } }, - "/v1/dataset-profiles/page": { - "get": { - "operationId": "DatasetProfileController.page", + "/v1/devices/sync/operations": { + "post": { + "operationId": "DeviceSyncController.enqueue", "parameters": [ - { - "name": "datasetVersionId", - "required": true, - "in": "query", - "schema": { "type": "string" } - }, - { "name": "limit", "required": true, "in": "query", "schema": { "type": "string" } }, - { "name": "cursor", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6674,6 +7823,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } + } + } + }, "responses": { "200": { "description": "", @@ -6726,15 +7883,12 @@ } }, "security": [{ "bearer": [] }], - "summary": "List dataset profiles with a stable scoped cursor", - "tags": ["datasets"] - } - }, - "/v1/dataset-profiles/{profileId}": { + "summary": "Enqueue an opaque, tenant-scoped synchronization operation", + "tags": ["devices"] + }, "get": { - "operationId": "DatasetProfileController.get", + "operationId": "DeviceSyncController.list", "parameters": [ - { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6795,13 +7949,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an exact immutable dataset profile disclosure", - "tags": ["datasets"] + "summary": "List synchronization operation status without source content", + "tags": ["devices"] } }, - "/v1/dataset-exports": { + "/v1/devices/sync/pull": { "post": { - "operationId": "DatasetExportController.create", + "operationId": "DeviceSyncController.pull", "parameters": [ { "name": "X-Correlation-Id", @@ -6814,13 +7968,11 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDatasetExportManifestDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } } }, "responses": { - "201": { + "200": { "description": "", "headers": { "X-Correlation-Id": { @@ -6871,15 +8023,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Create a governed dataset export verification manifest", - "tags": ["datasets"] + "summary": "Pull an opaque, signed, cursor-bound synchronization batch", + "tags": ["devices"] } }, - "/v1/dataset-exports/{manifestId}": { - "get": { - "operationId": "DatasetExportController.find", + "/v1/devices/sync/push": { + "post": { + "operationId": "DeviceSyncController.push", "parameters": [ - { "name": "manifestId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6888,6 +8039,12 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } + } + }, "responses": { "200": { "description": "", @@ -6940,14 +8097,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read an immutable governed dataset export manifest", - "tags": ["datasets"] + "summary": "Push a signed, dependency-ordered synchronization batch", + "tags": ["devices"] } }, - "/v1/devices/sync/operations": { + "/v1/devices/sync/operations/{operationId}/transition": { "post": { - "operationId": "DeviceSyncController.enqueue", + "operationId": "DeviceSyncController.transition", "parameters": [ + { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -6960,7 +8118,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncOperationDto" } + "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } } } }, @@ -7016,11 +8174,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Enqueue an opaque, tenant-scoped synchronization operation", + "summary": "Advance one synchronization operation with an expected revision", "tags": ["devices"] - }, - "get": { - "operationId": "DeviceSyncController.list", + } + }, + "/v1/devices/sync/conflicts": { + "post": { + "operationId": "DeviceSyncController.createConflict", "parameters": [ { "name": "X-Correlation-Id", @@ -7030,6 +8190,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } + } + } + }, "responses": { "200": { "description": "", @@ -7082,13 +8250,11 @@ } }, "security": [{ "bearer": [] }], - "summary": "List synchronization operation status without source content", + "summary": "Record a conflict and stop the affected operation", "tags": ["devices"] - } - }, - "/v1/devices/sync/pull": { - "post": { - "operationId": "DeviceSyncController.pull", + }, + "get": { + "operationId": "DeviceSyncController.listConflicts", "parameters": [ { "name": "X-Correlation-Id", @@ -7098,12 +8264,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PullDeviceSyncDto" } } - } - }, "responses": { "200": { "description": "", @@ -7156,13 +8316,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pull an opaque, signed, cursor-bound synchronization batch", + "summary": "List explicit synchronization conflicts", "tags": ["devices"] } }, - "/v1/devices/sync/push": { + "/v1/devices/sync/packages": { "post": { - "operationId": "DeviceSyncController.push", + "operationId": "DeviceSyncController.issuePackage", "parameters": [ { "name": "X-Correlation-Id", @@ -7175,7 +8335,9 @@ "requestBody": { "required": true, "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/PushDeviceSyncDto" } } + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } + } } }, "responses": { @@ -7230,15 +8392,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "Push a signed, dependency-ordered synchronization batch", + "summary": "Issue a digest-only strict-Local package manifest", "tags": ["devices"] } }, - "/v1/devices/sync/operations/{operationId}/transition": { + "/v1/devices/sync/packages/receipts": { "post": { - "operationId": "DeviceSyncController.transition", + "operationId": "DeviceSyncController.receipt", "parameters": [ - { "name": "operationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7251,7 +8412,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/TransitionDeviceSyncOperationDto" } + "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } } } }, @@ -7307,14 +8468,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Advance one synchronization operation with an expected revision", + "summary": "Record a content-safe strict-Local transfer receipt", "tags": ["devices"] } }, - "/v1/devices/sync/conflicts": { + "/v1/devices/{deviceId}/capabilities": { "post": { - "operationId": "DeviceSyncController.createConflict", + "operationId": "DeviceCapabilityController.report", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7327,7 +8489,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceSyncConflictDto" } + "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } } } }, @@ -7383,12 +8545,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a conflict and stop the affected operation", + "summary": "Report one content-free device capability", "tags": ["devices"] }, "get": { - "operationId": "DeviceSyncController.listConflicts", + "operationId": "DeviceCapabilityController.listCapabilities", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7449,14 +8612,21 @@ } }, "security": [{ "bearer": [] }], - "summary": "List explicit synchronization conflicts", + "summary": "List content-free capabilities for one device", "tags": ["devices"] } }, - "/v1/devices/sync/packages": { + "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { "post": { - "operationId": "DeviceSyncController.issuePackage", + "operationId": "DeviceCapabilityController.pause", "parameters": [ + { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "capabilityId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -7469,7 +8639,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateStrictLocalPackageDto" } + "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } } } }, @@ -7525,13 +8695,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a digest-only strict-Local package manifest", + "summary": "Pause one device capability with an optimistic revision", "tags": ["devices"] } }, - "/v1/devices/sync/packages/receipts": { + "/v1/devices/grants": { "post": { - "operationId": "DeviceSyncController.receipt", + "operationId": "DeviceCapabilityController.issueGrant", "parameters": [ { "name": "X-Correlation-Id", @@ -7544,9 +8714,7 @@ "requestBody": { "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDeviceTransferReceiptDto" } - } + "application/json": { "schema": { "$ref": "#/components/schemas/IssueDeviceGrantDto" } } } }, "responses": { @@ -7601,13 +8769,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "Record a content-safe strict-Local transfer receipt", + "summary": "Issue a typed, workspace-scoped device grant", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities": { - "post": { - "operationId": "DeviceCapabilityController.report", + "/v1/devices/{deviceId}/grants": { + "get": { + "operationId": "DeviceCapabilityController.listGrants", "parameters": [ { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -7618,14 +8786,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ReportDeviceCapabilityDto" } - } - } - }, "responses": { "200": { "description": "", @@ -7678,13 +8838,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Report one content-free device capability", + "summary": "List typed grants for one device in the current workspace", "tags": ["devices"] - }, - "get": { - "operationId": "DeviceCapabilityController.listCapabilities", + } + }, + "/v1/devices/grants/{grantId}/revoke": { + "post": { + "operationId": "DeviceCapabilityController.revokeGrant", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7693,6 +8855,14 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeviceGrantRevisionDto" } + } + } + }, "responses": { "200": { "description": "", @@ -7745,21 +8915,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List content-free capabilities for one device", + "summary": "Revoke one typed device grant with an optimistic revision", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/capabilities/{capabilityId}/pause": { + "/v1/data-mode-policies": { "post": { - "operationId": "DeviceCapabilityController.pause", + "operationId": "DataModePolicyController.publish", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, - { - "name": "capabilityId", - "required": true, - "in": "path", - "schema": { "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -7772,7 +8935,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceCapabilityRevisionDto" } + "schema": { "$ref": "#/components/schemas/PublishDataModePolicyDto" } } } }, @@ -7828,14 +8991,15 @@ } }, "security": [{ "bearer": [] }], - "summary": "Pause one device capability with an optimistic revision", + "summary": "Publish an immutable workspace data-mode policy version", "tags": ["devices"] } }, - "/v1/devices/grants": { - "post": { - "operationId": "DeviceCapabilityController.issueGrant", + "/v1/data-mode-policies/{policyId}": { + "get": { + "operationId": "DataModePolicyController.list", "parameters": [ + { "name": "policyId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -7844,12 +9008,6 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/IssueDeviceGrantDto" } } - } - }, "responses": { "200": { "description": "", @@ -7902,15 +9060,26 @@ } }, "security": [{ "bearer": [] }], - "summary": "Issue a typed, workspace-scoped device grant", + "summary": "List immutable versions of one workspace data-mode policy", "tags": ["devices"] } }, - "/v1/devices/{deviceId}/grants": { + "/v1/audit/events": { "get": { - "operationId": "DeviceCapabilityController.listGrants", + "operationId": "AuditController.events", "parameters": [ - { "name": "deviceId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -7922,6 +9091,22 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -7968,18 +9153,42 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], - "summary": "List typed grants for one device in the current workspace", - "tags": ["devices"] + "summary": "List immutable audit events visible to the caller", + "tags": ["audit"] } }, - "/v1/devices/grants/{grantId}/revoke": { - "post": { - "operationId": "DeviceCapabilityController.revokeGrant", + "/v1/audit/seals": { + "get": { + "operationId": "AuditController.seals", "parameters": [ - { "name": "grantId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 100, "type": "number" } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { "maxLength": 512, "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -7988,17 +9197,25 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DeviceGrantRevisionDto" } - } - } - }, "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8045,16 +9262,29 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], - "summary": "Revoke one typed device grant with an optimistic revision", - "tags": ["devices"] + "summary": "List verified audit seals visible to the caller", + "tags": ["audit"] } }, - "/v1/data-mode-policies": { + "/v1/audit/attestations": { "post": { - "operationId": "DataModePolicyController.publish", + "operationId": "AuditAttestationController.create", "parameters": [ { "name": "X-Correlation-Id", @@ -8068,13 +9298,16 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PublishDataModePolicyDto" } + "schema": { "$ref": "#/components/schemas/CreateAuditAttestationDto" } } } }, "responses": { - "200": { + "201": { "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8104,6 +9337,19 @@ } } }, + "404": { + "description": "The requested seal 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": { @@ -8121,18 +9367,36 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit attestation signing or persistence is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], - "summary": "Publish an immutable workspace data-mode policy version", - "tags": ["devices"] + "summary": "Create an independent signature for an exact audit seal", + "tags": ["audit"] } }, - "/v1/data-mode-policies/{policyId}": { + "/v1/audit/attestations/{attestationId}/verify": { "get": { - "operationId": "DataModePolicyController.list", + "operationId": "AuditAttestationController.verify", "parameters": [ - { "name": "policyId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "attestationId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -8144,6 +9408,15 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid"], + "properties": { "valid": { "type": "boolean" } } + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8173,6 +9446,19 @@ } } }, + "404": { + "description": "The attestation or its referenced seal 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": { @@ -8190,29 +9476,31 @@ "schema": { "format": "uuid", "type": "string" } } } + }, + "503": { + "description": "Audit attestation verification is unavailable.", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } } }, "security": [{ "bearer": [] }], - "summary": "List immutable versions of one workspace data-mode policy", - "tags": ["devices"] + "summary": "Verify an independent audit seal attestation", + "tags": ["audit"] } }, - "/v1/audit/events": { + "/v1/entitlements/snapshots/{snapshotId}": { "get": { - "operationId": "AuditController.events", + "operationId": "EntitlementController.snapshot", "parameters": [ - { - "name": "limit", - "required": false, - "in": "query", - "schema": { "minimum": 1, "maximum": 100, "type": "number" } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { "maxLength": 512, "type": "string" } - }, + { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "X-Correlation-Id", "in": "header", @@ -8225,20 +9513,7 @@ "200": { "description": "", "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["items"], - "properties": { - "items": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - }, - "nextCursor": { "type": "string", "maxLength": 512 } - }, - "additionalProperties": false - } - } + "application/json": { "schema": { "type": "object", "additionalProperties": true } } }, "headers": { "X-Correlation-Id": { @@ -8252,12 +9527,20 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } + "description": "The snapshot identifier is invalid.", + "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 entitlement snapshot is not visible.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8288,7 +9571,7 @@ } }, "503": { - "description": "Audit persistence is unavailable.", + "description": "Entitlement persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8302,26 +9585,14 @@ } }, "security": [{ "bearer": [] }], - "summary": "List immutable audit events visible to the caller", - "tags": ["audit"] + "summary": "Read one immutable entitlement snapshot in the caller scope", + "tags": ["entitlements"] } }, - "/v1/audit/seals": { + "/v1/entitlements/usage": { "get": { - "operationId": "AuditController.seals", + "operationId": "EntitlementController.usage", "parameters": [ - { - "name": "limit", - "required": false, - "in": "query", - "schema": { "minimum": 1, "maximum": 100, "type": "number" } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { "maxLength": 512, "type": "string" } - }, { "name": "X-Correlation-Id", "in": "header", @@ -8337,13 +9608,16 @@ "application/json": { "schema": { "type": "object", - "required": ["items"], + "required": ["entries", "reservations"], "properties": { - "items": { + "entries": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, - "nextCursor": { "type": "string", "maxLength": 512 } + "reservations": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } }, "additionalProperties": false } @@ -8397,7 +9671,7 @@ } }, "503": { - "description": "Audit persistence is unavailable.", + "description": "Usage persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8411,13 +9685,13 @@ } }, "security": [{ "bearer": [] }], - "summary": "List verified audit seals visible to the caller", - "tags": ["audit"] + "summary": "Read the append-only usage ledger state in the caller scope", + "tags": ["entitlements"] } }, - "/v1/entitlements/snapshots/{snapshotId}": { - "get": { - "operationId": "EntitlementController.snapshot", + "/v1/entitlements/snapshots/{snapshotId}/leases": { + "post": { + "operationId": "EntitlementController.issueLease", "parameters": [ { "name": "snapshotId", "required": true, "in": "path", "schema": { "type": "string" } }, { @@ -8428,8 +9702,16 @@ "schema": { "format": "uuid", "maxLength": 128, "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IssueEntitlementLeaseDto" } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } @@ -8446,7 +9728,7 @@ } }, "400": { - "description": "The snapshot identifier is invalid.", + "description": "The snapshot or expiry is invalid.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8490,7 +9772,7 @@ } }, "503": { - "description": "Entitlement persistence is unavailable.", + "description": "Lease signing or persistence is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8504,14 +9786,34 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read one immutable entitlement snapshot in the caller scope", + "summary": "Issue a signed, bounded offline entitlement lease", "tags": ["entitlements"] } }, - "/v1/entitlements/usage": { + "/v1/entitlements/leases/{leaseId}/verify": { "get": { - "operationId": "EntitlementController.usage", + "operationId": "EntitlementController.verifyLease", "parameters": [ + { "name": "leaseId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "snapshotRevision", + "required": true, + "in": "query", + "schema": { "minimum": 1, "type": "number" } + }, + { + "name": "securityEpoch", + "required": true, + "in": "query", + "schema": { "minimum": 1, "type": "number" } + }, + { + "name": "now", + "required": false, + "in": "query", + "description": "Verification time; server clock is used when omitted", + "schema": { "format": "date-time", "type": "string" } + }, { "name": "X-Correlation-Id", "in": "header", @@ -8527,18 +9829,8 @@ "application/json": { "schema": { "type": "object", - "required": ["entries", "reservations"], - "properties": { - "entries": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - }, - "reservations": { - "type": "array", - "items": { "type": "object", "additionalProperties": true } - } - }, - "additionalProperties": false + "required": ["valid"], + "properties": { "valid": { "type": "boolean" } } } } }, @@ -8554,12 +9846,20 @@ } }, "400": { - "description": "The request was malformed or failed closed validation.", - "content": { - "application/problem+json": { - "schema": { "$ref": "#/components/schemas/ProblemDetails" } + "description": "The lease verification input is invalid or stale.", + "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 lease is not visible.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8590,7 +9890,7 @@ } }, "503": { - "description": "Usage persistence is unavailable.", + "description": "Lease verification is unavailable.", "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -8604,7 +9904,7 @@ } }, "security": [{ "bearer": [] }], - "summary": "Read the append-only usage ledger state in the caller scope", + "summary": "Verify an offline entitlement lease against the current revision and epoch", "tags": ["entitlements"] } }, @@ -8861,7 +10161,8 @@ "organizationId": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, "authorizationEpoch": { "type": "number", "minimum": 1 }, - "mfaRequired": { "type": "boolean" } + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } }, "required": ["userId", "organizationId", "authorizationEpoch", "mfaRequired"] }, @@ -8885,7 +10186,8 @@ "refreshToken": { "type": "string", "minLength": 1, "maxLength": 4096 }, "accessExpiresAt": { "type": "string", "format": "date-time" }, "securityEpoch": { "type": "number", "minimum": 1 }, - "mfaRequired": { "type": "boolean" } + "mfaRequired": { "type": "boolean" }, + "mfaReenrollmentRequired": { "type": "boolean" } }, "required": [ "sessionId", @@ -9073,6 +10375,94 @@ }, "required": ["expectedRevision"] }, + "IssueInvitationDto": { + "type": "object", + "properties": { + "membershipId": { "type": "string", "format": "uuid" }, + "recipientEmail": { "type": "string", "format": "email", "maxLength": 254 } + }, + "required": ["membershipId", "recipientEmail"] + }, + "InvitationRejectedResponseDto": { + "type": "object", + "properties": { + "accepted": { "type": "boolean", "enum": [false], "example": false }, + "code": { + "type": "string", + "enum": [ + "INVITATION_REQUEST_REJECTED", + "INVITATION_SCOPE_DENIED", + "INVITATION_NOT_FOUND", + "INVITATION_CONFLICT", + "INVITATION_DELIVERY_UNAVAILABLE", + "INVITATION_UNAVAILABLE" + ] + } + }, + "required": ["accepted", "code"] + }, + "AcceptInvitationDto": { + "type": "object", + "properties": { + "token": { "type": "string", "minLength": 32, "maxLength": 512, "writeOnly": true } + }, + "required": ["token"] + }, + "RegistrationDto": { + "type": "object", + "properties": { + "email": { "type": "string", "example": "ngu***@example.com", "maxLength": 254 }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 200 }, + "password": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true }, + "locale": { "type": "string", "enum": ["vi-VN", "en"], "default": "vi-VN" } + }, + "required": ["email", "displayName", "password"] + }, + "RegistrationResponseDto": { + "type": "object", + "properties": { + "userId": { "type": "string", "format": "uuid" }, + "organizationId": { "type": "string", "format": "uuid" }, + "workspaceId": { "type": "string", "format": "uuid" }, + "projectId": { "type": "string", "format": "uuid" }, + "membershipId": { "type": "string", "format": "uuid" }, + "locale": { "type": "string", "enum": ["vi-VN", "en"] } + }, + "required": [ + "userId", + "organizationId", + "workspaceId", + "projectId", + "membershipId", + "locale" + ] + }, + "RecoveryRequestDto": { + "type": "object", + "properties": { "email": { "type": "string", "format": "email", "maxLength": 254 } }, + "required": ["email"] + }, + "RecoveryRequestResponseDto": { + "type": "object", + "properties": { "requested": { "type": "boolean", "enum": [true], "example": true } }, + "required": ["requested"] + }, + "RecoveryCompleteDto": { + "type": "object", + "properties": { + "token": { "type": "string", "minLength": 32, "maxLength": 512, "writeOnly": true }, + "newPassword": { "type": "string", "minLength": 12, "maxLength": 128, "writeOnly": true } + }, + "required": ["token", "newPassword"] + }, + "RecoveryCompleteResponseDto": { + "type": "object", + "properties": { + "userId": { "type": "string", "format": "uuid" }, + "mfaReenrollmentRequired": { "type": "boolean", "enum": [true], "example": true } + }, + "required": ["userId", "mfaReenrollmentRequired"] + }, "BootstrapUserDto": { "type": "object", "properties": { @@ -9170,6 +10560,34 @@ }, "required": ["accepted"] }, + "CreateServiceAccountDto": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "workspaceId": { + "type": "string", + "format": "uuid", + "description": "Optional workspace narrowing for the identity" + }, + "permissions": { + "minItems": 1, + "maxItems": 64, + "type": "array", + "items": { "type": "string" } + }, + "secretExpiresAt": { + "type": "string", + "format": "date-time", + "description": "Optional expiry, at most 365 days after issue" + } + }, + "required": ["name", "permissions"] + }, + "ServiceAccountRevisionDto": { + "type": "object", + "properties": { "expectedRevision": { "type": "number", "minimum": 1 } }, + "required": ["expectedRevision"] + }, "CreateInboxItemDto": { "type": "object", "properties": { @@ -10079,6 +11497,32 @@ "publishedAt" ] }, + "CreateAuditAttestationDto": { + "type": "object", + "properties": { + "attestationId": { + "type": "string", + "format": "uuid", + "description": "Server-generated when omitted" + }, + "signerKeyId": { "type": "string", "minLength": 1, "maxLength": 200 }, + "firstSequence": { "type": "number", "minimum": 1 }, + "lastSequence": { "type": "number", "minimum": 1 }, + "rootDigest": { "type": "string", "minLength": 1, "maxLength": 512 } + }, + "required": ["signerKeyId", "firstSequence", "lastSequence", "rootDigest"] + }, + "IssueEntitlementLeaseDto": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "UTC expiry no more than 24 hours after issue" + } + }, + "required": ["expiresAt"] + }, "SpreadsheetAuditSheetDto": { "type": "object", "properties": { diff --git a/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql b/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql new file mode 100644 index 00000000..38642d0b --- /dev/null +++ b/services/api/prisma/migrations/20260803040000_iam_invitation_tokens/migration.sql @@ -0,0 +1,31 @@ +-- IAM-010: invitation bearer values are short-lived and hashed at rest. +CREATE TABLE "iam"."invitation_tokens" ( + "id" UUID NOT NULL, + "membership_id" UUID NOT NULL, + "principal_id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "role_id" VARCHAR(32) NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "email_digest" CHAR(64) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "consumed_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitation_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "invitation_tokens_token_digest_key" +ON "iam"."invitation_tokens"("token_digest"); + +CREATE INDEX "invitation_tokens_membership_status_idx" +ON "iam"."invitation_tokens"("membership_id", "status"); + +CREATE INDEX "invitation_tokens_scope_idx" +ON "iam"."invitation_tokens"("organization_id", "scope_type", "workspace_id", "project_id"); diff --git a/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql b/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql new file mode 100644 index 00000000..f58f874b --- /dev/null +++ b/services/api/prisma/migrations/20260803050000_iam_recovery_challenges/migration.sql @@ -0,0 +1,30 @@ +-- IAM-015: preserve the security epoch and force MFA re-enrollment after recovery. +ALTER TABLE "iam"."users" + ADD COLUMN "mfa_reenrollment_required" BOOLEAN NOT NULL DEFAULT false; + +-- IAM-015: raw recovery bearers never persist; only keyed digests are stored. +CREATE TABLE "iam"."recovery_challenges" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "email_digest" CHAR(64) NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "consumed_at" TIMESTAMPTZ(6), + "revoked_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "recovery_challenges_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "recovery_challenges_token_digest_key" +ON "iam"."recovery_challenges"("token_digest"); + +CREATE INDEX "recovery_challenges_user_status_idx" +ON "iam"."recovery_challenges"("user_id", "status"); + +CREATE INDEX "recovery_challenges_expiry_status_idx" +ON "iam"."recovery_challenges"("expires_at", "status"); diff --git a/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql b/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql new file mode 100644 index 00000000..4f0e1c6d --- /dev/null +++ b/services/api/prisma/migrations/20260803060000_iam_service_accounts/migration.sql @@ -0,0 +1,28 @@ +-- IAM-013: store only scoped service-account metadata and a digest of the one-time secret. +CREATE TABLE "iam"."service_accounts" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "name" VARCHAR(200) NOT NULL, + "permissions" JSONB NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + "secret_digest" CHAR(64) NOT NULL, + "secret_version" INTEGER NOT NULL DEFAULT 1, + "secret_issued_at" TIMESTAMPTZ(6) NOT NULL, + "secret_expires_at" TIMESTAMPTZ(6), + "last_used_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMPTZ(6), + "revision" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "service_accounts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "service_accounts_secret_digest_key" +ON "iam"."service_accounts"("secret_digest"); + +CREATE INDEX "service_accounts_scope_status_idx" +ON "iam"."service_accounts"("organization_id", "workspace_id", "status"); + +CREATE INDEX "service_accounts_expiry_status_idx" +ON "iam"."service_accounts"("secret_expires_at", "status"); diff --git a/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql b/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql new file mode 100644 index 00000000..0a37e0bc --- /dev/null +++ b/services/api/prisma/migrations/20260803070000_bua_entitlement_leases/migration.sql @@ -0,0 +1,21 @@ +-- BUA-017/018: persist signed offline leases without provider-specific billing state. +CREATE TABLE "bua"."entitlement_leases" ( + "id" UUID NOT NULL, + "schema_version" INTEGER NOT NULL, + "scope_key" VARCHAR(200) NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "snapshot_revision" INTEGER NOT NULL, + "security_epoch" INTEGER NOT NULL, + "issued_at" TIMESTAMPTZ(6) NOT NULL, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + "payload" TEXT NOT NULL, + "signature" VARCHAR(2048) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "entitlement_leases_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "entitlement_leases_scope_expiry_idx" +ON "bua"."entitlement_leases"("organization_id", "workspace_id", "expires_at"); diff --git a/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql b/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql new file mode 100644 index 00000000..6629d6c8 --- /dev/null +++ b/services/api/prisma/migrations/20260803080000_aud_seal_attestations/migration.sql @@ -0,0 +1,24 @@ +-- AUD-015/016: independent seal attestation storage. +CREATE TABLE "aud"."audit_seal_attestations" ( + "id" UUID NOT NULL, + "schema_version" INTEGER NOT NULL, + "scope_key" VARCHAR(200) NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "first_sequence" INTEGER NOT NULL, + "last_sequence" INTEGER NOT NULL, + "event_count" INTEGER NOT NULL, + "root_digest" VARCHAR(512) NOT NULL, + "sealed_at" TIMESTAMPTZ(6) NOT NULL, + "signer_key_id" VARCHAR(200) NOT NULL, + "payload" TEXT NOT NULL, + "signature" VARCHAR(2048) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_seal_attestations_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "audit_attestations_scope_idx" +ON "aud"."audit_seal_attestations"("organization_id", "workspace_id", "project_id", "last_sequence"); diff --git a/services/api/prisma/schema/aud.prisma b/services/api/prisma/schema/aud.prisma index 77838972..2b43170e 100644 --- a/services/api/prisma/schema/aud.prisma +++ b/services/api/prisma/schema/aud.prisma @@ -51,3 +51,27 @@ model AuditSealRecord { @@map("audit_seals") @@schema("aud") } + +/// AUD-015/016: independent signatures bind an immutable seal range and signer key. +model AuditSealAttestationRecord { + id String @id @db.Uuid + schemaVersion Int @map("schema_version") + scopeKey String @map("scope_key") @db.VarChar(200) + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + firstSequence Int @map("first_sequence") + lastSequence Int @map("last_sequence") + eventCount Int @map("event_count") + rootDigest String @map("root_digest") @db.VarChar(512) + sealedAt DateTime @map("sealed_at") @db.Timestamptz(6) + signerKeyId String @map("signer_key_id") @db.VarChar(200) + payload String @db.Text + signature String @db.VarChar(2048) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([organizationId, workspaceId, projectId, lastSequence], map: "audit_attestations_scope_idx") + @@map("audit_seal_attestations") + @@schema("aud") +} diff --git a/services/api/prisma/schema/bua.prisma b/services/api/prisma/schema/bua.prisma index 75d97cdc..ec13e331 100644 --- a/services/api/prisma/schema/bua.prisma +++ b/services/api/prisma/schema/bua.prisma @@ -38,6 +38,27 @@ model EntitlementSnapshotRecord { @@schema("bua") } +/// BUA-017/018: signed offline leases are immutable and bound to a snapshot revision/epoch. +model EntitlementLeaseRecord { + id String @id @db.Uuid + schemaVersion Int @map("schema_version") + scopeKey String @map("scope_key") @db.VarChar(200) + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + snapshotRevision Int @map("snapshot_revision") + securityEpoch Int @map("security_epoch") + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + payload String @db.Text + signature String @db.VarChar(2048) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([organizationId, workspaceId, expiresAt], map: "entitlement_leases_scope_expiry_idx") + @@map("entitlement_leases") + @@schema("bua") +} + model UsageLedgerEntryRecord { id String @id @db.Uuid schemaVersion Int @map("schema_version") diff --git a/services/api/prisma/schema/iam.prisma b/services/api/prisma/schema/iam.prisma index 279aff05..4e1a6404 100644 --- a/services/api/prisma/schema/iam.prisma +++ b/services/api/prisma/schema/iam.prisma @@ -7,6 +7,7 @@ model UserIdentity { locale String @default("vi-VN") @db.VarChar(16) status String @default("ACTIVE") @db.VarChar(24) securityEpoch Int @default(1) @map("security_epoch") + mfaReenrollmentRequired Boolean @default(false) @map("mfa_reenrollment_required") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) @@ -91,6 +92,53 @@ model MembershipIdentity { @@schema("iam") } +/// IAM-010: raw invitation bearer values are never persisted; only keyed digests are stored. +model InvitationTokenRecord { + id String @id @db.Uuid + membershipId String @map("membership_id") @db.Uuid + principalId String @map("principal_id") @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + roleId String @map("role_id") @db.VarChar(32) + tokenDigest String @unique(map: "invitation_tokens_token_digest_key") @map("token_digest") @db.Char(64) + emailDigest String @map("email_digest") @db.Char(64) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(16) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(6) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([membershipId, status], map: "invitation_tokens_membership_status_idx") + @@index([organizationId, scopeType, workspaceId, projectId], map: "invitation_tokens_scope_idx") + @@map("invitation_tokens") + @@schema("iam") +} + +/// IAM-015: recovery bearers are short-lived, email-bound, and hashed at rest. +model RecoveryChallengeRecord { + id String @id @db.Uuid + userId String @map("user_id") @db.Uuid + tokenDigest String @unique(map: "recovery_challenges_token_digest_key") @map("token_digest") @db.Char(64) + emailDigest String @map("email_digest") @db.Char(64) + issuedAt DateTime @map("issued_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + status String @default("ACTIVE") @db.VarChar(16) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(6) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([userId, status], map: "recovery_challenges_user_status_idx") + @@index([expiresAt, status], map: "recovery_challenges_expiry_status_idx") + @@map("recovery_challenges") + @@schema("iam") +} + model SessionRecord { id String @id @db.Uuid userId String @map("user_id") @db.Uuid @@ -235,3 +283,26 @@ model AuthorizationSnapshot { @@map("authorization_snapshots") @@schema("iam") } + +/// IAM-013: non-interactive identities retain only a digest of their one-time secret. +model ServiceAccountRecord { + id String @id @db.Uuid + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + name String @db.VarChar(200) + permissions Json + status String @default("ACTIVE") @db.VarChar(16) + secretDigest String @unique(map: "service_accounts_secret_digest_key") @map("secret_digest") @db.Char(64) + secretVersion Int @default(1) @map("secret_version") + secretIssuedAt DateTime @map("secret_issued_at") @db.Timestamptz(6) + secretExpiresAt DateTime? @map("secret_expires_at") @db.Timestamptz(6) + lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([organizationId, workspaceId, status], map: "service_accounts_scope_status_idx") + @@index([secretExpiresAt, status], map: "service_accounts_expiry_status_idx") + @@map("service_accounts") + @@schema("iam") +} diff --git a/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts new file mode 100644 index 00000000..15b4c9f2 --- /dev/null +++ b/services/api/src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.ts @@ -0,0 +1,94 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditAttestationRepositoryPortV1, + AuditAttestationTransactionPortV1, +} from '../application/audit-attestation-repository.port.js'; +import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function clone(attestation: AuditSealAttestationV1): AuditSealAttestationV1 { + return Object.freeze({ + ...attestation, + tenantScope: Object.freeze({ ...attestation.tenantScope }), + }); +} + +/** In-memory independent attestation store with immutable identity and tenant visibility. */ +export class InMemoryAuditAttestationRepositoryAdapter implements AuditAttestationRepositoryPortV1 { + private attestations = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = this.attestations.get(attestation.attestationId); + if (existing && !sameAuditSealAttestationV1(existing, attestation)) + throw new Error('AUD_IMMUTABLE_ATTESTATION'); + this.attestations.set(attestation.attestationId, clone(attestation)); + } + + public async findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const attestation = this.attestations.get(attestationId); + return attestation && visible(context.tenantScope, attestation.tenantScope) + ? clone(attestation) + : undefined; + } + + public async listAttestations( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.attestations.values()] + .filter((attestation) => visible(context.tenantScope, attestation.tenantScope)) + .sort( + (left, right) => + tenantScopeKeyV1(left.tenantScope).localeCompare(tenantScopeKeyV1(right.tenantScope)) || + left.lastSequence - right.lastSequence || + left.attestationId.localeCompare(right.attestationId), + ) + .map(clone); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.attestations); + try { + return await work({ + saveAttestation: this.saveAttestation.bind(this), + findAttestation: this.findAttestation.bind(this), + }); + } catch (error) { + this.attestations = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts new file mode 100644 index 00000000..c193f725 --- /dev/null +++ b/services/api/src/features/aud/adapter/prisma-audit-attestation-repository.adapter.ts @@ -0,0 +1,253 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + AuditAttestationRepositoryPortV1, + AuditAttestationTransactionPortV1, +} from '../application/audit-attestation-repository.port.js'; +import { sameAuditSealAttestationV1 } from '../application/audit-equality.js'; + +export interface AuditAttestationDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly rootDigest: string; + readonly sealedAt: Date; + readonly signerKeyId: string; + readonly payload: string; + readonly signature: string; + readonly createdAt: Date; +} + +interface AuditAttestationDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +interface AuditAttestationDelegateV1 { + create(input: { + readonly data: AuditAttestationDatabaseCreateDataV1; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface AuditAttestationDatabaseClientV1 { + readonly auditSealAttestationRecord: AuditAttestationDelegateV1; + $transaction( + work: (transaction: AuditAttestationDatabaseClientV1) => Promise, + ): Promise; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function persistedScope(row: AuditAttestationDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('AUD_PERSISTED_ATTESTATION_SCOPE_INVALID'); + return parsed.value; +} + +function persistedAttestation(row: AuditAttestationDatabaseRowV1): AuditSealAttestationV1 { + const attestationId = parseStableIdentifierV1(row.id); + const scope = persistedScope(row); + const sealedAt = parseStrictUtcTimestampV1(row.sealedAt.toISOString()); + if ( + row.schemaVersion !== 1 || + !attestationId.accepted || + !sealedAt.accepted || + !positiveInteger(row.firstSequence) || + !positiveInteger(row.lastSequence) || + row.lastSequence < row.firstSequence || + !positiveInteger(row.eventCount) || + !text(row.rootDigest, 512) || + !text(row.signerKeyId, 200) || + !text(row.payload, 10000) || + !text(row.signature, 2048) + ) + throw new Error('AUD_PERSISTED_ATTESTATION_INVALID'); + return Object.freeze({ + schemaVersion: 1, + attestationId: attestationId.value, + tenantScope: scope, + firstSequence: row.firstSequence, + lastSequence: row.lastSequence, + eventCount: row.eventCount, + rootDigest: row.rootDigest, + sealedAt: sealedAt.value, + signerKeyId: row.signerKeyId, + payload: row.payload, + signature: row.signature, + }); +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + if (context.tenantScope.scopeType === 'organization') + return { organizationId: context.tenantScope.organizationId }; + if (context.tenantScope.scopeType === 'workspace') { + return { + organizationId: context.tenantScope.organizationId, + OR: [ + { scopeType: 'organization' }, + { scopeType: 'workspace', workspaceId: context.tenantScope.workspaceId }, + ], + }; + } + return { + organizationId: context.tenantScope.organizationId, + OR: [ + { scopeType: 'organization' }, + { scopeType: 'workspace', workspaceId: context.tenantScope.workspaceId }, + { scopeType: 'project', projectId: context.tenantScope.projectId }, + ], + }; +} + +function attestationData( + attestation: AuditSealAttestationV1, +): AuditAttestationDatabaseCreateDataV1 { + return { + ...databaseScope(attestation.tenantScope), + id: attestation.attestationId, + schemaVersion: attestation.schemaVersion, + scopeKey: tenantScopeKeyV1(attestation.tenantScope), + firstSequence: attestation.firstSequence, + lastSequence: attestation.lastSequence, + eventCount: attestation.eventCount, + rootDigest: attestation.rootDigest, + sealedAt: new Date(attestation.sealedAt), + signerKeyId: attestation.signerKeyId, + payload: attestation.payload, + signature: attestation.signature, + createdAt: new Date(), + }; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaAuditAttestationTransactionAdapter implements AuditAttestationTransactionPortV1 { + public constructor(private readonly client: AuditAttestationDatabaseClientV1) {} + + public async saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, attestation.tenantScope)) + throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.auditSealAttestationRecord.findFirst({ + where: { id: attestation.attestationId }, + }); + if (existing !== null) { + if (!sameAuditSealAttestationV1(persistedAttestation(existing), attestation)) + throw new Error('AUD_IMMUTABLE_ATTESTATION'); + return; + } + await this.client.auditSealAttestationRecord.create({ data: attestationData(attestation) }); + } + + public async findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + const row = await this.client.auditSealAttestationRecord.findFirst({ + where: { id: attestationId, ...scopeWhere(context) }, + }); + if (row === null) return undefined; + const attestation = persistedAttestation(row); + return visible(context.tenantScope, attestation.tenantScope) ? attestation : undefined; + } +} + +export class PrismaAuditAttestationRepositoryAdapter implements AuditAttestationRepositoryPortV1 { + public constructor(private readonly client: AuditAttestationDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaAuditAttestationTransactionAdapter(transaction)), + ); + } + + public saveAttestation( + context: IamTenantContextV1, + attestation: AuditSealAttestationV1, + ): Promise { + return new PrismaAuditAttestationTransactionAdapter(this.client).saveAttestation( + context, + attestation, + ); + } + + public findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise { + return new PrismaAuditAttestationTransactionAdapter(this.client).findAttestation( + context, + attestationId, + ); + } + + public async listAttestations( + context: IamTenantContextV1, + ): Promise { + const rows = await this.client.auditSealAttestationRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + }); + return rows + .map(persistedAttestation) + .filter((attestation) => visible(context.tenantScope, attestation.tenantScope)) + .sort( + (left, right) => + tenantScopeKeyV1(left.tenantScope).localeCompare(tenantScopeKeyV1(right.tenantScope)) || + left.lastSequence - right.lastSequence || + left.attestationId.localeCompare(right.attestationId), + ); + } +} diff --git a/services/api/src/features/aud/api/audit-attestation.controller.ts b/services/api/src/features/aud/api/audit-attestation.controller.ts new file mode 100644 index 00000000..f6420631 --- /dev/null +++ b/services/api/src/features/aud/api/audit-attestation.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + AUDIT_ATTESTATION_SERVICE, + type AuditAttestationApplicationResultV1, + type AuditAttestationService, +} from '../application/audit-attestation.service.js'; +import { AuditProblemError } from '../application/audit-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { CreateAuditAttestationDto } from './audit-attestation.dto.js'; + +@ApiTags('audit') +@ApiBearerAuth() +@Controller('v1/audit') +export class AuditAttestationController { + public constructor( + @Inject(AUDIT_ATTESTATION_SERVICE) + private readonly attestations: AuditAttestationService, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private async execute( + work: () => Promise>, + ): Promise { + let result: AuditAttestationApplicationResultV1; + try { + result = await work(); + } catch { + throw new AuditProblemError('AUDIT_ATTESTATION_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'NOT_FOUND') throw new AuditProblemError('AUDIT_ATTESTATION_NOT_FOUND'); + if (result.code === 'UNAVAILABLE') throw new AuditProblemError('AUDIT_ATTESTATION_UNAVAILABLE'); + throw new AuditProblemError('AUDIT_ATTESTATION_REQUEST_INVALID'); + } + + @Post('attestations') + @HttpCode(201) + @ApiOperation({ summary: 'Create an independent signature for an exact audit seal' }) + @ApiBody({ type: CreateAuditAttestationDto }) + @ApiCreatedResponse({ schema: { type: 'object', additionalProperties: true } }) + @ApiNotFoundResponse({ description: 'The requested seal is not visible.' }) + @ApiServiceUnavailableResponse({ + description: 'Audit attestation signing or persistence is unavailable.', + }) + async create( + @Req() request: unknown, + @Body() input: CreateAuditAttestationDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => this.attestations.create(context, input)); + } + + @Get('attestations/:attestationId/verify') + @ApiOperation({ summary: 'Verify an independent audit seal attestation' }) + @ApiOkResponse({ + schema: { type: 'object', required: ['valid'], properties: { valid: { type: 'boolean' } } }, + }) + @ApiNotFoundResponse({ description: 'The attestation or its referenced seal is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Audit attestation verification is unavailable.' }) + async verify( + @Req() request: unknown, + @Param('attestationId') attestationId: string, + ): Promise<{ readonly valid: true }> { + const context = await this.requestContext.resolve(request); + await this.execute(() => this.attestations.verify(context, { attestationId })); + return Object.freeze({ valid: true }); + } +} diff --git a/services/api/src/features/aud/api/audit-attestation.dto.ts b/services/api/src/features/aud/api/audit-attestation.dto.ts new file mode 100644 index 00000000..9ad2111d --- /dev/null +++ b/services/api/src/features/aud/api/audit-attestation.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateAuditAttestationDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Server-generated when omitted' }) + @IsOptional() + @IsUUID() + attestationId?: string; + + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + signerKeyId!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + firstSequence!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + lastSequence!: number; + + @ApiProperty({ minLength: 1, maxLength: 512 }) + @IsString() + @MinLength(1) + @MaxLength(512) + rootDigest!: string; +} diff --git a/services/api/src/features/aud/application/audit-attestation-repository.port.ts b/services/api/src/features/aud/application/audit-attestation-repository.port.ts new file mode 100644 index 00000000..2ca4fa8a --- /dev/null +++ b/services/api/src/features/aud/application/audit-attestation-repository.port.ts @@ -0,0 +1,22 @@ +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const AUDIT_ATTESTATION_REPOSITORY_PORT = Symbol('AUDIT_ATTESTATION_REPOSITORY_PORT'); + +export interface AuditAttestationTransactionPortV1 { + saveAttestation(context: IamTenantContextV1, attestation: AuditSealAttestationV1): Promise; + findAttestation( + context: IamTenantContextV1, + attestationId: StableIdentifierV1, + ): Promise; +} + +export interface AuditAttestationRepositoryPortV1 extends AuditAttestationTransactionPortV1 { + listAttestations(context: IamTenantContextV1): Promise; + withTransaction( + context: IamTenantContextV1, + work: (transaction: AuditAttestationTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/aud/application/audit-attestation.service.ts b/services/api/src/features/aud/application/audit-attestation.service.ts new file mode 100644 index 00000000..e149307a --- /dev/null +++ b/services/api/src/features/aud/application/audit-attestation.service.ts @@ -0,0 +1,153 @@ +import { randomUUID } from 'node:crypto'; + +import { + createAuditSealAttestationV1, + verifyAuditSealAttestationV1, + type AuditErrorCodeV1, + type AuditSealAttestationSignerV1, + type AuditSealAttestationV1, + type AuditResultV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + tenantScopeKeyV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { AuditAttestationRepositoryPortV1 } from './audit-attestation-repository.port.js'; +import type { AuditRepositoryPortV1 } from './audit-repository.port.js'; + +export const AUDIT_ATTESTATION_SERVICE = Symbol('AUDIT_ATTESTATION_SERVICE'); + +export type AuditAttestationClockV1 = () => Date; +export type AuditAttestationIdGeneratorV1 = () => string; + +export type AuditAttestationApplicationCodeV1 = AuditErrorCodeV1 | 'NOT_FOUND' | 'UNAVAILABLE'; + +export type AuditAttestationApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: AuditAttestationApplicationCodeV1 }; + +export interface CreateAuditAttestationInputV1 { + readonly attestationId?: unknown; + readonly signerKeyId: unknown; + readonly firstSequence: unknown; + readonly lastSequence: unknown; + readonly rootDigest: unknown; +} + +export interface VerifyAuditAttestationInputV1 { + readonly attestationId: unknown; +} + +function rejected( + code: AuditAttestationApplicationCodeV1, +): AuditAttestationApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function positiveInteger(input: unknown): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; +} + +function text(input: unknown, maxLength: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maxLength) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function applicationResult( + result: AuditResultV1, +): AuditAttestationApplicationResultV1 { + return result.accepted ? result : rejected(result.code); +} + +/** Attests only a persisted exact-scope seal and keeps the signature in a separate store. */ +export class AuditAttestationService { + public constructor( + private readonly auditRepository: AuditRepositoryPortV1, + private readonly attestationRepository: AuditAttestationRepositoryPortV1, + private readonly signer: AuditSealAttestationSignerV1, + private readonly idGenerator: AuditAttestationIdGeneratorV1 = () => randomUUID(), + ) {} + + public async create( + context: IamTenantContextV1, + input: CreateAuditAttestationInputV1, + ): Promise> { + const attestationId = stableId(input.attestationId ?? this.idGenerator()); + const firstSequence = positiveInteger(input.firstSequence); + const lastSequence = positiveInteger(input.lastSequence); + const rootDigest = text(input.rootDigest, 512); + if (!attestationId) return rejected('INVALID_IDENTIFIER'); + if (!firstSequence || !lastSequence || lastSequence < firstSequence) + return rejected('INVALID_SEQUENCE'); + if (!rootDigest) return rejected('INVALID_TEXT'); + const seals = await this.auditRepository.listSeals(context); + const seal = seals.find( + (candidate) => + tenantScopeKeyV1(candidate.tenantScope) === tenantScopeKeyV1(context.tenantScope) && + candidate.firstSequence === firstSequence && + candidate.lastSequence === lastSequence && + candidate.rootDigest === rootDigest, + ); + if (!seal) return rejected('NOT_FOUND'); + const created = createAuditSealAttestationV1( + seal, + { attestationId, signerKeyId: input.signerKeyId }, + this.signer, + ); + if (!created.accepted) return applicationResult(created); + await this.attestationRepository.withTransaction(context, async (transaction) => { + await transaction.saveAttestation(context, created.value); + }); + return created; + } + + public async verify( + context: IamTenantContextV1, + input: VerifyAuditAttestationInputV1, + ): Promise> { + const attestationId = stableId(input.attestationId); + if (!attestationId) return rejected('INVALID_IDENTIFIER'); + const attestation = await this.attestationRepository.findAttestation(context, attestationId); + if (!attestation) return rejected('NOT_FOUND'); + const seals = await this.auditRepository.listSeals(context); + const seal = seals.find( + (candidate) => + tenantScopeKeyV1(candidate.tenantScope) === tenantScopeKeyV1(attestation.tenantScope) && + candidate.firstSequence === attestation.firstSequence && + candidate.lastSequence === attestation.lastSequence && + candidate.rootDigest === attestation.rootDigest, + ); + if (!seal) return rejected('NOT_FOUND'); + return applicationResult(verifyAuditSealAttestationV1(attestation, seal, this.signer)); + } +} + +export class UnavailableAuditAttestationService { + public create( + context: IamTenantContextV1, + input: CreateAuditAttestationInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } + + public verify( + context: IamTenantContextV1, + input: VerifyAuditAttestationInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } +} diff --git a/services/api/src/features/aud/application/audit-equality.ts b/services/api/src/features/aud/application/audit-equality.ts index a2429f6a..f1ced32b 100644 --- a/services/api/src/features/aud/application/audit-equality.ts +++ b/services/api/src/features/aud/application/audit-equality.ts @@ -1,4 +1,9 @@ -import type { AuditEventV1, AuditSealV1, AuditSummaryV1 } from '@databreeze/domain/audit/v1'; +import type { + AuditEventV1, + AuditSealAttestationV1, + AuditSealV1, + AuditSummaryV1, +} from '@databreeze/domain/audit/v1'; import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; function sameScope(left: TenantScopeV1, right: TenantScopeV1): boolean { @@ -54,3 +59,22 @@ export function sameAuditSealV1(left: AuditSealV1, right: AuditSealV1): boolean left.sealedAt === right.sealedAt ); } + +export function sameAuditSealAttestationV1( + left: AuditSealAttestationV1, + right: AuditSealAttestationV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.attestationId === right.attestationId && + sameScope(left.tenantScope, right.tenantScope) && + left.firstSequence === right.firstSequence && + left.lastSequence === right.lastSequence && + left.eventCount === right.eventCount && + left.rootDigest === right.rootDigest && + left.sealedAt === right.sealedAt && + left.signerKeyId === right.signerKeyId && + left.payload === right.payload && + left.signature === right.signature + ); +} diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts index 61b181f7..c749c3c4 100644 --- a/services/api/src/features/aud/application/audit-problem.error.ts +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -1,5 +1,12 @@ export class AuditProblemError extends Error { - public constructor(readonly code: 'AUDIT_UNAVAILABLE' | 'AUDIT_INTEGRITY_INVALID') { + public constructor( + readonly code: + | 'AUDIT_UNAVAILABLE' + | 'AUDIT_INTEGRITY_INVALID' + | 'AUDIT_ATTESTATION_NOT_FOUND' + | 'AUDIT_ATTESTATION_REQUEST_INVALID' + | 'AUDIT_ATTESTATION_UNAVAILABLE', + ) { super(code); this.name = 'AuditProblemError'; } diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index 2bb92343..9c7e7890 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -39,6 +39,7 @@ export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise>; + listSeals(context: IamTenantContextV1): Promise; withTransaction( context: IamTenantContextV1, work: (transaction: AuditTransactionPortV1) => Promise, diff --git a/services/api/src/features/aud/aud.module.ts b/services/api/src/features/aud/aud.module.ts index 906e60f1..488eda24 100644 --- a/services/api/src/features/aud/aud.module.ts +++ b/services/api/src/features/aud/aud.module.ts @@ -1,6 +1,23 @@ import { type DynamicModule, Module } from '@nestjs/common'; import { AuditLedgerService } from './application/audit-ledger.service.js'; +import { + AUDIT_ATTESTATION_SERVICE, + AuditAttestationService, + UnavailableAuditAttestationService, + type AuditAttestationIdGeneratorV1, + type AuditAttestationService as AuditAttestationServicePortV1, +} from './application/audit-attestation.service.js'; +import { + AUDIT_ATTESTATION_REPOSITORY_PORT, + type AuditAttestationRepositoryPortV1, +} from './application/audit-attestation-repository.port.js'; +import type { AuditSealAttestationSignerV1 } from '@databreeze/domain/audit/v1'; +import { InMemoryAuditAttestationRepositoryAdapter } from './adapter/in-memory-audit-attestation-repository.adapter.js'; +import { + PrismaAuditAttestationRepositoryAdapter, + type AuditAttestationDatabaseClientV1, +} from './adapter/prisma-audit-attestation-repository.adapter.js'; import { AUDIT_REPOSITORY_PORT, type AuditRepositoryPortV1, @@ -12,6 +29,7 @@ import { } from './adapter/prisma-audit-repository.adapter.js'; import { Sha256AuditDigestAdapter } from './adapter/sha256-audit-digest.adapter.js'; import { AuditController } from './api/audit.controller.js'; +import { AuditAttestationController } from './api/audit-attestation.controller.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -24,6 +42,13 @@ export interface AudModuleOptions { readonly auditRepository?: AuditRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly auditDatabase?: AuditDatabaseClientV1; + readonly auditAttestationRepository?: AuditAttestationRepositoryPortV1; + readonly auditAttestationDatabase?: AuditAttestationDatabaseClientV1; + readonly auditAttestationService?: + | AuditAttestationServicePortV1 + | UnavailableAuditAttestationService; + readonly auditAttestationSigner?: AuditSealAttestationSignerV1; + readonly auditAttestationIdGenerator?: AuditAttestationIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -37,18 +62,40 @@ export class AudModule { ? new InMemoryAuditRepositoryAdapter() : new PrismaAuditRepositoryAdapter(options.auditDatabase, digest)); const service = new AuditLedgerService(repository, digest); + const attestationRepository = + options.auditAttestationRepository ?? + (options.auditAttestationDatabase === undefined + ? new InMemoryAuditAttestationRepositoryAdapter() + : new PrismaAuditAttestationRepositoryAdapter(options.auditAttestationDatabase)); + const attestationService = + options.auditAttestationService ?? + (options.auditAttestationSigner === undefined + ? new UnavailableAuditAttestationService() + : new AuditAttestationService( + repository, + attestationRepository, + options.auditAttestationSigner, + options.auditAttestationIdGenerator, + )); return { module: AudModule, - controllers: [AuditController], + controllers: [AuditController, AuditAttestationController], providers: [ { provide: AUDIT_REPOSITORY_PORT, useValue: repository }, { provide: AUDIT_LEDGER_SERVICE, useValue: service }, + { provide: AUDIT_ATTESTATION_REPOSITORY_PORT, useValue: attestationRepository }, + { provide: AUDIT_ATTESTATION_SERVICE, useValue: attestationService }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [AUDIT_REPOSITORY_PORT, AUDIT_LEDGER_SERVICE], + exports: [ + AUDIT_REPOSITORY_PORT, + AUDIT_LEDGER_SERVICE, + AUDIT_ATTESTATION_REPOSITORY_PORT, + AUDIT_ATTESTATION_SERVICE, + ], }; } } diff --git a/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts b/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts new file mode 100644 index 00000000..0fbc617a --- /dev/null +++ b/services/api/src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.ts @@ -0,0 +1,34 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { EntitlementLeaseSignerV1 } from '../application/entitlement-lease.service.js'; + +const HMAC_ALGORITHM = 'sha256'; +const MINIMUM_KEY_BYTES = 32; + +/** Provider-neutral HMAC signer for short-lived entitlement leases. */ +export class HmacEntitlementLeaseSignerAdapter implements EntitlementLeaseSignerV1 { + private readonly key: Uint8Array; + + public constructor(key: Uint8Array | string) { + const normalized = typeof key === 'string' ? Buffer.from(key, 'utf8') : Buffer.from(key); + if (normalized.length < MINIMUM_KEY_BYTES) throw new Error('BUA_LEASE_SIGNING_KEY_TOO_SHORT'); + this.key = normalized; + } + + public sign(payload: string): string { + return createHmac(HMAC_ALGORITHM, this.key).update(payload, 'utf8').digest('base64url'); + } + + public verify(payload: string, signature: string): boolean { + if (signature.length === 0 || signature.length > 2048 || !/^[A-Za-z0-9_-]+$/u.test(signature)) + return false; + let presented: Buffer; + try { + presented = Buffer.from(signature, 'base64url'); + } catch { + return false; + } + const expected = createHmac(HMAC_ALGORITHM, this.key).update(payload, 'utf8').digest(); + return presented.length === expected.length && timingSafeEqual(presented, expected); + } +} diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts new file mode 100644 index 00000000..252553d1 --- /dev/null +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.ts @@ -0,0 +1,68 @@ +import { tenantScopeContainsV1, type EntitlementLeaseV1 } from '@databreeze/domain/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + EntitlementLeaseRepositoryPortV1, + EntitlementLeaseTransactionPortV1, +} from '../application/entitlement-lease-repository.port.js'; + +function leaseScope(lease: EntitlementLeaseV1) { + return lease.tenantScope; +} + +function clone(lease: EntitlementLeaseV1): EntitlementLeaseV1 { + return Object.freeze({ ...lease, tenantScope: Object.freeze({ ...lease.tenantScope }) }); +} + +/** BUA local adapter with immutable lease identity and tenant visibility checks. */ +export class InMemoryEntitlementLeaseRepositoryAdapter implements EntitlementLeaseRepositoryPortV1 { + private leases = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, leaseScope(lease))) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = this.leases.get(lease.leaseId); + if (existing && JSON.stringify(existing) !== JSON.stringify(lease)) + throw new Error('BUA_IMMUTABLE_LEASE'); + this.leases.set(lease.leaseId, clone(lease)); + } + + public async findLease( + context: IamTenantContextV1, + leaseId: EntitlementLeaseV1['leaseId'], + ): Promise { + await Promise.resolve(); + const lease = this.leases.get(leaseId); + return lease && + (tenantScopeContainsV1(context.tenantScope, leaseScope(lease)) || + tenantScopeContainsV1(leaseScope(lease), context.tenantScope)) + ? clone(lease) + : undefined; + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.leases); + try { + return await work({ + saveLease: this.saveLease.bind(this), + findLease: this.findLease.bind(this), + }); + } catch (error) { + this.leases = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts new file mode 100644 index 00000000..21825d74 --- /dev/null +++ b/services/api/src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.ts @@ -0,0 +1,191 @@ +import type { EntitlementLeaseV1 } from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopeKeyV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EntitlementDatabaseClientV1 } from './prisma-entitlement-repository.adapter.js'; +import type { + EntitlementLeaseRepositoryPortV1, + EntitlementLeaseTransactionPortV1, +} from '../application/entitlement-lease-repository.port.js'; + +export interface EntitlementLeaseDatabaseRowV1 { + readonly id: string; + readonly schemaVersion: number; + readonly scopeKey: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly snapshotRevision: number; + readonly securityEpoch: number; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly payload: string; + readonly signature: string; + readonly createdAt: Date; +} + +export interface EntitlementLeaseDatabaseCreateDataV1 + extends Omit { + readonly createdAt: Date; +} + +interface EntitlementLeaseDelegateV1 { + create(input: { + readonly data: EntitlementLeaseDatabaseCreateDataV1; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; +} + +export interface EntitlementLeaseDatabaseClientV1 extends EntitlementDatabaseClientV1 { + readonly entitlementLeaseRecord: EntitlementLeaseDelegateV1; +} + +function leaseScope(lease: EntitlementLeaseV1): TenantScopeV1 { + return lease.tenantScope; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + const organizationId = context.tenantScope.organizationId; + if (context.tenantScope.scopeType === 'organization') return { organizationId }; + return { + organizationId, + OR: [{ workspaceId: null }, { workspaceId: context.tenantScope.workspaceId }], + }; +} + +function persistedScope(row: EntitlementLeaseDatabaseRowV1): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + }); + if (!parsed.accepted || parsed.value.scopeType === 'project') + throw new Error('BUA_PERSISTED_LEASE_INVALID'); + return parsed.value; +} + +function persistedLease(row: EntitlementLeaseDatabaseRowV1): EntitlementLeaseV1 { + const id = parseStableIdentifierV1(row.id); + const scope = persistedScope(row); + const issuedAt = parseStrictUtcTimestampV1(row.issuedAt.toISOString()); + const expiresAt = parseStrictUtcTimestampV1(row.expiresAt.toISOString()); + if ( + !id.accepted || + !issuedAt.accepted || + !expiresAt.accepted || + row.schemaVersion !== 1 || + !Number.isSafeInteger(row.snapshotRevision) || + row.snapshotRevision < 1 || + !Number.isSafeInteger(row.securityEpoch) || + row.securityEpoch < 1 || + typeof row.payload !== 'string' || + row.payload.length === 0 || + row.payload.length > 10000 || + typeof row.signature !== 'string' || + row.signature.length === 0 || + row.signature.length > 2048 + ) + throw new Error('BUA_PERSISTED_LEASE_INVALID'); + return Object.freeze({ + schemaVersion: 1, + leaseId: id.value, + tenantScope: scope, + snapshotRevision: row.snapshotRevision, + securityEpoch: row.securityEpoch, + issuedAt: issuedAt.value, + expiresAt: expiresAt.value, + payload: row.payload, + signature: row.signature, + }); +} + +function leaseData(lease: EntitlementLeaseV1): EntitlementLeaseDatabaseCreateDataV1 { + return { + id: lease.leaseId, + schemaVersion: lease.schemaVersion, + scopeKey: tenantScopeKeyV1(lease.tenantScope), + scopeType: lease.tenantScope.scopeType, + organizationId: lease.tenantScope.organizationId, + workspaceId: + lease.tenantScope.scopeType === 'organization' ? null : lease.tenantScope.workspaceId, + snapshotRevision: lease.snapshotRevision, + securityEpoch: lease.securityEpoch, + issuedAt: new Date(lease.issuedAt), + expiresAt: new Date(lease.expiresAt), + payload: lease.payload, + signature: lease.signature, + createdAt: new Date(lease.issuedAt), + }; +} + +function isUniqueConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaEntitlementLeaseTransactionAdapter implements EntitlementLeaseTransactionPortV1 { + public constructor(private readonly client: EntitlementLeaseDatabaseClientV1) {} + + public async saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, leaseScope(lease))) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.entitlementLeaseRecord.findFirst({ + where: { id: lease.leaseId }, + }); + if (existing) { + if (JSON.stringify(persistedLease(existing)) !== JSON.stringify(lease)) + throw new Error('BUA_IMMUTABLE_LEASE'); + return; + } + try { + await this.client.entitlementLeaseRecord.create({ data: leaseData(lease) }); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('BUA_LEASE_CONFLICT'); + throw error; + } + } + + public async findLease( + context: IamTenantContextV1, + leaseId: StableIdentifierV1, + ): Promise { + const row = await this.client.entitlementLeaseRecord.findFirst({ + where: { id: leaseId, ...scopeWhere(context) }, + }); + return row ? persistedLease(row) : undefined; + } +} + +export class PrismaEntitlementLeaseRepositoryAdapter implements EntitlementLeaseRepositoryPortV1 { + public constructor(private readonly client: EntitlementLeaseDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work( + new PrismaEntitlementLeaseTransactionAdapter( + transaction as EntitlementLeaseDatabaseClientV1, + ), + ), + ); + } + + public saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise { + return new PrismaEntitlementLeaseTransactionAdapter(this.client).saveLease(context, lease); + } + + public findLease(context: IamTenantContextV1, leaseId: StableIdentifierV1) { + return new PrismaEntitlementLeaseTransactionAdapter(this.client).findLease(context, leaseId); + } +} diff --git a/services/api/src/features/bua/api/entitlement-lease.dto.ts b/services/api/src/features/bua/api/entitlement-lease.dto.ts new file mode 100644 index 00000000..917036c8 --- /dev/null +++ b/services/api/src/features/bua/api/entitlement-lease.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class IssueEntitlementLeaseDto { + @ApiProperty({ format: 'date-time', description: 'UTC expiry no more than 24 hours after issue' }) + @IsISO8601() + expiresAt!: string; +} + +export class VerifyEntitlementLeaseDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + snapshotRevision!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + securityEpoch!: number; + + @ApiPropertyOptional({ + format: 'date-time', + description: 'Verification time; server clock is used when omitted', + }) + @IsOptional() + @IsISO8601() + now?: string; +} diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 82ee4876..217f69fe 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, Inject, Param, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, + ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -20,6 +21,12 @@ import { type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; +import { + ENTITLEMENT_LEASE_SERVICE, + type EntitlementLeaseApplicationResultV1, + type EntitlementLeaseService, +} from '../application/entitlement-lease.service.js'; +import { IssueEntitlementLeaseDto, VerifyEntitlementLeaseDto } from './entitlement-lease.dto.js'; const ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA = { type: 'object', @@ -45,8 +52,29 @@ export class EntitlementController { private readonly repository: EntitlementRepositoryPortV1, @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + @Inject(ENTITLEMENT_LEASE_SERVICE) + private readonly leases: EntitlementLeaseService, ) {} + private async executeLease( + work: () => Promise>, + ): Promise { + let result: EntitlementLeaseApplicationResultV1; + try { + result = await work(); + } catch { + throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'ENTITLEMENT_NOT_FOUND') + throw new EntitlementProblemError('ENTITLEMENT_NOT_FOUND'); + if (result.code === 'LEASE_INVALID') + throw new EntitlementProblemError('ENTITLEMENT_LEASE_INVALID'); + if (result.code === 'LEASE_STALE') throw new EntitlementProblemError('ENTITLEMENT_LEASE_STALE'); + if (result.code === 'UNAVAILABLE') throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); + throw new EntitlementProblemError('ENTITLEMENT_REQUEST_INVALID'); + } + @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) @ApiOkResponse({ schema: ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA }) @@ -82,4 +110,48 @@ export class EntitlementController { throw new EntitlementProblemError('ENTITLEMENT_UNAVAILABLE'); } } + + @Post('snapshots/:snapshotId/leases') + @ApiOperation({ summary: 'Issue a signed, bounded offline entitlement lease' }) + @ApiCreatedResponse({ schema: { type: 'object', additionalProperties: true } }) + @ApiBadRequestResponse({ description: 'The snapshot or expiry is invalid.' }) + @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Lease signing or persistence is unavailable.' }) + async issueLease( + @Req() request: unknown, + @Param('snapshotId') snapshotId: string, + @Body() input: IssueEntitlementLeaseDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.executeLease(() => + this.leases.issue(context, { snapshotId, expiresAt: input.expiresAt }), + ); + } + + @Get('leases/:leaseId/verify') + @ApiOperation({ + summary: 'Verify an offline entitlement lease against the current revision and epoch', + }) + @ApiOkResponse({ + schema: { type: 'object', required: ['valid'], properties: { valid: { type: 'boolean' } } }, + }) + @ApiBadRequestResponse({ description: 'The lease verification input is invalid or stale.' }) + @ApiNotFoundResponse({ description: 'The lease is not visible.' }) + @ApiServiceUnavailableResponse({ description: 'Lease verification is unavailable.' }) + async verifyLease( + @Req() request: unknown, + @Param('leaseId') leaseId: string, + @Query() input: VerifyEntitlementLeaseDto, + ): Promise<{ readonly valid: true }> { + const context = await this.requestContext.resolve(request); + await this.executeLease(() => + this.leases.verify(context, { + leaseId, + now: input.now, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + }), + ); + return Object.freeze({ valid: true }); + } } diff --git a/services/api/src/features/bua/application/entitlement-lease-repository.port.ts b/services/api/src/features/bua/application/entitlement-lease-repository.port.ts new file mode 100644 index 00000000..2c55043d --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-lease-repository.port.ts @@ -0,0 +1,21 @@ +import type { EntitlementLeaseV1 } from '@databreeze/domain/entitlements/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const ENTITLEMENT_LEASE_REPOSITORY_PORT = Symbol('ENTITLEMENT_LEASE_REPOSITORY_PORT'); + +export interface EntitlementLeaseTransactionPortV1 { + saveLease(context: IamTenantContextV1, lease: EntitlementLeaseV1): Promise; + findLease( + context: IamTenantContextV1, + leaseId: StableIdentifierV1, + ): Promise; +} + +export interface EntitlementLeaseRepositoryPortV1 extends EntitlementLeaseTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: EntitlementLeaseTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/bua/application/entitlement-lease.service.ts b/services/api/src/features/bua/application/entitlement-lease.service.ts new file mode 100644 index 00000000..a0691137 --- /dev/null +++ b/services/api/src/features/bua/application/entitlement-lease.service.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto'; + +import { + acceptEntitlementLeaseV1, + createEntitlementLeaseV1, + type EntitlementErrorCodeV1, + type EntitlementLeaseV1, + type EntitlementResultV1, + type LeaseSignatureIssuerV1, + type LeaseSignatureVerifierV1, +} from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { EntitlementLeaseRepositoryPortV1 } from './entitlement-lease-repository.port.js'; +import type { EntitlementRepositoryPortV1 } from './entitlement-repository.port.js'; + +export const ENTITLEMENT_LEASE_SERVICE = Symbol('ENTITLEMENT_LEASE_SERVICE'); + +export type EntitlementLeaseClockV1 = () => Date; +export type EntitlementLeaseIdGeneratorV1 = () => string; + +export interface EntitlementLeaseSignerV1 + extends LeaseSignatureIssuerV1, + LeaseSignatureVerifierV1 {} + +export type EntitlementLeaseApplicationCodeV1 = EntitlementErrorCodeV1 | 'UNAVAILABLE'; + +export type EntitlementLeaseApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: EntitlementLeaseApplicationCodeV1 }; + +export interface IssueEntitlementLeaseInputV1 { + readonly snapshotId: unknown; + readonly expiresAt: unknown; +} + +export interface VerifyEntitlementLeaseInputV1 { + readonly leaseId: unknown; + readonly now?: unknown; + readonly snapshotRevision: unknown; + readonly securityEpoch: unknown; +} + +function rejected( + code: EntitlementLeaseApplicationCodeV1, +): EntitlementLeaseApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stableId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function clockTimestamp(clock: EntitlementLeaseClockV1): StrictUtcTimestampV1 | undefined { + try { + return timestamp(clock().toISOString()); + } catch { + return undefined; + } +} + +function applicationResult( + result: EntitlementResultV1, +): EntitlementLeaseApplicationResultV1 { + return result.accepted ? result : rejected(result.code); +} + +/** Coordinates immutable entitlement snapshots and signed offline lease persistence. */ +export class EntitlementLeaseService { + public constructor( + private readonly leaseRepository: EntitlementLeaseRepositoryPortV1, + private readonly entitlementRepository: EntitlementRepositoryPortV1, + private readonly signer: EntitlementLeaseSignerV1, + private readonly clock: EntitlementLeaseClockV1 = () => new Date(), + private readonly idGenerator: EntitlementLeaseIdGeneratorV1 = () => randomUUID(), + ) {} + + public async issue( + context: IamTenantContextV1, + input: IssueEntitlementLeaseInputV1, + ): Promise> { + const snapshotId = stableId(input.snapshotId); + const leaseId = stableId(this.idGenerator()); + const issuedAt = clockTimestamp(this.clock); + if (!snapshotId || !leaseId) return rejected('INVALID_IDENTIFIER'); + if (!issuedAt) return rejected('INVALID_TIMESTAMP'); + + const snapshot = await this.entitlementRepository.findSnapshot(context, snapshotId); + if (!snapshot) return rejected('ENTITLEMENT_NOT_FOUND'); + const issued = createEntitlementLeaseV1( + snapshot, + { leaseId, issuedAt, expiresAt: input.expiresAt }, + this.signer, + ); + if (!issued.accepted) return applicationResult(issued); + await this.leaseRepository.withTransaction(context, async (transaction) => { + await transaction.saveLease(context, issued.value); + }); + return issued; + } + + public async verify( + context: IamTenantContextV1, + input: VerifyEntitlementLeaseInputV1, + ): Promise> { + const leaseId = stableId(input.leaseId); + if (!leaseId) return rejected('INVALID_IDENTIFIER'); + const now = input.now === undefined ? clockTimestamp(this.clock) : timestamp(input.now); + if (!now) return rejected('INVALID_TIMESTAMP'); + const lease = await this.leaseRepository.findLease(context, leaseId); + if (!lease) return rejected('ENTITLEMENT_NOT_FOUND'); + return applicationResult( + acceptEntitlementLeaseV1( + lease, + { + now, + tenantScope: context.tenantScope, + snapshotRevision: input.snapshotRevision, + securityEpoch: input.securityEpoch, + }, + this.signer, + ), + ); + } +} + +/** Safe composition default when key material or persistence is not configured. */ +export class UnavailableEntitlementLeaseService { + public issue( + context: IamTenantContextV1, + input: IssueEntitlementLeaseInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } + + public verify( + context: IamTenantContextV1, + input: VerifyEntitlementLeaseInputV1, + ): Promise> { + void context; + void input; + return Promise.resolve(rejected('UNAVAILABLE')); + } +} diff --git a/services/api/src/features/bua/application/entitlement-problem.error.ts b/services/api/src/features/bua/application/entitlement-problem.error.ts index 0d58eb00..8a17d7eb 100644 --- a/services/api/src/features/bua/application/entitlement-problem.error.ts +++ b/services/api/src/features/bua/application/entitlement-problem.error.ts @@ -1,6 +1,8 @@ export type EntitlementProblemCodeV1 = | 'ENTITLEMENT_NOT_FOUND' | 'ENTITLEMENT_REQUEST_INVALID' + | 'ENTITLEMENT_LEASE_INVALID' + | 'ENTITLEMENT_LEASE_STALE' | 'ENTITLEMENT_UNAVAILABLE'; export class EntitlementProblemError extends Error { diff --git a/services/api/src/features/bua/bua.module.ts b/services/api/src/features/bua/bua.module.ts index 5a956f6b..fc72f9d3 100644 --- a/services/api/src/features/bua/bua.module.ts +++ b/services/api/src/features/bua/bua.module.ts @@ -6,6 +6,25 @@ import { type EntitlementDatabaseClientV1, } from './adapter/prisma-entitlement-repository.adapter.js'; import { EntitlementAdmissionService } from './application/entitlement-admission.service.js'; +import { + ENTITLEMENT_LEASE_SERVICE, + EntitlementLeaseService, + UnavailableEntitlementLeaseService, + type EntitlementLeaseClockV1, + type EntitlementLeaseIdGeneratorV1, + type EntitlementLeaseService as EntitlementLeaseServicePortV1, + type EntitlementLeaseSignerV1, +} from './application/entitlement-lease.service.js'; +import { + ENTITLEMENT_LEASE_REPOSITORY_PORT, + type EntitlementLeaseRepositoryPortV1, +} from './application/entitlement-lease-repository.port.js'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from './adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { + PrismaEntitlementLeaseRepositoryAdapter, + type EntitlementLeaseDatabaseClientV1, +} from './adapter/prisma-entitlement-lease-repository.adapter.js'; +import { HmacEntitlementLeaseSignerAdapter } from './adapter/hmac-entitlement-lease-signer.adapter.js'; import { ENTITLEMENT_REPOSITORY_PORT, type EntitlementRepositoryPortV1, @@ -23,6 +42,16 @@ export interface BuaModuleOptions { readonly entitlementRepository?: EntitlementRepositoryPortV1; /** Production composition passes the generated Prisma client; tests may keep the port in-memory. */ readonly entitlementDatabase?: EntitlementDatabaseClientV1; + readonly entitlementLeaseRepository?: EntitlementLeaseRepositoryPortV1; + readonly entitlementLeaseDatabase?: EntitlementLeaseDatabaseClientV1; + readonly entitlementLeaseService?: + | EntitlementLeaseServicePortV1 + | UnavailableEntitlementLeaseService; + readonly entitlementLeaseSigner?: EntitlementLeaseSignerV1; + /** Secret-manager supplied key; direct signer injection remains available for HSM/KMS adapters. */ + readonly entitlementLeaseSigningKey?: Uint8Array | string; + readonly entitlementLeaseClock?: EntitlementLeaseClockV1; + readonly entitlementLeaseIdGenerator?: EntitlementLeaseIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -35,18 +64,46 @@ export class BuaModule { ? new InMemoryEntitlementRepositoryAdapter() : new PrismaEntitlementRepositoryAdapter(options.entitlementDatabase)); const service = new EntitlementAdmissionService(repository); + const leaseRepository = + options.entitlementLeaseRepository ?? + (options.entitlementLeaseDatabase === undefined + ? new InMemoryEntitlementLeaseRepositoryAdapter() + : new PrismaEntitlementLeaseRepositoryAdapter(options.entitlementLeaseDatabase)); + const leaseSigner = + options.entitlementLeaseSigner ?? + (options.entitlementLeaseSigningKey === undefined + ? undefined + : new HmacEntitlementLeaseSignerAdapter(options.entitlementLeaseSigningKey)); + const leaseService = + options.entitlementLeaseService ?? + (leaseSigner === undefined + ? new UnavailableEntitlementLeaseService() + : new EntitlementLeaseService( + leaseRepository, + repository, + leaseSigner, + options.entitlementLeaseClock, + options.entitlementLeaseIdGenerator, + )); return { module: BuaModule, controllers: [EntitlementController], providers: [ { provide: ENTITLEMENT_REPOSITORY_PORT, useValue: repository }, { provide: ENTITLEMENT_ADMISSION_SERVICE, useValue: service }, + { provide: ENTITLEMENT_LEASE_REPOSITORY_PORT, useValue: leaseRepository }, + { provide: ENTITLEMENT_LEASE_SERVICE, useValue: leaseService }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), }, ], - exports: [ENTITLEMENT_REPOSITORY_PORT, ENTITLEMENT_ADMISSION_SERVICE], + exports: [ + ENTITLEMENT_REPOSITORY_PORT, + ENTITLEMENT_ADMISSION_SERVICE, + ENTITLEMENT_LEASE_REPOSITORY_PORT, + ENTITLEMENT_LEASE_SERVICE, + ], }; } } diff --git a/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts b/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts new file mode 100644 index 00000000..1b05f471 --- /dev/null +++ b/services/api/src/features/iam/adapter/iam-invitation-crypto.adapter.ts @@ -0,0 +1,46 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; + +import type { IamInvitationDigestPortV1 } from '../application/invitation.service.js'; + +export type IamInvitationDigestKeyV1 = string | Uint8Array; + +function validKey(key: IamInvitationDigestKeyV1): boolean { + return ( + (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + ); +} + +function boundedInput(value: string): string { + if (value.length === 0 || value.length > 4096 || /\p{Cc}/u.test(value)) + throw new Error('IAM_INVITATION_INPUT_INVALID'); + return value.normalize('NFC'); +} + +/** HMAC keeps invitation digests keyed while domain-separating bearer and recipient material. */ +export class HmacSha256IamInvitationDigestAdapter implements IamInvitationDigestPortV1 { + public constructor(private readonly key: IamInvitationDigestKeyV1) { + if (!validKey(key)) throw new Error('IAM_INVITATION_KEY_INVALID'); + } + + private digest(domain: 'token' | 'email', value: string): string { + return createHmac('sha256', this.key) + .update(`databreeze:iam:invitation:${domain}:v1\u0000${boundedInput(value)}`, 'utf8') + .digest('hex'); + } + + public digestToken(rawToken: string): string { + return this.digest('token', rawToken); + } + + public digestEmail(normalizedEmail: string): string { + return this.digest('email', normalizedEmail); + } +} + +export function randomIamInvitationIdV1(): string { + return randomUUID(); +} + +export function randomIamInvitationTokenV1(): string { + return randomBytes(32).toString('base64url'); +} diff --git a/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts b/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts new file mode 100644 index 00000000..6d9a3227 --- /dev/null +++ b/services/api/src/features/iam/adapter/iam-recovery-crypto.adapter.ts @@ -0,0 +1,50 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; + +import type { RecoveryDigestPortV1 } from '../application/recovery-repository.port.js'; +import type { + RecoveryIdGeneratorV1, + RecoveryTokenGeneratorV1, +} from '../application/recovery.service.js'; + +export type IamRecoveryDigestKeyV1 = string | Uint8Array; + +function validKey(key: IamRecoveryDigestKeyV1): boolean { + return ( + (typeof key === 'string' && key.length > 0) || (key instanceof Uint8Array && key.length > 0) + ); +} + +function boundedInput(value: string): string { + if (value.length === 0 || value.length > 4096 || /\p{Cc}/u.test(value)) + throw new Error('IAM_RECOVERY_INPUT_INVALID'); + return value.normalize('NFC'); +} + +/** HMAC keeps recovery digests keyed and separate from invitation/session bearer digests. */ +export class HmacSha256IamRecoveryDigestAdapter implements RecoveryDigestPortV1 { + public constructor(private readonly key: IamRecoveryDigestKeyV1) { + if (!validKey(key)) throw new Error('IAM_RECOVERY_KEY_INVALID'); + } + + private digest(domain: 'token' | 'email', value: string): string { + return createHmac('sha256', this.key) + .update(`databreeze:iam:recovery:${domain}:v1\u0000${boundedInput(value)}`, 'utf8') + .digest('hex'); + } + + public digestToken(rawToken: string): string { + return this.digest('token', rawToken); + } + + public digestEmail(normalizedEmail: string): string { + return this.digest('email', normalizedEmail); + } +} + +export const randomIamRecoveryIdV1: RecoveryIdGeneratorV1 = Object.freeze({ + next: () => randomUUID(), +}); + +export const randomIamRecoveryTokenV1: RecoveryTokenGeneratorV1 = Object.freeze({ + next: () => randomBytes(32).toString('base64url'), +}); diff --git a/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts new file mode 100644 index 00000000..545d4242 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.ts @@ -0,0 +1,187 @@ +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; + +import type { IamMembershipRecordV1 } from '../application/iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../application/invitation-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; + +function visible(context: TenantScopeV1, target: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, target) || tenantScopeContainsV1(target, context); +} + +function cloneMembership(record: IamMembershipRecordV1): IamMembershipRecordV1 { + return Object.freeze({ ...record, scope: Object.freeze({ ...record.scope }) }); +} + +function cloneInvitation(record: InvitationTokenV1): InvitationTokenV1 { + return Object.freeze({ ...record, scope: Object.freeze({ ...record.scope }) }); +} + +function sameInvitationIdentity(left: InvitationTokenV1, right: InvitationTokenV1): boolean { + return ( + left.membershipId === right.membershipId && + left.principalId === right.principalId && + tenantScopesEqualV1(left.scope, right.scope) && + left.roleId === right.roleId && + left.tokenDigest === right.tokenDigest && + left.emailDigest === right.emailDigest && + left.issuedAt === right.issuedAt && + left.expiresAt === right.expiresAt + ); +} + +/** Test/local adapter that mirrors the scoped and compare-and-set rules of PostgreSQL. */ +export class InMemoryIamInvitationRepositoryAdapter implements IamInvitationRepositoryPortV1 { + private memberships: IamMembershipRecordV1[]; + private invitations: InvitationTokenV1[] = []; + private transactionTail: Promise = Promise.resolve(); + + public constructor(memberships: readonly IamMembershipRecordV1[] = []) { + this.memberships = memberships.map(cloneMembership); + } + + public seedMemberships(memberships: readonly IamMembershipRecordV1[]): void { + this.memberships = memberships.map(cloneMembership); + } + + public async withTransaction( + _context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const prior = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await prior; + const membershipsBefore = this.memberships.map(cloneMembership); + const invitationsBefore = this.invitations.map(cloneInvitation); + try { + return await work({ + findMembershipForPrincipal: this.findMembershipForPrincipal.bind(this), + findMembershipById: this.findMembershipById.bind(this), + findInvitationByDigest: this.findInvitationByDigest.bind(this), + findActiveInvitationForMembership: this.findActiveInvitationForMembership.bind(this), + saveInvitation: this.saveInvitation.bind(this), + saveMembership: this.saveMembership.bind(this), + }); + } catch (error) { + this.memberships = membershipsBefore; + this.invitations = invitationsBefore; + throw error; + } finally { + release(); + } + } + + private async findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const visibleMemberships = this.memberships.filter((membership) => + visible(context.tenantScope, membership.scope), + ); + return selectAuthoritativeMembership(visibleMemberships, context, principalId); + } + + private async findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const membership = this.memberships.find((item) => item.id === membershipId); + return membership && visible(context.tenantScope, membership.scope) + ? cloneMembership(membership) + : undefined; + } + + private async findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise { + await Promise.resolve(); + const invitation = this.invitations.find( + (item) => + item.tokenDigest === tokenDigest && tenantScopeContainsV1(context.tenantScope, item.scope), + ); + return invitation ? cloneInvitation(invitation) : undefined; + } + + private async findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const invitation = this.invitations.find( + (item) => + item.membershipId === membershipId && + item.status === 'ACTIVE' && + tenantScopeContainsV1(context.tenantScope, item.scope), + ); + return invitation ? cloneInvitation(invitation) : undefined; + } + + private async saveInvitation( + context: IamTenantContextV1, + invitation: InvitationTokenV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, invitation.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const existing = this.invitations.find((item) => item.id === invitation.id); + if (!existing) { + if (this.invitations.some((item) => item.tokenDigest === invitation.tokenDigest)) + throw new Error('IAM_INVITATION_CONFLICT'); + if ( + this.invitations.some( + (item) => item.membershipId === invitation.membershipId && item.status === 'ACTIVE', + ) + ) + throw new Error('IAM_INVITATION_CONFLICT'); + this.invitations.push(cloneInvitation(invitation)); + return; + } + if (!sameInvitationIdentity(existing, invitation)) + throw new Error('IAM_INVITATION_SCOPE_IMMUTABLE'); + if (invitation.revision !== existing.revision + 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + if (existing.status !== 'ACTIVE' || invitation.status === 'ACTIVE') + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + this.invitations = this.invitations.map((item) => + item.id === invitation.id ? cloneInvitation(invitation) : item, + ); + } + + private async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const index = this.memberships.findIndex((item) => item.id === membership.id); + if (index < 0) throw new Error('IAM_REVISION_CONFLICT'); + const existing = this.memberships[index]; + if (!existing) throw new Error('IAM_REVISION_CONFLICT'); + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) || + existing.roleId !== membership.roleId + ) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + this.memberships = this.memberships.map((item, itemIndex) => + itemIndex === index ? cloneMembership(membership) : item, + ); + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts index d67720e3..0fe9f705 100644 --- a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts @@ -63,8 +63,17 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { /** In-memory MFA state adapter; secrets remain opaque references and codes remain digests. */ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { private states = new Map(); + private recoveryReenrollment = new Map(); private transactionTail: Promise = Promise.resolve(); + public setRecoveryReenrollmentRequired(userId: StableIdentifierV1, required = true): void { + this.recoveryReenrollment.set(userId, required); + } + + public isRecoveryReenrollmentRequired(userId: StableIdentifierV1): boolean { + return this.recoveryReenrollment.get(userId) === true; + } + public async findState(userId: StableIdentifierV1): Promise { await Promise.resolve(); return cloneState(this.states.get(userId) ?? { factors: [], recoveryCodes: [] }); @@ -82,6 +91,13 @@ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { this.states.set(userId, cloneState(state)); } + public async clearRecoveryReenrollment(userId: StableIdentifierV1): Promise { + await Promise.resolve(); + if (this.recoveryReenrollment.get(userId) !== true) return false; + this.recoveryReenrollment.set(userId, false); + return true; + } + public async withTransaction( work: (transaction: MfaTransactionPortV1) => Promise, ): Promise { @@ -92,13 +108,16 @@ export class InMemoryMfaRepositoryAdapter implements MfaRepositoryPortV1 { }); await previous; const before = new Map(this.states); + const beforeReenrollment = new Map(this.recoveryReenrollment); try { return await work({ findState: this.findState.bind(this), saveState: this.saveState.bind(this), + clearRecoveryReenrollment: this.clearRecoveryReenrollment.bind(this), }); } catch (error) { this.states = before; + this.recoveryReenrollment = beforeReenrollment; throw error; } finally { release(); diff --git a/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts new file mode 100644 index 00000000..c1113772 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-recovery-admission.adapter.ts @@ -0,0 +1,37 @@ +import type { RecoveryAdmissionPortV1 } from '../application/recovery-repository.port.js'; + +export interface InMemoryRecoveryAdmissionOptionsV1 { + readonly maxAttempts?: number; + readonly windowSeconds?: number; +} + +/** Deterministic bounded admission store for alpha/tests; production supplies a shared rate-limit adapter. */ +export class InMemoryRecoveryAdmissionAdapter implements RecoveryAdmissionPortV1 { + private readonly maxAttempts: number; + private readonly windowMs: number; + private readonly attempts = new Map(); + + public constructor(options: InMemoryRecoveryAdmissionOptionsV1 = {}) { + this.maxAttempts = options.maxAttempts ?? 3; + this.windowMs = (options.windowSeconds ?? 15 * 60) * 1_000; + if (!Number.isSafeInteger(this.maxAttempts) || this.maxAttempts < 1 || this.maxAttempts > 100) + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + if (!Number.isSafeInteger(this.windowMs) || this.windowMs < 1_000 || this.windowMs > 86_400_000) + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + } + + public async allow(keyDigest: string, issuedAt: string): Promise { + await Promise.resolve(); + const at = Date.parse(issuedAt); + if (!/^[a-f0-9]{64}$/u.test(keyDigest) || !Number.isFinite(at)) return false; + const current = this.attempts.get(keyDigest) ?? []; + const kept = current.filter((timestamp) => at - timestamp < this.windowMs); + if (kept.length >= this.maxAttempts) { + this.attempts.set(keyDigest, kept); + return false; + } + kept.push(at); + this.attempts.set(keyDigest, kept); + return true; + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts new file mode 100644 index 00000000..9f8e978b --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-recovery-repository.adapter.ts @@ -0,0 +1,122 @@ +import type { RecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import type { + RecoveryCompletionInputV1, + RecoveryRepositoryPortV1, + RecoveryTransactionPortV1, +} from '../application/recovery-repository.port.js'; + +interface RecoveryAccountV1 { + readonly email: string; + readonly userId: string; + credentialId?: string; + credentialHash?: string; + securityEpoch: number; + mfaReenrollmentRequired: boolean; + activeSessionFamilies: Set; +} + +function cloneChallenge(value: RecoveryChallengeV1): RecoveryChallengeV1 { + return Object.freeze({ ...value }); +} + +/** In-memory recovery adapter that models credential, epoch, MFA, and session-family effects. */ +export class InMemoryRecoveryRepositoryAdapter implements RecoveryRepositoryPortV1 { + private accounts = new Map(); + private challenges = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public seed(input: { + readonly email: string; + readonly userId: string; + readonly securityEpoch?: number; + readonly activeSessionFamilies?: readonly string[]; + }): void { + this.accounts.set(input.email, { + email: input.email, + userId: input.userId, + securityEpoch: input.securityEpoch ?? 1, + mfaReenrollmentRequired: false, + activeSessionFamilies: new Set(input.activeSessionFamilies ?? []), + }); + } + + public account(userId: string): Readonly | undefined { + const account = [...this.accounts.values()].find((candidate) => candidate.userId === userId); + return account + ? Object.freeze({ ...account, activeSessionFamilies: new Set(account.activeSessionFamilies) }) + : undefined; + } + + public challenge(tokenDigest: string): RecoveryChallengeV1 | undefined { + const challenge = this.challenges.get(tokenDigest); + return challenge ? cloneChallenge(challenge) : undefined; + } + + public async withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const beforeAccounts = new Map( + [...this.accounts].map(([key, value]) => [ + key, + { ...value, activeSessionFamilies: new Set(value.activeSessionFamilies) }, + ]), + ); + const beforeChallenges = new Map(this.challenges); + const transaction: RecoveryTransactionPortV1 = { + findUserIdByEmail: async (email) => { + await Promise.resolve(); + return this.accounts.get(email)?.userId as StableIdentifierV1 | undefined; + }, + findChallengeByTokenDigest: async (tokenDigest) => { + await Promise.resolve(); + const challenge = this.challenges.get(tokenDigest); + return challenge ? cloneChallenge(challenge) : undefined; + }, + findActiveChallengeForUser: async (userId) => { + await Promise.resolve(); + return [...this.challenges.values()].find( + (challenge) => challenge.userId === userId && challenge.status === 'ACTIVE', + ); + }, + saveChallenge: async (challenge) => { + await Promise.resolve(); + const existing = this.challenges.get(challenge.tokenDigest); + if (existing && existing.revision + 1 !== challenge.revision) + throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + this.challenges.set(challenge.tokenDigest, cloneChallenge(challenge)); + }, + completeRecovery: async (input: RecoveryCompletionInputV1) => { + await Promise.resolve(); + const account = [...this.accounts.values()].find( + (candidate) => candidate.userId === input.challenge.userId, + ); + if (!account) throw new Error('IAM_RECOVERY_USER_NOT_FOUND'); + this.accounts.set(account.email, { + ...account, + credentialId: input.credentialId, + credentialHash: input.credential.encodedHash, + securityEpoch: account.securityEpoch + 1, + mfaReenrollmentRequired: true, + activeSessionFamilies: new Set(), + }); + this.challenges.set(input.challenge.tokenDigest, cloneChallenge(input.challenge)); + }, + }; + try { + return await work(transaction); + } catch (error) { + this.accounts = beforeAccounts; + this.challenges = beforeChallenges; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts new file mode 100644 index 00000000..4f012304 --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-registration-repository.adapter.ts @@ -0,0 +1,56 @@ +import type { + RegistrationPersistenceInputV1, + RegistrationRepositoryPortV1, + RegistrationTransactionPortV1, +} from '../application/registration-repository.port.js'; +import { RegistrationConflictError } from '../application/registration-repository.port.js'; + +function clone(value: TValue): TValue { + return structuredClone(value); +} + +/** Deterministic transactional registration store for tests and private-alpha composition. */ +export class InMemoryRegistrationRepositoryAdapter implements RegistrationRepositoryPortV1 { + private records = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.records); + const transaction: RegistrationTransactionPortV1 = { + findByEmail: async (email) => { + await Promise.resolve(); + return this.records.has(email); + }, + save: async (input) => { + await Promise.resolve(); + if (this.records.has(input.email)) throw new RegistrationConflictError(); + this.records.set(input.email, clone(input)); + }, + }; + try { + return await work(transaction); + } catch (error) { + this.records = before; + throw error; + } finally { + release(); + } + } + + public has(email: string): boolean { + return this.records.has(email); + } + + public get(email: string): RegistrationPersistenceInputV1 | undefined { + const value = this.records.get(email); + return value ? clone(value) : undefined; + } +} diff --git a/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts new file mode 100644 index 00000000..55bd76eb --- /dev/null +++ b/services/api/src/features/iam/adapter/in-memory-service-account-repository.adapter.ts @@ -0,0 +1,134 @@ +import { tenantScopeContainsV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import type { + ServiceAccountRepositoryPortV1, + ServiceAccountTransactionPortV1, +} from '../application/service-account-repository.port.js'; + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +function visibleInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + const scope = accountScope(account); + return ( + tenantScopeContainsV1(context.tenantScope, scope) || + tenantScopeContainsV1(scope, context.tenantScope) + ); +} + +function writableInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + return tenantScopeContainsV1(context.tenantScope, accountScope(account)); +} + +function clone(account: ServiceAccountV1): ServiceAccountV1 { + return Object.freeze({ ...account, permissions: Object.freeze([...account.permissions]) }); +} + +/** Deterministic local adapter with the same visibility and optimistic-write rules as PostgreSQL. */ +export class InMemoryServiceAccountRepositoryAdapter implements ServiceAccountRepositoryPortV1 { + private accounts = new Map(); + private transactionTail: Promise = Promise.resolve(); + + public async findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: ServiceAccountV1['id'], + ): Promise { + await Promise.resolve(); + const account = this.accounts.get(serviceAccountId); + return account && visibleInScope(context, account) ? clone(account) : undefined; + } + + public async findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise { + await Promise.resolve(); + const account = [...this.accounts.values()].find( + (candidate) => candidate.secretDigest === secretDigest && visibleInScope(context, candidate), + ); + return account ? clone(account) : undefined; + } + + public async listServiceAccounts( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.accounts.values()] + .filter((account) => visibleInScope(context, account)) + .sort((left, right) => left.id.localeCompare(right.id)) + .map(clone); + } + + public async saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + ): Promise { + await Promise.resolve(); + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const existing = this.accounts.get(account.id); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(account)) + throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); + return; + } + const duplicateDigest = [...this.accounts.values()].find( + (candidate) => candidate.secretDigest === account.secretDigest, + ); + if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + this.accounts.set(account.id, clone(account)); + } + + public async replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise { + await Promise.resolve(); + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const current = this.accounts.get(account.id); + if (!current || !visibleInScope(context, current)) throw new Error('SERVICE_ACCOUNT_NOT_FOUND'); + if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); + if (account.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); + const duplicateDigest = [...this.accounts.values()].find( + (candidate) => candidate.id !== account.id && candidate.secretDigest === account.secretDigest, + ); + if (duplicateDigest) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + this.accounts.set(account.id, clone(account)); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = new Map(this.accounts); + try { + return await work({ + findServiceAccount: this.findServiceAccount.bind(this), + findServiceAccountByDigest: this.findServiceAccountByDigest.bind(this), + listServiceAccounts: this.listServiceAccounts.bind(this), + saveServiceAccount: this.saveServiceAccount.bind(this), + replaceServiceAccount: this.replaceServiceAccount.bind(this), + }); + } catch (error) { + this.accounts = before; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts index bdae6fcc..8c8d641c 100644 --- a/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-credential-lookup.adapter.ts @@ -11,6 +11,7 @@ export interface UserIdentityDatabaseRowV1 { readonly email: string; readonly status: string; readonly securityEpoch: number; + readonly mfaReenrollmentRequired?: boolean; } export interface PasswordCredentialDatabaseRowV1 { @@ -189,6 +190,9 @@ export class PrismaCredentialLookupAdapter implements CredentialLookupPortV1 { workspaceId, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, + ...(user.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: user.mfaReenrollmentRequired }), }), credential: Object.freeze({ algorithm: 'argon2id' as const, diff --git a/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts new file mode 100644 index 00000000..80039d33 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-iam-invitation-repository.adapter.ts @@ -0,0 +1,368 @@ +import { + createInvitationTokenV1, + type InvitationTokenStatusV1, + type InvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; +import { + tenantScopeContainsV1, + tenantScopesEqualV1, + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { validateMembershipV1 } from '@databreeze/domain/identity/v1'; + +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../application/invitation-repository.port.js'; +import type { IamMembershipRecordV1 } from '../application/iam-repository.port.js'; +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; + +export interface IamInvitationMembershipDatabaseRowV1 { + readonly id: string; + readonly principalType: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly status: string; + readonly startsAt: Date | null; + readonly expiresAt: Date | null; + readonly revision: number; +} + +export interface IamInvitationDatabaseRowV1 { + readonly id: string; + readonly membershipId: string; + readonly principalId: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly roleId: string; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: InvitationTokenStatusV1; + readonly consumedAt: Date | null; + readonly revision: number; +} + +interface IamInvitationMembershipDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { + readonly data: IamInvitationMembershipDatabaseRowV1; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface IamInvitationTokenDelegateV1 { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + create(input: { readonly data: IamInvitationDatabaseRowV1 }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Partial; + }): Promise<{ readonly count: number }>; +} + +interface IamInvitationTransactionDatabaseClientV1 { + readonly membershipIdentity: IamInvitationMembershipDelegateV1; + readonly invitationTokenRecord: IamInvitationTokenDelegateV1; +} + +export interface IamInvitationDatabaseClientV1 extends IamInvitationTransactionDatabaseClientV1 { + $transaction( + work: (transaction: IamInvitationTransactionDatabaseClientV1) => Promise, + ): Promise; +} + +function parseScope(input: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 | undefined { + const organizationId = parseStableIdentifierV1(input.organizationId); + if (!organizationId.accepted) return undefined; + if (input.scopeType === 'ORGANIZATION') { + return input.workspaceId === null && input.projectId === null + ? { scopeType: 'organization', organizationId: organizationId.value } + : undefined; + } + const workspaceId = parseStableIdentifierV1(input.workspaceId); + if (!workspaceId.accepted) return undefined; + if (input.scopeType === 'WORKSPACE') { + return input.projectId === null + ? { + scopeType: 'workspace', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + } + : undefined; + } + const projectId = parseStableIdentifierV1(input.projectId); + if (!projectId.accepted || input.scopeType !== 'PROJECT') return undefined; + return { + scopeType: 'project', + organizationId: organizationId.value, + workspaceId: workspaceId.value, + projectId: projectId.value, + }; +} + +function membershipFromRow(row: IamInvitationMembershipDatabaseRowV1): IamMembershipRecordV1 { + const scope = parseScope(row); + const validated = validateMembershipV1({ + id: row.id, + principalType: row.principalType, + principalId: row.principalId, + scope, + roleId: row.roleId, + status: row.status, + ...(row.startsAt === null ? {} : { startsAt: row.startsAt.toISOString() }), + ...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt.toISOString() }), + revision: row.revision, + }); + if (!validated.accepted) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return validated.value; +} + +function invitationFromRow(row: IamInvitationDatabaseRowV1): InvitationTokenV1 { + const scope = parseScope(row); + const issuedAt = row.issuedAt.toISOString(); + const expiresAt = row.expiresAt.toISOString(); + const base = createInvitationTokenV1({ + id: row.id, + membershipId: row.membershipId, + principalId: row.principalId, + scope, + roleId: row.roleId, + tokenDigest: row.tokenDigest, + emailDigest: row.emailDigest, + issuedAt, + expiresAt, + revision: row.revision, + }); + if (!base.accepted) throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'REDEEMED' && row.status !== 'REVOKED') + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + const consumedAtCandidate = row.consumedAt === null ? undefined : row.consumedAt.toISOString(); + const consumedAtParsed = + consumedAtCandidate === undefined ? undefined : parseStrictUtcTimestampV1(consumedAtCandidate); + const consumedAt = consumedAtParsed?.accepted ? consumedAtParsed.value : undefined; + if ((row.status === 'REDEEMED') !== (consumedAt !== undefined)) + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + if (consumedAtCandidate !== undefined && !consumedAtParsed?.accepted) + throw new Error('IAM_PERSISTED_INVITATION_INVALID'); + return Object.freeze({ + ...base.value, + status: row.status, + ...(consumedAt === undefined ? {} : { consumedAt }), + }); +} + +function invitationRow(invitation: InvitationTokenV1): IamInvitationDatabaseRowV1 { + return { + id: invitation.id, + membershipId: invitation.membershipId, + principalId: invitation.principalId, + scopeType: invitation.scope.scopeType.toUpperCase(), + organizationId: invitation.scope.organizationId, + workspaceId: + invitation.scope.scopeType === 'organization' ? null : invitation.scope.workspaceId, + projectId: invitation.scope.scopeType === 'project' ? invitation.scope.projectId : null, + roleId: invitation.roleId, + tokenDigest: invitation.tokenDigest, + emailDigest: invitation.emailDigest, + issuedAt: new Date(invitation.issuedAt), + expiresAt: new Date(invitation.expiresAt), + status: invitation.status, + consumedAt: invitation.consumedAt ? new Date(invitation.consumedAt) : null, + revision: invitation.revision, + }; +} + +function visibleInScope(context: TenantScopeV1, target: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, target) || tenantScopeContainsV1(target, context); +} + +function uniqueConstraint(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + +class PrismaIamInvitationTransactionAdapter implements IamInvitationTransactionPortV1 { + public constructor(private readonly client: IamInvitationTransactionDatabaseClientV1) {} + + public async findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise { + const rows = await this.client.membershipIdentity.findMany({ + where: { organizationId: context.tenantScope.organizationId, principalId, status: 'ACTIVE' }, + }); + const memberships = rows + .map((row) => { + try { + return membershipFromRow(row); + } catch { + return undefined; + } + }) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) + .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); + return selectAuthoritativeMembership(memberships, context, principalId); + } + + public async findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const row = await this.client.membershipIdentity.findUnique({ where: { id: membershipId } }); + if (!row) return undefined; + const membership = membershipFromRow(row); + return visibleInScope(context.tenantScope, membership.scope) ? membership : undefined; + } + + public async findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise { + const row = await this.client.invitationTokenRecord.findUnique({ where: { tokenDigest } }); + if (!row) return undefined; + const invitation = invitationFromRow(row); + return tenantScopeContainsV1(context.tenantScope, invitation.scope) ? invitation : undefined; + } + + public async findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise { + const row = await this.client.invitationTokenRecord.findFirst({ + where: { membershipId, status: 'ACTIVE' }, + }); + if (!row) return undefined; + const invitation = invitationFromRow(row); + return tenantScopeContainsV1(context.tenantScope, invitation.scope) ? invitation : undefined; + } + + public async saveInvitation( + context: IamTenantContextV1, + invitation: InvitationTokenV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, invitation.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const row = invitationRow(invitation); + const existingRow = await this.client.invitationTokenRecord.findUnique({ + where: { id: invitation.id }, + }); + if (!existingRow) { + const active = await this.client.invitationTokenRecord.findFirst({ + where: { membershipId: invitation.membershipId, status: 'ACTIVE' }, + }); + if (active) throw new Error('IAM_INVITATION_CONFLICT'); + try { + await this.client.invitationTokenRecord.create({ data: row }); + } catch (error) { + if (uniqueConstraint(error)) throw new Error('IAM_INVITATION_CONFLICT'); + throw error; + } + return; + } + const existing = invitationFromRow(existingRow); + if ( + existing.membershipId !== invitation.membershipId || + existing.principalId !== invitation.principalId || + !tenantScopesEqualV1(existing.scope, invitation.scope) || + existing.roleId !== invitation.roleId || + existing.tokenDigest !== invitation.tokenDigest || + existing.emailDigest !== invitation.emailDigest || + existing.issuedAt !== invitation.issuedAt || + existing.expiresAt !== invitation.expiresAt + ) + throw new Error('IAM_INVITATION_SCOPE_IMMUTABLE'); + if (existing.status !== 'ACTIVE' || invitation.status === 'ACTIVE') + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + if (invitation.revision !== existing.revision + 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + const updated = await this.client.invitationTokenRecord.updateMany({ + where: { id: invitation.id, revision: existing.revision, status: 'ACTIVE' }, + data: { + status: invitation.status, + consumedAt: invitation.consumedAt ? new Date(invitation.consumedAt) : null, + revision: invitation.revision, + }, + }); + if (updated.count !== 1) throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + } + + public async saveMembership( + context: IamTenantContextV1, + membership: IamMembershipRecordV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, membership.scope)) + throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); + const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); + if (!validated.accepted) throw new Error(`IAM_${validated.code}`); + const existingRow = await this.client.membershipIdentity.findUnique({ + where: { id: membership.id }, + }); + if (!existingRow) throw new Error('IAM_REVISION_CONFLICT'); + const existing = membershipFromRow(existingRow); + if (!visibleInScope(context.tenantScope, existing.scope)) + throw new Error('IAM_REVISION_CONFLICT'); + if ( + existing.principalId !== membership.principalId || + !tenantScopesEqualV1(existing.scope, membership.scope) || + existing.roleId !== membership.roleId + ) + throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); + if (membership.revision !== existing.revision + 1) throw new Error('IAM_REVISION_CONFLICT'); + const updated = await this.client.membershipIdentity.updateMany({ + where: { id: membership.id, revision: existing.revision }, + data: { + status: membership.status, + startsAt: membership.startsAt ? new Date(membership.startsAt) : null, + expiresAt: membership.expiresAt ? new Date(membership.expiresAt) : null, + revision: membership.revision, + }, + }); + if (updated.count !== 1) throw new Error('IAM_REVISION_CONFLICT'); + } +} + +export class PrismaIamInvitationRepositoryAdapter implements IamInvitationRepositoryPortV1 { + public constructor(private readonly client: IamInvitationDatabaseClientV1) {} + + public withTransaction( + _context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaIamInvitationTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 9bf9437a..ec2308c4 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -265,7 +265,9 @@ function bootstrapFromRows( }); } -class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTransactionPortV1 { +export class PrismaIdentityBootstrapTransactionAdapter + implements IdentityBootstrapTransactionPortV1 +{ public constructor(private readonly client: IdentityBootstrapDatabaseClientV1) {} public async findByUserId( diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts index 2384a4e3..bd3ac175 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -79,6 +79,12 @@ interface MfaRecoveryCodeDelegateV1 { export interface MfaDatabaseClientV1 { readonly mfaFactor: MfaFactorDelegateV1; readonly mfaRecoveryCode: MfaRecoveryCodeDelegateV1; + readonly userIdentity?: { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; + }; $transaction( work: (transaction: MfaDatabaseClientV1) => Promise, ): Promise; @@ -286,6 +292,15 @@ class PrismaMfaTransactionAdapter implements MfaTransactionPortV1 { if (updated.count !== 1) throw new Error('IAM_MFA_REVISION_CONFLICT'); } } + + public async clearRecoveryReenrollment(userId: string): Promise { + if (!this.client.userIdentity) return false; + const updated = await this.client.userIdentity.updateMany({ + where: { id: userId, mfaReenrollmentRequired: true }, + data: { mfaReenrollmentRequired: false }, + }); + return updated.count === 1; + } } export class PrismaMfaRepositoryAdapter implements MfaRepositoryPortV1 { diff --git a/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts b/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts new file mode 100644 index 00000000..68c8d3f5 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-principal-email-lookup.adapter.ts @@ -0,0 +1,35 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamPrincipalEmailLookupPortV1 } from '../application/invitation.service.js'; + +export interface IamPrincipalEmailDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; +} + +export interface IamPrincipalEmailDatabaseClientV1 { + readonly userIdentity: { + findUnique(input: { + readonly where: Readonly>; + }): Promise; + }; +} + +/** Reads only the active, normalized email needed by the invitation use case. */ +export class PrismaIamPrincipalEmailLookupAdapter implements IamPrincipalEmailLookupPortV1 { + public constructor(private readonly client: IamPrincipalEmailDatabaseClientV1) {} + + public async findEmail(principalId: StableIdentifierV1): Promise { + const row = await this.client.userIdentity.findUnique({ where: { id: principalId } }); + if (!row || row.status !== 'ACTIVE') return undefined; + const persistedId = parseStableIdentifierV1(row.id); + if (!persistedId.accepted || persistedId.value !== principalId) return undefined; + const email = normalizeEmailAddressV1(row.email); + return email.accepted ? email.value : undefined; + } +} diff --git a/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts new file mode 100644 index 00000000..905c0ff6 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-recovery-repository.adapter.ts @@ -0,0 +1,294 @@ +import { + createRecoveryChallengeV1, + type RecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { + RecoveryCompletionInputV1, + RecoveryRepositoryPortV1, + RecoveryTransactionPortV1, +} from '../application/recovery-repository.port.js'; + +export interface RecoveryUserDatabaseRowV1 { + readonly id: string; + readonly email: string; + readonly status: string; + readonly securityEpoch: number; + readonly mfaReenrollmentRequired: boolean; +} + +export interface RecoveryChallengeDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly tokenDigest: string; + readonly emailDigest: string; + readonly issuedAt: Date; + readonly expiresAt: Date; + readonly status: string; + readonly consumedAt?: Date | null; + readonly revokedAt?: Date | null; + readonly revision: number; +} + +export interface RecoverySessionDatabaseRowV1 { + readonly id: string; + readonly userId: string; + readonly familyId: string; +} + +interface UniqueDelegateV1 { + findUnique(input: { readonly where: Readonly> }): Promise; +} + +interface UserDelegateV1 extends UniqueDelegateV1 { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +interface ChallengeDelegateV1 extends UniqueDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + create(input: { + readonly data: Readonly>; + }): Promise; + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +interface PasswordCredentialDelegateV1 { + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; +} + +interface SessionDelegateV1 { + findMany(input: { + readonly where: Readonly>; + }): Promise; + update(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise; +} + +interface UpdateManyDelegateV1 { + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +export interface RecoveryDatabaseClientV1 { + readonly userIdentity: UserDelegateV1; + readonly recoveryChallenge: ChallengeDelegateV1; + readonly passwordCredential: PasswordCredentialDelegateV1; + readonly sessionRecord: SessionDelegateV1; + readonly refreshTokenRecord: UpdateManyDelegateV1; + readonly accessTokenRecord: UpdateManyDelegateV1; + readonly mfaFactor: UpdateManyDelegateV1; + $transaction( + work: (transaction: RecoveryDatabaseClientV1) => Promise, + ): Promise; +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: Date | null | undefined): string | undefined { + if (!input) return undefined; + const value = input.toISOString(); + return Number.isFinite(Date.parse(value)) ? value : undefined; +} + +function challengeFromRow(row: RecoveryChallengeDatabaseRowV1): RecoveryChallengeV1 { + const created = createRecoveryChallengeV1({ + id: row.id, + userId: row.userId, + tokenDigest: row.tokenDigest, + emailDigest: row.emailDigest, + issuedAt: timestamp(row.issuedAt), + expiresAt: timestamp(row.expiresAt), + revision: row.revision, + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status !== 'ACTIVE' && row.status !== 'CONSUMED' && row.status !== 'REVOKED') + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + const consumedAt = timestamp(row.consumedAt); + const revokedAt = timestamp(row.revokedAt); + if ((row.consumedAt && !consumedAt) || (row.revokedAt && !revokedAt)) + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'CONSUMED' && !consumedAt) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'REVOKED' && !revokedAt) throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + if (row.status === 'ACTIVE' && (consumedAt || revokedAt)) + throw new Error('IAM_PERSISTED_RECOVERY_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + ...(consumedAt ? { consumedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function challengeData(challenge: RecoveryChallengeV1): Record { + return { + id: challenge.id, + userId: challenge.userId, + tokenDigest: challenge.tokenDigest, + emailDigest: challenge.emailDigest, + issuedAt: new Date(challenge.issuedAt), + expiresAt: new Date(challenge.expiresAt), + status: challenge.status, + consumedAt: challenge.consumedAt ? new Date(challenge.consumedAt) : null, + revokedAt: challenge.revokedAt ? new Date(challenge.revokedAt) : null, + revision: challenge.revision, + }; +} + +function sameImmutableFields(left: RecoveryChallengeV1, right: RecoveryChallengeV1): boolean { + return ( + left.id === right.id && + left.userId === right.userId && + left.tokenDigest === right.tokenDigest && + left.emailDigest === right.emailDigest && + left.issuedAt === right.issuedAt && + left.expiresAt === right.expiresAt + ); +} + +function isConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaRecoveryTransactionAdapter implements RecoveryTransactionPortV1 { + public constructor(private readonly client: RecoveryDatabaseClientV1) {} + + public async findUserIdByEmail(emailInput: string) { + const email = normalizeEmailAddressV1(emailInput); + if (!email.accepted) return undefined; + const user = await this.client.userIdentity.findUnique({ where: { email: email.value } }); + const userId = stable(user?.id); + return user && user.status === 'ACTIVE' && user.email === email.value ? userId : undefined; + } + + public async findChallengeByTokenDigest(tokenDigest: string) { + const row = await this.client.recoveryChallenge.findUnique({ where: { tokenDigest } }); + return row ? challengeFromRow(row) : undefined; + } + + public async findActiveChallengeForUser(userId: StableIdentifierV1) { + const rows = await this.client.recoveryChallenge.findMany({ + where: { userId, status: 'ACTIVE' }, + }); + const row = [...rows].sort((left, right) => left.id.localeCompare(right.id))[0]; + return row ? challengeFromRow(row) : undefined; + } + + public async saveChallenge(challenge: RecoveryChallengeV1): Promise { + if (!stable(challenge.id) || !stable(challenge.userId)) + throw new Error('IAM_RECOVERY_INVALID_IDENTIFIER'); + const existingRow = await this.client.recoveryChallenge.findUnique({ + where: { id: challenge.id }, + }); + try { + if (!existingRow) { + if (challenge.revision !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + await this.client.recoveryChallenge.create({ data: challengeData(challenge) }); + return; + } + const existing = challengeFromRow(existingRow); + if (!sameImmutableFields(existing, challenge)) throw new Error('IAM_RECOVERY_IMMUTABLE'); + if ( + existing.revision === challenge.revision && + JSON.stringify(existing) === JSON.stringify(challenge) + ) + return; + if (challenge.revision !== existing.revision + 1) + throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + const updated = await this.client.recoveryChallenge.updateMany({ + where: { id: challenge.id, revision: existing.revision }, + data: challengeData(challenge), + }); + if (updated.count !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + } catch (error) { + if (isConflict(error)) throw new Error('IAM_RECOVERY_CONFLICT'); + throw error; + } + } + + public async completeRecovery(input: RecoveryCompletionInputV1): Promise { + if (input.challenge.status !== 'CONSUMED' || !input.challenge.consumedAt) + throw new Error('IAM_RECOVERY_STATE_INVALID'); + const user = await this.client.userIdentity.findUnique({ + where: { id: input.challenge.userId }, + }); + if (!user || user.status === 'DEACTIVATED' || user.id !== input.challenge.userId) + throw new Error('IAM_RECOVERY_USER_NOT_FOUND'); + const updatedUser = await this.client.userIdentity.updateMany({ + where: { id: user.id, securityEpoch: user.securityEpoch }, + data: { securityEpoch: user.securityEpoch + 1, mfaReenrollmentRequired: true }, + }); + if (updatedUser.count !== 1) throw new Error('IAM_RECOVERY_REVISION_CONFLICT'); + await this.client.passwordCredential.update({ + where: { userId: user.id }, + data: { + id: input.credentialId, + algorithm: input.credential.algorithm, + encodedHash: input.credential.encodedHash, + rotatedAt: new Date(input.challenge.consumedAt), + }, + }); + const revokedAt = new Date(input.challenge.consumedAt); + const sessions = await this.client.sessionRecord.findMany({ + where: { userId: user.id, status: 'ACTIVE' }, + }); + for (const session of sessions) { + await this.client.refreshTokenRecord.updateMany({ + where: { familyId: session.familyId, status: 'ACTIVE' }, + data: { status: 'REVOKED' }, + }); + await this.client.accessTokenRecord.updateMany({ + where: { sessionId: session.id, status: 'ACTIVE' }, + data: { status: 'REVOKED', revokedAt }, + }); + await this.client.sessionRecord.update({ + where: { id: session.id }, + data: { status: 'REVOKED', revokedAt }, + }); + } + await this.client.mfaFactor.updateMany({ + where: { userId: user.id, status: { in: ['ACTIVE', 'PENDING'] } }, + data: { status: 'REVOKED', revokedAt, revision: { increment: 1 } }, + }); + await this.saveChallenge(input.challenge); + } +} + +/** PostgreSQL adapter for the recovery challenge and security-state transaction. */ +export class PrismaRecoveryRepositoryAdapter implements RecoveryRepositoryPortV1 { + public constructor(private readonly client: RecoveryDatabaseClientV1) {} + + public withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaRecoveryTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts new file mode 100644 index 00000000..5f72e831 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-registration-repository.adapter.ts @@ -0,0 +1,99 @@ +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; + +import { + PrismaIdentityBootstrapTransactionAdapter, + type IdentityBootstrapDatabaseClientV1, + type UserIdentityDatabaseRowV1, +} from './prisma-identity-bootstrap-repository.adapter.js'; +import { + RegistrationConflictError, + type RegistrationPersistenceInputV1, + type RegistrationRepositoryPortV1, + type RegistrationTransactionPortV1, +} from '../application/registration-repository.port.js'; + +interface RegistrationUserDelegateV1 { + findUnique(input: { + readonly where: Readonly<{ readonly id?: string; readonly email?: string }>; + }): Promise; + create(input: { + readonly data: Readonly>; + }): Promise; +} + +interface RegistrationCredentialDelegateV1 { + create(input: { readonly data: Readonly> }): Promise; +} + +export interface RegistrationDatabaseClientV1 + extends Omit { + readonly userIdentity: RegistrationUserDelegateV1; + readonly passwordCredential: RegistrationCredentialDelegateV1; + $transaction( + work: (transaction: RegistrationDatabaseClientV1) => Promise, + ): Promise; +} + +function uniqueConflict(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + ('code' in error ? error.code === 'P2002' : error instanceof RegistrationConflictError) + ); +} + +class PrismaRegistrationTransactionAdapter implements RegistrationTransactionPortV1 { + public constructor(private readonly client: RegistrationDatabaseClientV1) {} + + public async findByEmail(emailInput: string): Promise { + const email = normalizeEmailAddressV1(emailInput); + if (!email.accepted) return false; + const row = await this.client.userIdentity.findUnique({ where: { email: email.value } }); + return row?.email === email.value; + } + + public async save(input: RegistrationPersistenceInputV1): Promise { + const email = normalizeEmailAddressV1(input.email); + if (!email.accepted || email.value !== input.email) + throw new Error('IAM_REGISTRATION_INPUT_INVALID'); + try { + await this.client.userIdentity.create({ + data: { + id: input.bootstrap.user.id, + email: input.email, + displayName: input.bootstrap.user.displayName, + locale: input.bootstrap.user.locale, + status: input.bootstrap.user.status, + securityEpoch: input.bootstrap.user.securityEpoch, + createdAt: new Date(input.bootstrap.user.createdAt), + }, + }); + await this.client.passwordCredential.create({ + data: { + id: input.credentialId, + userId: input.bootstrap.user.id, + algorithm: input.credential.algorithm, + encodedHash: input.credential.encodedHash, + createdAt: new Date(input.bootstrap.user.createdAt), + }, + }); + await new PrismaIdentityBootstrapTransactionAdapter(this.client).save(input.bootstrap); + } catch (error) { + if (uniqueConflict(error)) throw new RegistrationConflictError(); + throw error; + } + } +} + +/** PostgreSQL adapter for the atomic account and personal-tenant registration unit. */ +export class PrismaRegistrationRepositoryAdapter implements RegistrationRepositoryPortV1 { + public constructor(private readonly client: RegistrationDatabaseClientV1) {} + + public withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaRegistrationTransactionAdapter(transaction)), + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts new file mode 100644 index 00000000..d652a262 --- /dev/null +++ b/services/api/src/features/iam/adapter/prisma-service-account-repository.adapter.ts @@ -0,0 +1,288 @@ +import { + createServiceAccountV1, + type ServiceAccountV1, +} from '@databreeze/domain/service-account/v1'; +import { + parseStrictUtcTimestampV1, + tenantScopeContainsV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import type { + ServiceAccountRepositoryPortV1, + ServiceAccountTransactionPortV1, +} from '../application/service-account-repository.port.js'; + +export interface ServiceAccountDatabaseRowV1 { + readonly id: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly name: string; + readonly permissions: unknown; + readonly status: string; + readonly secretDigest: string; + readonly secretVersion: number; + readonly secretIssuedAt: Date; + readonly secretExpiresAt: Date | null; + readonly lastUsedAt: Date | null; + readonly createdAt: Date; + readonly revokedAt: Date | null; + readonly revision: number; +} + +interface ServiceAccountDelegateV1 { + create(input: { + readonly data: Readonly>; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; +} + +export interface ServiceAccountDatabaseClientV1 { + readonly serviceAccount: ServiceAccountDelegateV1; + $transaction( + work: (transaction: ServiceAccountDatabaseClientV1) => Promise, + ): Promise; +} + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +function writableInScope(context: IamTenantContextV1, account: ServiceAccountV1): boolean { + return tenantScopeContainsV1(context.tenantScope, accountScope(account)); +} + +function timestamp(value: Date | null | undefined): StrictUtcTimestampV1 | undefined { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) return undefined; + const parsed = parseStrictUtcTimestampV1(value.toISOString()); + return parsed.accepted ? parsed.value : undefined; +} + +function accountFromRow(row: ServiceAccountDatabaseRowV1): ServiceAccountV1 { + const created = createServiceAccountV1({ + id: row.id, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + name: row.name, + permissions: row.permissions, + secretDigest: row.secretDigest, + secretIssuedAt: timestamp(row.secretIssuedAt), + ...(row.secretExpiresAt === null ? {} : { secretExpiresAt: timestamp(row.secretExpiresAt) }), + createdAt: timestamp(row.createdAt), + }); + if (!created.accepted) throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + if ( + (row.status !== 'ACTIVE' && row.status !== 'REVOKED') || + !Number.isSafeInteger(row.secretVersion) || + row.secretVersion < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + const secretExpiresAt = timestamp(row.secretExpiresAt); + const lastUsedAt = timestamp(row.lastUsedAt); + const revokedAt = timestamp(row.revokedAt); + if ( + (row.secretExpiresAt !== null && !secretExpiresAt) || + (row.lastUsedAt !== null && !lastUsedAt) || + (row.revokedAt !== null && !revokedAt) || + (row.status === 'ACTIVE' && revokedAt !== undefined) || + (row.status === 'REVOKED' && revokedAt === undefined) + ) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + if (lastUsedAt && Date.parse(lastUsedAt) < Date.parse(created.value.secretIssuedAt)) + throw new Error('IAM_PERSISTED_SERVICE_ACCOUNT_INVALID'); + return Object.freeze({ + ...created.value, + status: row.status, + secretVersion: row.secretVersion, + revision: row.revision, + ...(secretExpiresAt ? { secretExpiresAt } : {}), + ...(lastUsedAt ? { lastUsedAt } : {}), + ...(revokedAt ? { revokedAt } : {}), + }); +} + +function accountData(account: ServiceAccountV1): Readonly> { + return { + id: account.id, + organizationId: account.organizationId, + workspaceId: account.workspaceId ?? null, + name: account.name, + permissions: account.permissions, + status: account.status, + secretDigest: account.secretDigest, + secretVersion: account.secretVersion, + secretIssuedAt: new Date(account.secretIssuedAt), + secretExpiresAt: account.secretExpiresAt ? new Date(account.secretExpiresAt) : null, + lastUsedAt: account.lastUsedAt ? new Date(account.lastUsedAt) : null, + createdAt: new Date(account.createdAt), + revokedAt: account.revokedAt ? new Date(account.revokedAt) : null, + revision: account.revision, + }; +} + +function scopeWhere(context: IamTenantContextV1): Readonly> { + const organizationId = context.tenantScope.organizationId; + if (context.tenantScope.scopeType === 'organization') return { organizationId }; + return { + organizationId, + OR: [{ workspaceId: null }, { workspaceId: context.tenantScope.workspaceId }], + }; +} + +function isUniqueConflict(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +} + +class PrismaServiceAccountTransactionAdapter implements ServiceAccountTransactionPortV1 { + public constructor(private readonly client: ServiceAccountDatabaseClientV1) {} + + public async findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: StableIdentifierV1, + ): Promise { + const row = await this.client.serviceAccount.findFirst({ + where: { id: serviceAccountId, ...scopeWhere(context) }, + }); + return row ? accountFromRow(row) : undefined; + } + + public async findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise { + const row = await this.client.serviceAccount.findFirst({ + where: { secretDigest, ...scopeWhere(context) }, + }); + return row ? accountFromRow(row) : undefined; + } + + public async listServiceAccounts( + context: IamTenantContextV1, + ): Promise { + const rows = await this.client.serviceAccount.findMany({ + where: scopeWhere(context), + orderBy: { createdAt: 'desc' }, + }); + return rows.map(accountFromRow); + } + + public async saveServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + ): Promise { + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const existing = await this.client.serviceAccount.findFirst({ + where: { id: account.id, organizationId: account.organizationId }, + }); + if (existing) { + if (JSON.stringify(accountFromRow(existing)) !== JSON.stringify(account)) + throw new Error('IMMUTABLE_SERVICE_ACCOUNT'); + return; + } + try { + await this.client.serviceAccount.create({ data: accountData(account) }); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + throw error; + } + } + + public async replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise { + if (!writableInScope(context, account)) throw new Error('SCOPE_DENIED'); + const current = await this.findServiceAccount(context, account.id); + if (!current) throw new Error('SERVICE_ACCOUNT_NOT_FOUND'); + if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); + if (account.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); + try { + const updated = await this.client.serviceAccount.updateMany({ + where: { + id: account.id, + organizationId: account.organizationId, + workspaceId: account.workspaceId ?? null, + revision: expectedRevision, + }, + data: accountData(account), + }); + if (updated.count !== 1) throw new Error('REVISION_CONFLICT'); + } catch (error) { + if (isUniqueConflict(error)) throw new Error('SERVICE_ACCOUNT_CONFLICT'); + throw error; + } + } +} + +/** PostgreSQL adapter for scoped service-account metadata and optimistic lifecycle writes. */ +export class PrismaServiceAccountRepositoryAdapter implements ServiceAccountRepositoryPortV1 { + public constructor(private readonly client: ServiceAccountDatabaseClientV1) {} + + public withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaServiceAccountTransactionAdapter(transaction)), + ); + } + + public saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).saveServiceAccount( + context, + account, + ); + } + + public findServiceAccount(context: IamTenantContextV1, serviceAccountId: StableIdentifierV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccount( + context, + serviceAccountId, + ); + } + + public findServiceAccountByDigest(context: IamTenantContextV1, secretDigest: string) { + return new PrismaServiceAccountTransactionAdapter(this.client).findServiceAccountByDigest( + context, + secretDigest, + ); + } + + public listServiceAccounts(context: IamTenantContextV1) { + return new PrismaServiceAccountTransactionAdapter(this.client).listServiceAccounts(context); + } + + public replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ) { + return new PrismaServiceAccountTransactionAdapter(this.client).replaceServiceAccount( + context, + account, + expectedRevision, + ); + } +} diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index 6c57ace8..cff1d55a 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -62,6 +62,7 @@ export interface SessionUserDatabaseRowV1 { readonly id: string; readonly status: string; readonly securityEpoch: number; + readonly mfaReenrollmentRequired?: boolean; } export interface SessionMembershipDatabaseRowV1 { @@ -561,6 +562,9 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { workspaceId: workspaceId.value, securityEpoch: user.securityEpoch, mfaRequired: factors.length > 0, + ...(user.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: user.mfaReenrollmentRequired }), }); } } diff --git a/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts b/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts new file mode 100644 index 00000000..ddec4f72 --- /dev/null +++ b/services/api/src/features/iam/adapter/random-service-account-secret.adapter.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import type { + ServiceAccountSecretIssueV1, + ServiceAccountSecretIssuerV1, +} from '../application/service-account.service.js'; + +export type ServiceAccountRandomBytesV1 = (size: number) => Buffer; + +/** Generates credentials only at issuance time; callers must persist the digest, never the secret. */ +export class RandomServiceAccountSecretIssuer implements ServiceAccountSecretIssuerV1 { + public constructor( + private readonly source: ServiceAccountRandomBytesV1 = (size) => randomBytes(size), + ) {} + + public issue(): ServiceAccountSecretIssueV1 { + const bytes = this.source(32); + if (!Buffer.isBuffer(bytes) || bytes.length !== 32) throw new Error('SECRET_GENERATION_FAILED'); + const secret = `dbsa_${bytes.toString('base64url')}`; + const digest = createHash('sha256').update(secret, 'utf8').digest('hex'); + return Object.freeze({ secret, digest }); + } +} diff --git a/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts new file mode 100644 index 00000000..d237084c --- /dev/null +++ b/services/api/src/features/iam/adapter/redis-recovery-admission.adapter.ts @@ -0,0 +1,96 @@ +import type { RecoveryAdmissionPortV1 } from '../application/recovery-repository.port.js'; + +/** Narrow adapter boundary for an atomic Redis INCR/PEXPIRE operation. */ +export interface RecoveryAdmissionCounterPortV1 { + incrementWindow(input: { readonly key: string; readonly ttlMs: number }): Promise; +} + +export interface RedisEvalClientPortV1 { + eval(script: string, keys: readonly string[], arguments_: readonly string[]): Promise; +} + +export const REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1 = [ + 'local count = redis.call("INCR", KEYS[1])', + 'if count == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[1]) end', + 'return count', +].join('\n'); + +/** Binds the counter contract to clients that expose Redis EVAL with explicit keys/arguments. */ +export class RedisEvalRecoveryAdmissionCounterAdapter implements RecoveryAdmissionCounterPortV1 { + public constructor(private readonly client: RedisEvalClientPortV1) {} + + public async incrementWindow(input: { + readonly key: string; + readonly ttlMs: number; + }): Promise { + if ( + typeof input.key !== 'string' || + input.key.length === 0 || + !Number.isSafeInteger(input.ttlMs) || + input.ttlMs < 1_000 || + input.ttlMs > 86_400_000 + ) { + throw new Error('IAM_RECOVERY_ADMISSION_COUNTER_INVALID'); + } + const result = await this.client.eval( + REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + [input.key], + [String(input.ttlMs)], + ); + const count = typeof result === 'number' ? result : Number(result); + if (!Number.isSafeInteger(count) || count < 1) { + throw new Error('IAM_RECOVERY_ADMISSION_COUNTER_INVALID'); + } + return count; + } +} + +export interface RedisRecoveryAdmissionOptionsV1 { + readonly maxAttempts?: number; + readonly windowSeconds?: number; + readonly keyPrefix?: string; +} + +/** + * Shared admission policy for horizontally scaled API instances. + * The injected counter must increment and apply the TTL atomically (for example, + * with one Redis Lua script); this class never stores raw email addresses. + */ +export class RedisRecoveryAdmissionAdapter implements RecoveryAdmissionPortV1 { + private readonly maxAttempts: number; + private readonly windowMs: number; + private readonly keyPrefix: string; + + public constructor( + private readonly counter: RecoveryAdmissionCounterPortV1, + options: RedisRecoveryAdmissionOptionsV1 = {}, + ) { + this.maxAttempts = options.maxAttempts ?? 3; + this.windowMs = (options.windowSeconds ?? 15 * 60) * 1_000; + this.keyPrefix = options.keyPrefix ?? 'databreeze:iam:recovery:admission:v1:'; + if ( + !Number.isSafeInteger(this.maxAttempts) || + this.maxAttempts < 1 || + this.maxAttempts > 100 || + !Number.isSafeInteger(this.windowMs) || + this.windowMs < 1_000 || + this.windowMs > 86_400_000 || + !/^[\w:-]{1,120}$/u.test(this.keyPrefix) + ) { + throw new Error('IAM_RECOVERY_ADMISSION_INVALID'); + } + } + + public async allow(keyDigest: string, issuedAt: string): Promise { + if (!/^[a-f0-9]{64}$/u.test(keyDigest) || !Number.isFinite(Date.parse(issuedAt))) return false; + try { + const count = await this.counter.incrementWindow({ + key: `${this.keyPrefix}${keyDigest}`, + ttlMs: this.windowMs, + }); + return Number.isSafeInteger(count) && count >= 1 && count <= this.maxAttempts; + } catch { + return false; + } + } +} diff --git a/services/api/src/features/iam/api/auth-session.dto.ts b/services/api/src/features/iam/api/auth-session.dto.ts index 1f181768..ce1d9cb0 100644 --- a/services/api/src/features/iam/api/auth-session.dto.ts +++ b/services/api/src/features/iam/api/auth-session.dto.ts @@ -50,4 +50,9 @@ export class AuthSessionDto { @ApiProperty() @IsBoolean() mfaRequired!: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + mfaReenrollmentRequired?: boolean; } diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 664da70a..d31817a1 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -68,6 +68,9 @@ export class AuthenticationController { : { workspaceId: context.tenantScope.workspaceId }), authorizationEpoch: context.authorizationEpoch, mfaRequired: context.mfaRequired ?? false, + ...(context.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: context.mfaReenrollmentRequired }), }; } @@ -109,6 +112,9 @@ export class AuthenticationController { accessExpiresAt: result.value.session.accessExpiresAt, securityEpoch: result.value.principal.securityEpoch, mfaRequired: result.value.principal.mfaRequired, + ...(result.value.principal.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: result.value.principal.mfaReenrollmentRequired }), }; } diff --git a/services/api/src/features/iam/api/current-session.dto.ts b/services/api/src/features/iam/api/current-session.dto.ts index bf333dd3..df1005b0 100644 --- a/services/api/src/features/iam/api/current-session.dto.ts +++ b/services/api/src/features/iam/api/current-session.dto.ts @@ -22,4 +22,9 @@ export class CurrentSessionDto { @ApiProperty() @IsBoolean() mfaRequired!: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + mfaReenrollmentRequired?: boolean; } diff --git a/services/api/src/features/iam/api/invitation.controller.ts b/services/api/src/features/iam/api/invitation.controller.ts new file mode 100644 index 00000000..789971e7 --- /dev/null +++ b/services/api/src/features/iam/api/invitation.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post, Req } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + IAM_INVITATION_SERVICE, + type IamInvitationApplicationResultV1, + type IamInvitationService, +} from '../application/invitation.service.js'; +import { InvitationProblemError } from '../application/invitation-problem.error.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + AcceptInvitationDto, + InvitationRejectedResponseDto, + IssueInvitationDto, +} from './invitation.dto.js'; + +function invitationError(result: IamInvitationApplicationResultV1): TValue { + if (result.accepted) return result.value; + switch (result.code) { + case 'SCOPE_DENIED': + throw new InvitationProblemError('INVITATION_SCOPE_DENIED'); + case 'NOT_FOUND': + throw new InvitationProblemError('INVITATION_NOT_FOUND'); + case 'CONFLICT': + throw new InvitationProblemError('INVITATION_CONFLICT'); + case 'DELIVERY_UNAVAILABLE': + throw new InvitationProblemError('INVITATION_DELIVERY_UNAVAILABLE'); + case 'UNAVAILABLE': + throw new InvitationProblemError('INVITATION_UNAVAILABLE'); + default: + throw new InvitationProblemError('INVITATION_REQUEST_REJECTED'); + } +} + +/** IAM-010: invitation bearer material is accepted only in a write body and never returned. */ +@ApiTags('identity') +@ApiBearerAuth() +@Controller('v1/invitations') +export class IamInvitationController { + public constructor( + @Optional() + @Inject(IAM_INVITATION_SERVICE) + private readonly invitations: IamInvitationService | undefined, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private requireService(): IamInvitationService { + if (this.invitations === undefined) throw new InvitationProblemError('INVITATION_UNAVAILABLE'); + return this.invitations; + } + + @Post() + @HttpCode(200) + @ApiOperation({ summary: 'Deliver a single-use invitation token to an existing principal' }) + @ApiBody({ type: IssueInvitationDto }) + @ApiOkResponse({ description: 'Invitation metadata without bearer material.' }) + @ApiBadRequestResponse({ type: InvitationRejectedResponseDto }) + @ApiForbiddenResponse({ type: InvitationRejectedResponseDto }) + @ApiNotFoundResponse({ type: InvitationRejectedResponseDto }) + @ApiConflictResponse({ type: InvitationRejectedResponseDto }) + @ApiServiceUnavailableResponse({ type: InvitationRejectedResponseDto }) + async issue(@Req() request: unknown, @Body() input: IssueInvitationDto): Promise { + const context = await this.requestContext.resolve(request); + return invitationError(await this.requireService().issue(context, input)); + } + + @Post('accept') + @HttpCode(200) + @ApiOperation({ summary: 'Redeem a single-use invitation token for the authenticated principal' }) + @ApiBody({ type: AcceptInvitationDto }) + @ApiOkResponse({ description: 'Activated membership metadata.' }) + @ApiBadRequestResponse({ type: InvitationRejectedResponseDto }) + @ApiForbiddenResponse({ type: InvitationRejectedResponseDto }) + @ApiNotFoundResponse({ type: InvitationRejectedResponseDto }) + @ApiConflictResponse({ type: InvitationRejectedResponseDto }) + @ApiServiceUnavailableResponse({ type: InvitationRejectedResponseDto }) + async accept(@Req() request: unknown, @Body() input: AcceptInvitationDto): Promise { + const context = await this.requestContext.resolve(request); + return invitationError(await this.requireService().accept(context, input.token)); + } +} diff --git a/services/api/src/features/iam/api/invitation.dto.ts b/services/api/src/features/iam/api/invitation.dto.ts new file mode 100644 index 00000000..3d3ccaad --- /dev/null +++ b/services/api/src/features/iam/api/invitation.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; + +export class IssueInvitationDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + membershipId!: string; + + @ApiProperty({ format: 'email', maxLength: 254 }) + @IsEmail() + @IsString() + @MinLength(3) + @MaxLength(254) + recipientEmail!: string; +} + +export class AcceptInvitationDto { + @ApiProperty({ minLength: 32, maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(32) + @MaxLength(512) + token!: string; +} + +export class InvitationRejectedResponseDto { + @ApiProperty({ enum: [false], example: false }) + accepted!: false; + + @ApiProperty({ + enum: [ + 'INVITATION_REQUEST_REJECTED', + 'INVITATION_SCOPE_DENIED', + 'INVITATION_NOT_FOUND', + 'INVITATION_CONFLICT', + 'INVITATION_DELIVERY_UNAVAILABLE', + 'INVITATION_UNAVAILABLE', + ], + }) + code!: string; +} diff --git a/services/api/src/features/iam/api/recovery.controller.ts b/services/api/src/features/iam/api/recovery.controller.ts new file mode 100644 index 00000000..eb217732 --- /dev/null +++ b/services/api/src/features/iam/api/recovery.controller.ts @@ -0,0 +1,81 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post } from '@nestjs/common'; +import { + ApiAcceptedResponse, + ApiBadRequestResponse, + ApiBody, + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { IAM_RECOVERY_SERVICE, RecoveryService } from '../application/recovery.service.js'; +import { RecoveryProblemError } from '../application/recovery-problem.error.js'; +import { + RecoveryCompleteDto, + RecoveryCompleteResponseDto, + RecoveryRequestDto, + RecoveryRequestResponseDto, +} from './recovery.dto.js'; + +/** IAM-015: public account recovery never discloses whether an email has an account. */ +@ApiTags('auth') +@Controller('v1/auth') +export class RecoveryController { + public constructor( + @Optional() + @Inject(IAM_RECOVERY_SERVICE) + private readonly recovery: RecoveryService | undefined, + ) {} + + @Post('recovery') + @HttpCode(202) + @ApiOperation({ + summary: 'Request an account recovery link', + description: 'The accepted response is intentionally identical for known and unknown emails.', + }) + @ApiBody({ type: RecoveryRequestDto }) + @ApiAcceptedResponse({ type: RecoveryRequestResponseDto }) + @ApiBadRequestResponse({ description: 'The recovery request was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Recovery delivery is unavailable.' }) + async request(@Body() input: RecoveryRequestDto): Promise { + if (this.recovery === undefined) throw new RecoveryProblemError('RECOVERY_UNAVAILABLE'); + const result = await this.recovery.request(input.email); + if (!result.accepted) { + throw new RecoveryProblemError( + result.code === 'RECOVERY_UNAVAILABLE' + ? 'RECOVERY_UNAVAILABLE' + : 'RECOVERY_REQUEST_REJECTED', + ); + } + return { requested: true }; + } + + @Post('recovery/complete') + @HttpCode(200) + @ApiOperation({ + summary: 'Complete account recovery', + description: 'Consumes a single-use link, revokes sessions, and requires MFA re-enrollment.', + }) + @ApiBody({ type: RecoveryCompleteDto }) + @ApiOkResponse({ type: RecoveryCompleteResponseDto }) + @ApiBadRequestResponse({ description: 'The recovery token or password was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Recovery persistence is unavailable.' }) + async complete(@Body() input: RecoveryCompleteDto): Promise { + if (this.recovery === undefined) throw new RecoveryProblemError('RECOVERY_UNAVAILABLE'); + const result = await this.recovery.complete(input.token, input.newPassword); + if (!result.accepted) { + throw new RecoveryProblemError( + result.code === 'RECOVERY_UNAVAILABLE' + ? 'RECOVERY_UNAVAILABLE' + : result.code === 'INVALID_TOKEN' + ? 'RECOVERY_TOKEN_INVALID' + : 'RECOVERY_REQUEST_REJECTED', + ); + } + return { + userId: result.value.userId, + mfaReenrollmentRequired: result.value.mfaReenrollmentRequired, + }; + } +} diff --git a/services/api/src/features/iam/api/recovery.dto.ts b/services/api/src/features/iam/api/recovery.dto.ts new file mode 100644 index 00000000..ef91af94 --- /dev/null +++ b/services/api/src/features/iam/api/recovery.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'; + +export class RecoveryRequestDto { + @ApiProperty({ format: 'email', maxLength: 254 }) + @IsEmail() + @IsString() + @MinLength(3) + @MaxLength(254) + email!: string; +} + +export class RecoveryCompleteDto { + @ApiProperty({ minLength: 32, maxLength: 512, writeOnly: true }) + @IsString() + @MinLength(32) + @MaxLength(512) + token!: string; + + @ApiProperty({ minLength: 12, maxLength: 128, writeOnly: true }) + @IsString() + @MinLength(12) + @MaxLength(128) + newPassword!: string; +} + +export class RecoveryRequestResponseDto { + @ApiProperty({ enum: [true], example: true }) + requested!: true; +} + +export class RecoveryCompleteResponseDto { + @ApiProperty({ format: 'uuid' }) + userId!: string; + + @ApiProperty({ enum: [true], example: true }) + mfaReenrollmentRequired!: true; +} diff --git a/services/api/src/features/iam/api/registration.controller.ts b/services/api/src/features/iam/api/registration.controller.ts new file mode 100644 index 00000000..2f6e39cf --- /dev/null +++ b/services/api/src/features/iam/api/registration.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, HttpCode, Inject, Optional, Post } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBody, + ApiCreatedResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + IAM_REGISTRATION_SERVICE, + type RegistrationService, +} from '../application/registration.service.js'; +import { RegistrationProblemError } from '../application/registration-problem.error.js'; +import { RegistrationDto, RegistrationResponseDto } from './registration.dto.js'; + +/** IAM-001/IAM-009: account registration creates a safe personal hierarchy without a session. */ +@ApiTags('auth') +@Controller('v1/auth') +export class RegistrationController { + public constructor( + @Optional() + @Inject(IAM_REGISTRATION_SERVICE) + private readonly registration: RegistrationService | undefined, + ) {} + + @Post('register') + @HttpCode(201) + @ApiOperation({ + summary: 'Create an account and personal organization hierarchy', + description: 'Registration does not return bearer material; sign in separately after creation.', + }) + @ApiBody({ type: RegistrationDto }) + @ApiCreatedResponse({ type: RegistrationResponseDto }) + @ApiBadRequestResponse({ description: 'The registration request was rejected.' }) + @ApiServiceUnavailableResponse({ description: 'Registration persistence is unavailable.' }) + async register(@Body() input: RegistrationDto): Promise { + if (this.registration === undefined) + throw new RegistrationProblemError('REGISTRATION_UNAVAILABLE'); + const result = await this.registration.register(input); + if (!result.accepted) { + throw new RegistrationProblemError( + result.code === 'REGISTRATION_UNAVAILABLE' + ? 'REGISTRATION_UNAVAILABLE' + : 'REGISTRATION_REQUEST_REJECTED', + ); + } + return { + userId: result.value.bootstrap.user.id, + organizationId: result.value.bootstrap.organization.id, + workspaceId: result.value.bootstrap.workspace.id, + projectId: result.value.bootstrap.project.id, + membershipId: result.value.bootstrap.membership.id, + locale: result.value.bootstrap.user.locale, + }; + } +} diff --git a/services/api/src/features/iam/api/registration.dto.ts b/services/api/src/features/iam/api/registration.dto.ts new file mode 100644 index 00000000..1063c7ba --- /dev/null +++ b/services/api/src/features/iam/api/registration.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class RegistrationDto { + @ApiProperty({ example: 'ngu***@example.com', maxLength: 254 }) + @IsEmail() + @MaxLength(254) + email!: string; + + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + displayName!: string; + + @ApiProperty({ minLength: 12, maxLength: 128, writeOnly: true }) + @IsString() + @MinLength(12) + @MaxLength(128) + password!: string; + + @ApiPropertyOptional({ enum: ['vi-VN', 'en'], default: 'vi-VN' }) + @IsOptional() + @IsIn(['vi-VN', 'en']) + locale?: 'vi-VN' | 'en'; +} + +export class RegistrationResponseDto { + @ApiProperty({ format: 'uuid' }) + userId!: string; + + @ApiProperty({ format: 'uuid' }) + organizationId!: string; + + @ApiProperty({ format: 'uuid' }) + workspaceId!: string; + + @ApiProperty({ format: 'uuid' }) + projectId!: string; + + @ApiProperty({ format: 'uuid' }) + membershipId!: string; + + @ApiProperty({ enum: ['vi-VN', 'en'] }) + locale!: 'vi-VN' | 'en'; +} diff --git a/services/api/src/features/iam/api/service-account.controller.ts b/services/api/src/features/iam/api/service-account.controller.ts new file mode 100644 index 00000000..26994555 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.controller.ts @@ -0,0 +1,111 @@ +import { Body, Controller, Get, Headers, HttpCode, Inject, Param, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; +import { + SERVICE_ACCOUNT_SERVICE, + type ServiceAccountApplicationResultV1, + type ServiceAccountService, +} from '../application/service-account.service.js'; +import { ServiceAccountProblemError } from '../application/service-account-problem.error.js'; +import { CreateServiceAccountDto, ServiceAccountRevisionDto } from './service-account.dto.js'; + +@ApiTags('service-accounts') +@ApiBearerAuth() +@Controller('v1') +export class ServiceAccountController { + public constructor( + @Inject(SERVICE_ACCOUNT_SERVICE) + private readonly serviceAccounts: ServiceAccountService, + @Inject(REQUEST_TENANT_CONTEXT) + private readonly requestContext: RequestTenantContextPortV1, + ) {} + + private async execute( + work: () => Promise>, + ): Promise { + let result: ServiceAccountApplicationResultV1; + try { + result = await work(); + } catch { + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_UNAVAILABLE'); + } + if (result.accepted) return result.value; + if (result.code === 'SCOPE_DENIED') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_SCOPE_DENIED'); + if (result.code === 'NOT_FOUND') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_NOT_FOUND'); + if (result.code === 'CONFLICT') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_CONFLICT'); + if (result.code === 'REVOKED') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REVOKED'); + if (result.code === 'EXPIRED') throw new ServiceAccountProblemError('SERVICE_ACCOUNT_EXPIRED'); + if (result.code === 'UNAVAILABLE') + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_UNAVAILABLE'); + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_REQUEST_REJECTED'); + } + + @Get('organizations/:organizationId/service-accounts') + @ApiOperation({ + summary: 'List content-free service-account identities in an organization scope', + }) + async list( + @Req() request: unknown, + @Param('organizationId') organizationId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const parsed = parseStableIdentifierV1(organizationId); + if (!parsed.accepted || parsed.value !== context.tenantScope.organizationId) + throw new ServiceAccountProblemError('SERVICE_ACCOUNT_SCOPE_DENIED'); + return this.execute(() => this.serviceAccounts.list(context)); + } + + @Post('service-accounts') + @HttpCode(201) + @ApiOperation({ + summary: 'Create an action-scoped service account and return its one-time secret', + }) + @ApiBody({ type: CreateServiceAccountDto }) + async create( + @Req() request: unknown, + @Headers('idempotency-key') _idempotencyKey: string | undefined, + @Body() input: CreateServiceAccountDto, + ): Promise { + const context = await this.requestContext.resolve(request); + void _idempotencyKey; + return this.execute(() => this.serviceAccounts.create(context, input)); + } + + @Post('service-accounts/:serviceAccountId/rotate') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate a service-account secret and return the successor once' }) + @ApiBody({ type: ServiceAccountRevisionDto }) + async rotate( + @Req() request: unknown, + @Param('serviceAccountId') serviceAccountId: string, + @Body() input: ServiceAccountRevisionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => + this.serviceAccounts.rotate(context, serviceAccountId, input.expectedRevision), + ); + } + + @Post('service-accounts/:serviceAccountId/revoke') + @HttpCode(200) + @ApiOperation({ summary: 'Permanently revoke a service-account identity' }) + @ApiBody({ type: ServiceAccountRevisionDto }) + async revoke( + @Req() request: unknown, + @Param('serviceAccountId') serviceAccountId: string, + @Body() input: ServiceAccountRevisionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.execute(() => + this.serviceAccounts.revoke(context, serviceAccountId, input.expectedRevision), + ); + } +} diff --git a/services/api/src/features/iam/api/service-account.dto.ts b/services/api/src/features/iam/api/service-account.dto.ts new file mode 100644 index 00000000..0f0fd655 --- /dev/null +++ b/services/api/src/features/iam/api/service-account.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsISO8601, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateServiceAccountDto { + @ApiProperty({ minLength: 1, maxLength: 200 }) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Optional workspace narrowing for the identity', + }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 64 }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(64) + @IsString({ each: true }) + permissions!: string[]; + + @ApiPropertyOptional({ + format: 'date-time', + description: 'Optional expiry, at most 365 days after issue', + }) + @IsOptional() + @IsISO8601() + secretExpiresAt?: string; +} + +export class ServiceAccountRevisionDto { + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + expectedRevision!: number; +} diff --git a/services/api/src/features/iam/application/authentication.port.ts b/services/api/src/features/iam/application/authentication.port.ts index 766dff77..fe085d3d 100644 --- a/services/api/src/features/iam/application/authentication.port.ts +++ b/services/api/src/features/iam/application/authentication.port.ts @@ -10,6 +10,8 @@ export interface AuthenticatedPrincipalV1 { readonly workspaceId: string; readonly securityEpoch: number; readonly mfaRequired: boolean; + /** Recovery keeps this gate live until a new factor is verified. */ + readonly mfaReenrollmentRequired?: boolean; } export interface CredentialLookupPortV1 { diff --git a/services/api/src/features/iam/application/invitation-problem.error.ts b/services/api/src/features/iam/application/invitation-problem.error.ts new file mode 100644 index 00000000..50502441 --- /dev/null +++ b/services/api/src/features/iam/application/invitation-problem.error.ts @@ -0,0 +1,14 @@ +export type IamInvitationProblemCodeV1 = + | 'INVITATION_REQUEST_REJECTED' + | 'INVITATION_SCOPE_DENIED' + | 'INVITATION_NOT_FOUND' + | 'INVITATION_CONFLICT' + | 'INVITATION_DELIVERY_UNAVAILABLE' + | 'INVITATION_UNAVAILABLE'; + +export class InvitationProblemError extends Error { + public constructor(readonly code: IamInvitationProblemCodeV1) { + super(code); + this.name = 'InvitationProblemError'; + } +} diff --git a/services/api/src/features/iam/application/invitation-repository.port.ts b/services/api/src/features/iam/application/invitation-repository.port.ts new file mode 100644 index 00000000..15ff1ff2 --- /dev/null +++ b/services/api/src/features/iam/application/invitation-repository.port.ts @@ -0,0 +1,35 @@ +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_INVITATION_REPOSITORY_PORT = Symbol('IAM_INVITATION_REPOSITORY_PORT'); + +export interface IamInvitationTransactionPortV1 { + findMembershipForPrincipal( + context: IamTenantContextV1, + principalId: StableIdentifierV1, + ): Promise; + findMembershipById( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise; + findInvitationByDigest( + context: IamTenantContextV1, + tokenDigest: string, + ): Promise; + findActiveInvitationForMembership( + context: IamTenantContextV1, + membershipId: StableIdentifierV1, + ): Promise; + saveInvitation(context: IamTenantContextV1, invitation: InvitationTokenV1): Promise; + saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1): Promise; +} + +export interface IamInvitationRepositoryPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iam/application/invitation.service.ts b/services/api/src/features/iam/application/invitation.service.ts new file mode 100644 index 00000000..d2cfd819 --- /dev/null +++ b/services/api/src/features/iam/application/invitation.service.ts @@ -0,0 +1,278 @@ +import { timingSafeEqual } from 'node:crypto'; + +import { + consumeInvitationTokenV1, + createInvitationTokenV1, + type InvitationTokenV1, +} from '@databreeze/domain/invitation/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { roleHasPermissionV1, PERMISSIONS_V1 } from '@databreeze/domain/permissions/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, + tenantScopesEqualV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from './invitation-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const IAM_INVITATION_SERVICE = Symbol('IAM_INVITATION_SERVICE'); +export const IAM_PRINCIPAL_EMAIL_LOOKUP_PORT = Symbol('IAM_PRINCIPAL_EMAIL_LOOKUP_PORT'); + +export type IamInvitationApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_TEXT' + | 'INVALID_EMAIL' + | 'INVALID_TOKEN' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'INVALID_STATE' + | 'RECIPIENT_MISMATCH' + | 'CONFLICT' + | 'DELIVERY_UNAVAILABLE' + | 'UNAVAILABLE'; + +export type IamInvitationApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: IamInvitationApplicationCodeV1 }; + +export interface IamInvitationDigestPortV1 { + digestToken(rawToken: string): string; + digestEmail(normalizedEmail: string): string; +} + +export type IamInvitationIdGeneratorV1 = () => string; +export type IamInvitationTokenGeneratorV1 = () => string; +export type IamInvitationClockV1 = () => Date; + +export interface IamPrincipalEmailLookupPortV1 { + findEmail(principalId: StableIdentifierV1): Promise; +} + +/** Raw bearer material is deliberately confined to this delivery port. */ +export interface IamInvitationDeliveryPortV1 { + deliver(input: { + readonly invitationId: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly recipientEmail: string; + readonly rawToken: string; + readonly expiresAt: string; + }): Promise; +} + +export interface IamIssuedInvitationV1 { + readonly invitationId: StableIdentifierV1; + readonly membershipId: StableIdentifierV1; + readonly expiresAt: InvitationTokenV1['expiresAt']; + readonly deliveryStatus: 'DELIVERED'; +} + +function accepted(value: TValue): IamInvitationApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: IamInvitationApplicationCodeV1): IamInvitationApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function rawToken(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length < 32 || input.length > 512) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + return input; +} + +function safeDigestEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +function applicationError(error: unknown): IamInvitationApplicationCodeV1 { + const message = error instanceof Error ? error.message : ''; + if (message === 'IAM_SCOPE_DENIED' || message === 'IAM_SCOPE_NARROWING_REQUIRED') + return 'SCOPE_DENIED'; + if (message === 'IAM_INVITATION_CONFLICT' || message === 'IAM_REVISION_CONFLICT') + return 'CONFLICT'; + if (message === 'IAM_INVITATION_INVALID') return 'INVALID_TOKEN'; + return 'UNAVAILABLE'; +} + +/** IAM-010: issue and redeem one-time invitations without exposing bearer material. */ +export class IamInvitationService { + public constructor( + private readonly repository: IamInvitationRepositoryPortV1, + private readonly principalEmails: IamPrincipalEmailLookupPortV1, + private readonly idGenerator: IamInvitationIdGeneratorV1, + private readonly tokenGenerator: IamInvitationTokenGeneratorV1, + private readonly digest: IamInvitationDigestPortV1, + private readonly delivery: IamInvitationDeliveryPortV1, + private readonly clock: IamInvitationClockV1 = () => new Date(), + ) {} + + private now(): string | undefined { + try { + const value = this.clock(); + return value instanceof Date && Number.isFinite(value.getTime()) + ? value.toISOString() + : undefined; + } catch { + return undefined; + } + } + + private async authorize( + context: IamTenantContextV1, + target: IamMembershipRecordV1, + transaction: IamInvitationTransactionPortV1, + ): Promise<'ALLOWED' | 'DENIED' | 'UNAVAILABLE'> { + if (!tenantScopeContainsV1(context.tenantScope, target.scope)) return 'DENIED'; + try { + const actor = await transaction.findMembershipForPrincipal(context, context.actorId); + return actor && roleHasPermissionV1(actor.roleId, PERMISSIONS_V1.ORGANIZATION_SETTINGS_MANAGE) + ? 'ALLOWED' + : 'DENIED'; + } catch { + return 'UNAVAILABLE'; + } + } + + public async issue( + context: IamTenantContextV1, + input: { readonly membershipId: unknown; readonly recipientEmail: unknown }, + ): Promise> { + const membershipId = stable(input.membershipId); + if (!membershipId) return rejected('INVALID_IDENTIFIER'); + const normalizedEmail = normalizeEmailAddressV1(input.recipientEmail); + if (!normalizedEmail.accepted) return rejected('INVALID_EMAIL'); + const issuedAt = this.now(); + if (!issuedAt) return rejected('UNAVAILABLE'); + let invitationId: string; + let raw: string; + try { + invitationId = this.idGenerator(); + raw = this.tokenGenerator(); + } catch { + return rejected('UNAVAILABLE'); + } + if (!stable(invitationId) || !rawToken(raw)) return rejected('UNAVAILABLE'); + try { + return await this.repository.withTransaction(context, async (transaction) => { + const membership = await transaction.findMembershipById(context, membershipId); + if (!membership) return rejected('NOT_FOUND'); + if (membership.status !== 'INVITED') return rejected('INVALID_STATE'); + const authorization = await this.authorize(context, membership, transaction); + if (authorization !== 'ALLOWED') + return rejected(authorization === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'SCOPE_DENIED'); + const recipientEmail = await this.principalEmails.findEmail(membership.principalId); + const normalizedRecipient = normalizeEmailAddressV1(recipientEmail); + if (!normalizedRecipient.accepted || normalizedRecipient.value !== normalizedEmail.value) + return rejected('RECIPIENT_MISMATCH'); + if (await transaction.findActiveInvitationForMembership(context, membership.id)) + return rejected('CONFLICT'); + const expiresAt = new Date(Date.parse(issuedAt) + 7 * 24 * 60 * 60 * 1_000).toISOString(); + const token = createInvitationTokenV1({ + id: invitationId, + membershipId: membership.id, + principalId: membership.principalId, + scope: membership.scope, + roleId: membership.roleId, + tokenDigest: this.digest.digestToken(raw), + emailDigest: this.digest.digestEmail(normalizedEmail.value), + issuedAt, + expiresAt, + }); + if (!token.accepted) return rejected('UNAVAILABLE'); + try { + await this.delivery.deliver({ + invitationId: token.value.id, + membershipId: token.value.membershipId, + recipientEmail: normalizedEmail.value, + rawToken: raw, + expiresAt: token.value.expiresAt, + }); + } catch { + return rejected('DELIVERY_UNAVAILABLE'); + } + await transaction.saveInvitation(context, token.value); + return accepted({ + invitationId: token.value.id, + membershipId: token.value.membershipId, + expiresAt: token.value.expiresAt, + deliveryStatus: 'DELIVERED' as const, + }); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } + + public async accept( + context: IamTenantContextV1, + rawInput: unknown, + ): Promise> { + const raw = rawToken(rawInput); + if (!raw) return rejected('INVALID_TEXT'); + let digest: string; + try { + digest = this.digest.digestToken(raw); + } catch { + return rejected('UNAVAILABLE'); + } + try { + return await this.repository.withTransaction(context, async (transaction) => { + const token = await transaction.findInvitationByDigest(context, digest); + if (!token) return rejected('INVALID_TOKEN'); + if (token.principalId !== context.actorId) return rejected('INVALID_TOKEN'); + const recipientEmail = await this.principalEmails.findEmail(context.actorId); + const normalizedEmail = normalizeEmailAddressV1(recipientEmail); + if ( + !normalizedEmail.accepted || + !safeDigestEqual(this.digest.digestEmail(normalizedEmail.value), token.emailDigest) + ) + return rejected('INVALID_TOKEN'); + if (!tenantScopeContainsV1(context.tenantScope, token.scope)) + return rejected('INVALID_TOKEN'); + const membership = await transaction.findMembershipById(context, token.membershipId); + if ( + !membership || + membership.status !== 'INVITED' || + membership.principalId !== token.principalId || + membership.roleId !== token.roleId || + !tenantScopesEqualV1(membership.scope, token.scope) + ) + return rejected('INVALID_TOKEN'); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); + const consumed = consumeInvitationTokenV1(token, now); + if (!consumed.accepted) return rejected('INVALID_TOKEN'); + const { + startsAt: _startsAt, + expiresAt: _expiresAt, + ...membershipWithoutLifetime + } = membership; + void _startsAt; + void _expiresAt; + const next: IamMembershipRecordV1 = Object.freeze({ + ...membershipWithoutLifetime, + status: 'ACTIVE', + revision: membership.revision + 1, + }); + await transaction.saveInvitation(context, consumed.value); + await transaction.saveMembership(context, next); + return accepted(next); + }); + } catch (error) { + return rejected(applicationError(error)); + } + } +} diff --git a/services/api/src/features/iam/application/mfa-repository.port.ts b/services/api/src/features/iam/application/mfa-repository.port.ts index d3c58329..4ac8b66a 100644 --- a/services/api/src/features/iam/application/mfa-repository.port.ts +++ b/services/api/src/features/iam/application/mfa-repository.port.ts @@ -6,6 +6,8 @@ export const MFA_REPOSITORY_PORT = Symbol('MFA_REPOSITORY_PORT'); export interface MfaTransactionPortV1 { findState(userId: StableIdentifierV1): Promise; saveState(userId: StableIdentifierV1, state: MfaStateV1): Promise; + /** Clears the post-recovery gate after a newly verified factor; returns whether a flag changed. */ + clearRecoveryReenrollment?(userId: StableIdentifierV1): Promise; } export interface MfaRepositoryPortV1 extends MfaTransactionPortV1 { diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index 80de25e1..4a06fe32 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -12,6 +12,7 @@ import { } from '@databreeze/domain/tenant-scope/v1'; import type { MfaRepositoryPortV1 } from './mfa-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; export const MFA_SERVICE = Symbol('MFA_SERVICE'); @@ -162,6 +163,9 @@ export class MfaService { recoveryCodes: state.recoveryCodes, }); await transaction.saveState(userId, next); + if (transaction.clearRecoveryReenrollment) { + await transaction.clearRecoveryReenrollment(userId); + } return Object.freeze({ accepted: true, value: view(next) }); }); } @@ -192,7 +196,24 @@ export class MfaService { assertion: Parameters[1], principalId: StableIdentifierV1, now: unknown, + mfaReenrollmentRequired = false, + ): MfaResultV1 { + return requiresStepUpV1(risk, assertion, principalId, now, mfaReenrollmentRequired); + } + + /** Prefer this boundary for authenticated actions so the recovery gate travels with context. */ + public requireStepUpForContext( + context: Pick, + risk: unknown, + assertion: Parameters[1], + now: unknown, ): MfaResultV1 { - return requiresStepUpV1(risk, assertion, principalId, now); + return this.requireStepUp( + risk, + assertion, + context.actorId, + now, + context.mfaReenrollmentRequired === true, + ); } } diff --git a/services/api/src/features/iam/application/recovery-problem.error.ts b/services/api/src/features/iam/application/recovery-problem.error.ts new file mode 100644 index 00000000..36eca815 --- /dev/null +++ b/services/api/src/features/iam/application/recovery-problem.error.ts @@ -0,0 +1,11 @@ +export type RecoveryProblemCodeV1 = + | 'RECOVERY_REQUEST_REJECTED' + | 'RECOVERY_TOKEN_INVALID' + | 'RECOVERY_UNAVAILABLE'; + +export class RecoveryProblemError extends Error { + public constructor(readonly code: RecoveryProblemCodeV1) { + super(code); + this.name = 'RecoveryProblemError'; + } +} diff --git a/services/api/src/features/iam/application/recovery-repository.port.ts b/services/api/src/features/iam/application/recovery-repository.port.ts new file mode 100644 index 00000000..72616f2f --- /dev/null +++ b/services/api/src/features/iam/application/recovery-repository.port.ts @@ -0,0 +1,65 @@ +import type { RecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { PasswordCredentialV1 } from '../domain/password-credential.js'; + +export const IAM_RECOVERY_REPOSITORY_PORT = Symbol('IAM_RECOVERY_REPOSITORY_PORT'); +export const IAM_RECOVERY_ADMISSION_PORT = Symbol('IAM_RECOVERY_ADMISSION_PORT'); +export const IAM_RECOVERY_COMPLETION_ADMISSION_PORT = Symbol( + 'IAM_RECOVERY_COMPLETION_ADMISSION_PORT', +); + +export interface RecoveryCompletionInputV1 { + readonly challenge: RecoveryChallengeV1; + readonly credentialId: StableIdentifierV1; + readonly credential: PasswordCredentialV1; +} + +export interface RecoveryTransactionPortV1 { + findUserIdByEmail(email: string): Promise; + findChallengeByTokenDigest(tokenDigest: string): Promise; + findActiveChallengeForUser(userId: StableIdentifierV1): Promise; + saveChallenge(challenge: RecoveryChallengeV1): Promise; + completeRecovery(input: RecoveryCompletionInputV1): Promise; +} + +export interface RecoveryRepositoryPortV1 { + withTransaction( + work: (transaction: RecoveryTransactionPortV1) => Promise, + ): Promise; +} + +export interface RecoveryDigestPortV1 { + digestToken(rawToken: string): string; + digestEmail(normalizedEmail: string): string; +} + +/** Optional abuse-control boundary; callers must not use it to reveal account existence. */ +export interface RecoveryAdmissionPortV1 { + allow(keyDigest: string, issuedAt: string): Promise; +} + +export interface RecoveryDeliveryPortV1 { + deliver(input: { + readonly challengeId: StableIdentifierV1; + readonly recipientEmail: string; + readonly rawToken: string; + readonly expiresAt: string; + }): Promise; +} + +export type RecoveryFailureCodeV1 = 'INVALID_INPUT' | 'INVALID_TOKEN' | 'RECOVERY_UNAVAILABLE'; + +export type RecoveryRequestResultV1 = + | { readonly accepted: true; readonly value: { readonly requested: true } } + | { readonly accepted: false; readonly code: RecoveryFailureCodeV1 }; + +export type RecoveryCompletionResultV1 = + | { + readonly accepted: true; + readonly value: { + readonly userId: StableIdentifierV1; + readonly mfaReenrollmentRequired: true; + }; + } + | { readonly accepted: false; readonly code: RecoveryFailureCodeV1 }; diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts new file mode 100644 index 00000000..4a339b06 --- /dev/null +++ b/services/api/src/features/iam/application/recovery.service.ts @@ -0,0 +1,234 @@ +import { + consumeRecoveryChallengeV1, + createRecoveryChallengeV1, + RECOVERY_CHALLENGE_MAX_SECONDS_V1, + revokeRecoveryChallengeV1, +} from '@databreeze/domain/recovery/v1'; +import { normalizeEmailAddressV1 } from '@databreeze/domain/identity/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { PasswordCredentialService } from './password-credential.service.js'; +import type { + RecoveryCompletionResultV1, + RecoveryAdmissionPortV1, + RecoveryDeliveryPortV1, + RecoveryDigestPortV1, + RecoveryFailureCodeV1, + RecoveryRepositoryPortV1, + RecoveryRequestResultV1, +} from './recovery-repository.port.js'; + +export const IAM_RECOVERY_SERVICE = Symbol('IAM_RECOVERY_SERVICE'); + +export interface RecoveryIdGeneratorV1 { + next(): string; +} + +export interface RecoveryTokenGeneratorV1 { + next(): string; +} + +export interface RecoveryClockV1 { + now(): Date; +} + +export interface RecoveryServicePortsV1 { + readonly repository: RecoveryRepositoryPortV1; + readonly passwordCredentials: PasswordCredentialService; + readonly digest: RecoveryDigestPortV1; + readonly delivery: RecoveryDeliveryPortV1; + readonly ids: RecoveryIdGeneratorV1; + readonly tokens: RecoveryTokenGeneratorV1; + readonly clock?: RecoveryClockV1; + readonly admission?: RecoveryAdmissionPortV1; + readonly completionAdmission?: RecoveryAdmissionPortV1; +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const result = parseStableIdentifierV1(input); + return result.accepted ? result.value : undefined; +} + +function rawToken(input: unknown): string | undefined { + if (typeof input !== 'string' || input.length < 32 || input.length > 512) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + return input; +} + +function timestamp(clock: RecoveryClockV1 | undefined): string | undefined { + try { + const value = clock?.now() ?? new Date(); + return value instanceof Date && Number.isFinite(value.getTime()) + ? value.toISOString() + : undefined; + } catch { + return undefined; + } +} + +function unavailable(): { readonly accepted: false; readonly code: 'RECOVERY_UNAVAILABLE' } { + return Object.freeze({ accepted: false, code: 'RECOVERY_UNAVAILABLE' as const }); +} + +function inputRejected(code: RecoveryFailureCodeV1): { + readonly accepted: false; + readonly code: RecoveryFailureCodeV1; +} { + return Object.freeze({ accepted: false, code }); +} + +/** Public recovery flow: generic requests, hashed single-use tokens, and atomic credential reset. */ +export class RecoveryService { + public constructor(private readonly ports: RecoveryServicePortsV1) {} + + public async request(emailInput: unknown): Promise { + const normalized = normalizeEmailAddressV1(emailInput); + if (!normalized.accepted) return inputRejected('INVALID_INPUT'); + const issuedAt = timestamp(this.ports.clock); + if (!issuedAt) return unavailable(); + let challengeId: string; + let raw: string; + try { + challengeId = this.ports.ids.next(); + raw = this.ports.tokens.next(); + } catch { + return unavailable(); + } + if (!stable(challengeId) || !rawToken(raw)) return unavailable(); + let tokenDigest: string; + let emailDigest: string; + try { + tokenDigest = this.ports.digest.digestToken(raw); + emailDigest = this.ports.digest.digestEmail(normalized.value); + } catch { + return unavailable(); + } + if (this.ports.admission) { + try { + if (!(await this.ports.admission.allow(emailDigest, issuedAt))) + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + } catch { + return unavailable(); + } + } + const expiresAt = new Date( + Date.parse(issuedAt) + RECOVERY_CHALLENGE_MAX_SECONDS_V1 * 1_000, + ).toISOString(); + try { + return await this.ports.repository.withTransaction(async (transaction) => { + const userId = await transaction.findUserIdByEmail(normalized.value); + if (!userId) + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + const active = await transaction.findActiveChallengeForUser(userId); + const challenge = createRecoveryChallengeV1({ + id: challengeId, + userId, + tokenDigest, + emailDigest, + issuedAt, + expiresAt, + }); + if (!challenge.accepted) return unavailable(); + try { + await this.ports.delivery.deliver({ + challengeId: challenge.value.id, + recipientEmail: normalized.value, + rawToken: raw, + expiresAt: challenge.value.expiresAt, + }); + } catch { + return unavailable(); + } + if (active) { + const revoked = revokeRecoveryChallengeV1(active, issuedAt); + if (!revoked.accepted) return unavailable(); + await transaction.saveChallenge(revoked.value); + } + await transaction.saveChallenge(challenge.value); + return Object.freeze({ accepted: true as const, value: { requested: true as const } }); + }); + } catch { + return unavailable(); + } + } + + public async complete( + rawTokenInput: unknown, + newPassword: unknown, + ): Promise { + const raw = rawToken(rawTokenInput); + if (!raw) return inputRejected('INVALID_TOKEN'); + let digest: string; + try { + digest = this.ports.digest.digestToken(raw); + } catch { + return unavailable(); + } + const now = timestamp(this.ports.clock); + if (!now) return unavailable(); + + if (this.ports.completionAdmission) { + try { + if (!(await this.ports.completionAdmission.allow(digest, now))) + return inputRejected('INVALID_TOKEN'); + } catch { + return unavailable(); + } + } + + // Resolve and validate the challenge before doing expensive password work. This + // keeps unknown, expired, and already-consumed tokens cheap and indistinguishable. + let candidate: ReturnType | undefined; + try { + candidate = await this.ports.repository.withTransaction(async (transaction) => { + const challenge = await transaction.findChallengeByTokenDigest(digest); + return challenge ? consumeRecoveryChallengeV1(challenge, now) : undefined; + }); + } catch { + return unavailable(); + } + if (!candidate?.accepted) return inputRejected('INVALID_TOKEN'); + + const credential = await this.ports.passwordCredentials.create(newPassword); + if (!credential.accepted) { + return inputRejected( + credential.code === 'INVALID_PASSWORD' ? 'INVALID_INPUT' : 'RECOVERY_UNAVAILABLE', + ); + } + + let credentialId: StableIdentifierV1; + try { + const parsedId = stable(this.ports.ids.next()); + if (!parsedId) return unavailable(); + credentialId = parsedId; + } catch { + return unavailable(); + } + + try { + return await this.ports.repository.withTransaction(async (transaction) => { + const challenge = await transaction.findChallengeByTokenDigest(digest); + if (!challenge) return inputRejected('INVALID_TOKEN'); + const consumed = consumeRecoveryChallengeV1(challenge, now); + if (!consumed.accepted) return inputRejected('INVALID_TOKEN'); + await transaction.completeRecovery({ + challenge: consumed.value, + credentialId, + credential: credential.value, + }); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ + userId: consumed.value.userId, + mfaReenrollmentRequired: true as const, + }), + }); + }); + } catch { + return unavailable(); + } + } +} diff --git a/services/api/src/features/iam/application/registration-problem.error.ts b/services/api/src/features/iam/application/registration-problem.error.ts new file mode 100644 index 00000000..697ece10 --- /dev/null +++ b/services/api/src/features/iam/application/registration-problem.error.ts @@ -0,0 +1,10 @@ +export type RegistrationProblemCodeV1 = + | 'REGISTRATION_REQUEST_REJECTED' + | 'REGISTRATION_UNAVAILABLE'; + +export class RegistrationProblemError extends Error { + public constructor(readonly code: RegistrationProblemCodeV1) { + super(code); + this.name = 'RegistrationProblemError'; + } +} diff --git a/services/api/src/features/iam/application/registration-repository.port.ts b/services/api/src/features/iam/application/registration-repository.port.ts new file mode 100644 index 00000000..fe5eeddb --- /dev/null +++ b/services/api/src/features/iam/application/registration-repository.port.ts @@ -0,0 +1,45 @@ +import type { PasswordCredentialV1 } from '../domain/password-credential.js'; +import type { PersonalOrganizationBootstrapV1 } from '@databreeze/domain/identity/v1'; + +export const IAM_REGISTRATION_REPOSITORY_PORT = Symbol('IAM_REGISTRATION_REPOSITORY_PORT'); + +export interface RegistrationPersistenceInputV1 { + readonly email: string; + readonly credentialId: string; + readonly credential: PasswordCredentialV1; + readonly bootstrap: PersonalOrganizationBootstrapV1; +} + +export interface RegistrationTransactionPortV1 { + /** Exact normalized lookup; callers must not use this to disclose account existence. */ + findByEmail(email: string): Promise; + save(input: RegistrationPersistenceInputV1): Promise; +} + +export interface RegistrationRepositoryPortV1 { + withTransaction( + work: (transaction: RegistrationTransactionPortV1) => Promise, + ): Promise; +} + +/** Adapter-level signal for a concurrent unique-email race. */ +export class RegistrationConflictError extends Error { + public constructor() { + super('IAM_REGISTRATION_CONFLICT'); + this.name = 'RegistrationConflictError'; + } +} + +export type RegistrationFailureCodeV1 = + | 'INVALID_INPUT' + | 'REGISTRATION_REJECTED' + | 'REGISTRATION_UNAVAILABLE'; + +export interface RegistrationValueV1 { + readonly bootstrap: PersonalOrganizationBootstrapV1; + readonly email: string; +} + +export type RegistrationResultV1 = + | { readonly accepted: true; readonly value: RegistrationValueV1 } + | { readonly accepted: false; readonly code: RegistrationFailureCodeV1 }; diff --git a/services/api/src/features/iam/application/registration.service.ts b/services/api/src/features/iam/application/registration.service.ts new file mode 100644 index 00000000..1154faa5 --- /dev/null +++ b/services/api/src/features/iam/application/registration.service.ts @@ -0,0 +1,108 @@ +import { + bootstrapPersonalOrganizationV1, + isBoundedTextV1, + normalizeEmailAddressV1, + type LocaleV1, +} from '@databreeze/domain/identity/v1'; + +import type { PasswordCredentialService } from './password-credential.service.js'; +import { + RegistrationConflictError, + type RegistrationRepositoryPortV1, + type RegistrationResultV1, +} from './registration-repository.port.js'; + +export const IAM_REGISTRATION_SERVICE = Symbol('IAM_REGISTRATION_SERVICE'); + +export interface RegistrationInputV1 { + readonly email: unknown; + readonly displayName: unknown; + readonly password: unknown; + readonly locale?: unknown; +} + +export interface RegistrationClockV1 { + now(): Date; +} + +export interface RegistrationIdGeneratorV1 { + next(): string; +} + +export interface RegistrationServicePortsV1 { + readonly repository: RegistrationRepositoryPortV1; + readonly passwordCredentials: PasswordCredentialService; + readonly ids: RegistrationIdGeneratorV1; + readonly clock?: RegistrationClockV1; +} + +function now(clock: RegistrationClockV1 | undefined): Date { + return clock?.now() ?? new Date(); +} + +function locale(input: unknown): LocaleV1 | undefined { + return input === undefined ? 'vi-VN' : input === 'vi-VN' || input === 'en' ? input : undefined; +} + +/** Creates a user, password credential, and personal owner hierarchy atomically. */ +export class RegistrationService { + public constructor(private readonly ports: RegistrationServicePortsV1) {} + + public async register(input: RegistrationInputV1): Promise { + const email = normalizeEmailAddressV1(input.email); + const selectedLocale = locale(input.locale); + if (!email.accepted || !isBoundedTextV1(input.displayName, 200) || !selectedLocale) + return Object.freeze({ accepted: false, code: 'INVALID_INPUT' as const }); + + // Password hashing intentionally occurs before the uniqueness check so an existing account + // cannot be distinguished by a cheap fast path. The raw password never enters a repository. + const credential = await this.ports.passwordCredentials.create(input.password); + if (!credential.accepted) { + return Object.freeze({ + accepted: false, + code: + credential.code === 'INVALID_PASSWORD' + ? ('INVALID_INPUT' as const) + : ('REGISTRATION_UNAVAILABLE' as const), + }); + } + + const createdAt = now(this.ports.clock).toISOString(); + const bootstrap = bootstrapPersonalOrganizationV1({ + user: { + id: this.ports.ids.next(), + displayName: input.displayName, + locale: selectedLocale, + createdAt, + }, + organizationId: this.ports.ids.next(), + workspaceId: this.ports.ids.next(), + projectId: this.ports.ids.next(), + membershipId: this.ports.ids.next(), + createdAt, + }); + if (!bootstrap.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_INPUT' as const }); + + try { + return await this.ports.repository.withTransaction(async (transaction) => { + if (await transaction.findByEmail(email.value)) + return Object.freeze({ accepted: false, code: 'REGISTRATION_REJECTED' as const }); + await transaction.save({ + email: email.value, + credentialId: this.ports.ids.next(), + credential: credential.value, + bootstrap: bootstrap.value, + }); + return Object.freeze({ + accepted: true as const, + value: Object.freeze({ bootstrap: bootstrap.value, email: email.value }), + }); + }); + } catch (error) { + if (error instanceof RegistrationConflictError) + return Object.freeze({ accepted: false, code: 'REGISTRATION_REJECTED' as const }); + return Object.freeze({ accepted: false, code: 'REGISTRATION_UNAVAILABLE' as const }); + } + } +} diff --git a/services/api/src/features/iam/application/service-account-problem.error.ts b/services/api/src/features/iam/application/service-account-problem.error.ts new file mode 100644 index 00000000..66ff60f5 --- /dev/null +++ b/services/api/src/features/iam/application/service-account-problem.error.ts @@ -0,0 +1,15 @@ +export type ServiceAccountProblemCodeV1 = + | 'SERVICE_ACCOUNT_REQUEST_REJECTED' + | 'SERVICE_ACCOUNT_SCOPE_DENIED' + | 'SERVICE_ACCOUNT_NOT_FOUND' + | 'SERVICE_ACCOUNT_CONFLICT' + | 'SERVICE_ACCOUNT_REVOKED' + | 'SERVICE_ACCOUNT_EXPIRED' + | 'SERVICE_ACCOUNT_UNAVAILABLE'; + +export class ServiceAccountProblemError extends Error { + public constructor(readonly code: ServiceAccountProblemCodeV1) { + super(code); + this.name = 'ServiceAccountProblemError'; + } +} diff --git a/services/api/src/features/iam/application/service-account-repository.port.ts b/services/api/src/features/iam/application/service-account-repository.port.ts new file mode 100644 index 00000000..c7fc438c --- /dev/null +++ b/services/api/src/features/iam/application/service-account-repository.port.ts @@ -0,0 +1,31 @@ +import type { ServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const SERVICE_ACCOUNT_REPOSITORY_PORT = Symbol('SERVICE_ACCOUNT_REPOSITORY_PORT'); + +export interface ServiceAccountTransactionPortV1 { + findServiceAccount( + context: IamTenantContextV1, + serviceAccountId: StableIdentifierV1, + ): Promise; + findServiceAccountByDigest( + context: IamTenantContextV1, + secretDigest: string, + ): Promise; + listServiceAccounts(context: IamTenantContextV1): Promise; + saveServiceAccount(context: IamTenantContextV1, account: ServiceAccountV1): Promise; + replaceServiceAccount( + context: IamTenantContextV1, + account: ServiceAccountV1, + expectedRevision: number, + ): Promise; +} + +export interface ServiceAccountRepositoryPortV1 extends ServiceAccountTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: ServiceAccountTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/iam/application/service-account.service.ts b/services/api/src/features/iam/application/service-account.service.ts new file mode 100644 index 00000000..26009f35 --- /dev/null +++ b/services/api/src/features/iam/application/service-account.service.ts @@ -0,0 +1,458 @@ +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; + +import { + createServiceAccountV1, + isServiceAccountSecretUsableV1, + markServiceAccountUsedV1, + revokeServiceAccountV1, + rotateServiceAccountSecretV1, + type ServiceAccountV1, + type ServiceAccountErrorCodeV1, +} from '@databreeze/domain/service-account/v1'; +import { + roleHasPermissionV1, + PERMISSIONS_V1, + type PermissionV1, +} from '@databreeze/domain/permissions/v1'; +import { + parseStableIdentifierV1, + tenantScopeContainsV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamRepositoryPortV1 } from './iam-repository.port.js'; +import type { ServiceAccountRepositoryPortV1 } from './service-account-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +export const SERVICE_ACCOUNT_SERVICE = Symbol('SERVICE_ACCOUNT_SERVICE'); + +export interface ServiceAccountSecretIssueV1 { + readonly secret: string; + readonly digest: string; +} + +export interface ServiceAccountSecretIssuerV1 { + issue(): ServiceAccountSecretIssueV1; +} + +export type ServiceAccountClockV1 = () => Date; +export type ServiceAccountIdGeneratorV1 = () => string; + +export type ServiceAccountSafeViewV1 = Omit; + +export interface IssuedServiceAccountV1 { + readonly account: ServiceAccountSafeViewV1; + /** Returned only from create/rotate; never persisted or logged. */ + readonly secret: string; +} + +export type ServiceAccountPrincipalV1 = ServiceAccountSafeViewV1; + +export type ServiceAccountApplicationCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_INPUT' + | 'SCOPE_DENIED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'INVALID_CREDENTIALS' + | 'REVOKED' + | 'EXPIRED' + | 'UNAVAILABLE'; + +export type ServiceAccountApplicationResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: ServiceAccountApplicationCodeV1 }; + +export interface CreateServiceAccountInputV1 { + readonly name: unknown; + readonly workspaceId?: unknown; + readonly permissions: unknown; + readonly secretExpiresAt?: unknown; +} + +function accepted(value: TValue): ServiceAccountApplicationResultV1 { + return Object.freeze({ accepted: true, value }); +} + +function rejected(code: ServiceAccountApplicationCodeV1): ServiceAccountApplicationResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function unavailable(): ServiceAccountApplicationResultV1 { + return rejected('UNAVAILABLE'); +} + +function safeView(account: ServiceAccountV1): ServiceAccountSafeViewV1 { + const { secretDigest: _secretDigest, ...withoutDigest } = account; + void _secretDigest; + return Object.freeze({ + ...withoutDigest, + permissions: Object.freeze([...withoutDigest.permissions]), + }); +} + +function mapDomainCode(code: ServiceAccountErrorCodeV1): ServiceAccountApplicationCodeV1 { + if (code === 'INVALID_IDENTIFIER') return 'INVALID_IDENTIFIER'; + if (code === 'INVALID_STATE' || code === 'SECRET_REVOKED') return 'REVOKED'; + if (code === 'SECRET_EXPIRED') return 'EXPIRED'; + if (code === 'REVISION_CONFLICT') return 'CONFLICT'; + if (code === 'INVALID_PERMISSION' || code === 'INVALID_TEXT' || code === 'INVALID_LIFETIME') + return 'INVALID_INPUT'; + return 'INVALID_INPUT'; +} + +function mapRepositoryError(error: unknown): ServiceAccountApplicationCodeV1 { + const message = error instanceof Error ? error.message : ''; + if (message === 'SCOPE_DENIED') return 'SCOPE_DENIED'; + if (message === 'SERVICE_ACCOUNT_NOT_FOUND') return 'NOT_FOUND'; + if ( + message === 'REVISION_CONFLICT' || + message === 'INVALID_REVISION' || + message.endsWith('CONFLICT') + ) + return 'CONFLICT'; + return 'UNAVAILABLE'; +} + +function digestSecret(input: unknown): string | undefined { + if ( + typeof input !== 'string' || + input.length === 0 || + input.length > 512 || + /\p{Cc}/u.test(input) + ) + return undefined; + return createHash('sha256').update(input, 'utf8').digest('hex'); +} + +function safeDigestEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +function identifier(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scopeForAccount( + context: IamTenantContextV1, + workspaceId: StableIdentifierV1 | undefined, +): TenantScopeV1 | undefined { + if (workspaceId === undefined) { + return context.tenantScope.scopeType === 'organization' + ? { scopeType: 'organization', organizationId: context.tenantScope.organizationId } + : undefined; + } + const scope: TenantScopeV1 = { + scopeType: 'workspace', + organizationId: context.tenantScope.organizationId, + workspaceId, + }; + return tenantScopeContainsV1(context.tenantScope, scope) ? scope : undefined; +} + +function accountScope(account: ServiceAccountV1): TenantScopeV1 { + return account.workspaceId === undefined + ? { scopeType: 'organization', organizationId: account.organizationId } + : { + scopeType: 'workspace', + organizationId: account.organizationId, + workspaceId: account.workspaceId, + }; +} + +function serviceAccountPermissions(input: unknown): input is readonly PermissionV1[] { + return ( + Array.isArray(input) && + !input.some( + (permission) => + permission === PERMISSIONS_V1.SERVICE_ACCOUNT_READ || + permission === PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE || + permission === PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, + ) + ); +} + +/** IAM-013: action-scoped service identities with one-time credential issuance. */ +export class ServiceAccountService { + public constructor( + private readonly repository: ServiceAccountRepositoryPortV1, + private readonly iamRepository: IamRepositoryPortV1, + private readonly secretIssuer: ServiceAccountSecretIssuerV1, + private readonly clock: ServiceAccountClockV1 = () => new Date(), + private readonly idGenerator: ServiceAccountIdGeneratorV1 = () => randomUUID(), + ) {} + + public async create( + context: IamTenantContextV1, + input: CreateServiceAccountInputV1, + ): Promise> { + const workspaceId = input.workspaceId === undefined ? undefined : identifier(input.workspaceId); + if (input.workspaceId !== undefined && workspaceId === undefined) + return rejected('INVALID_IDENTIFIER'); + const targetScope = scopeForAccount(context, workspaceId); + if (!targetScope) return rejected('SCOPE_DENIED'); + const authorization = await this.authorize( + context, + targetScope, + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + if (!serviceAccountPermissions(input.permissions)) return rejected('INVALID_INPUT'); + let now: string; + let id: string; + let secret: ServiceAccountSecretIssueV1; + try { + now = this.clock().toISOString(); + id = this.idGenerator(); + secret = this.secretIssuer.issue(); + } catch { + return rejected('UNAVAILABLE'); + } + const candidate = createServiceAccountV1({ + id, + organizationId: context.tenantScope.organizationId, + ...(workspaceId === undefined ? {} : { workspaceId }), + name: input.name, + permissions: input.permissions, + secretDigest: secret.digest, + secretIssuedAt: now, + ...(input.secretExpiresAt === undefined ? {} : { secretExpiresAt: input.secretExpiresAt }), + createdAt: now, + }); + if (!candidate.accepted) return rejected(mapDomainCode(candidate.code)); + try { + await this.repository.saveServiceAccount(context, candidate.value); + return accepted(Object.freeze({ account: safeView(candidate.value), secret: secret.secret })); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + } + + public async list( + context: IamTenantContextV1, + ): Promise> { + const authorization = await this.authorize( + context, + context.tenantScope, + PERMISSIONS_V1.SERVICE_ACCOUNT_READ, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + try { + return accepted((await this.repository.listServiceAccounts(context)).map(safeView)); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + } + + /** Authenticate an already-scoped service-account bearer and advance last-use atomically. */ + public async authenticate( + context: IamTenantContextV1, + presentedSecret: unknown, + nowInput: unknown, + ): Promise> { + const digest = digestSecret(presentedSecret); + if (!digest) return rejected('INVALID_CREDENTIALS'); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccountByDigest(context, digest); + if (!current || !safeDigestEqual(current.secretDigest, digest)) + return rejected('INVALID_CREDENTIALS'); + const usable = isServiceAccountSecretUsableV1(current, nowInput); + if (!usable.accepted) return rejected('INVALID_CREDENTIALS'); + const used = markServiceAccountUsedV1(current, nowInput); + if (!used.accepted) return rejected('INVALID_CREDENTIALS'); + try { + await transaction.replaceServiceAccount(context, used.value, current.revision); + return accepted(safeView(used.value)); + } catch (error) { + const mapped = mapRepositoryError(error); + return rejected(mapped === 'CONFLICT' ? 'CONFLICT' : 'UNAVAILABLE'); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); + } + + public async rotate( + context: IamTenantContextV1, + serviceAccountIdInput: unknown, + expectedRevisionInput: unknown, + secretExpiresAt?: unknown, + ): Promise> { + const serviceAccountId = identifier(serviceAccountIdInput); + if (!serviceAccountId) return rejected('INVALID_IDENTIFIER'); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_MANAGE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + let now: string; + let secret: ServiceAccountSecretIssueV1; + try { + now = this.clock().toISOString(); + secret = this.secretIssuer.issue(); + } catch { + return rejected('UNAVAILABLE'); + } + const rotated = rotateServiceAccountSecretV1(current, { + secretDigest: secret.digest, + issuedAt: now, + ...(secretExpiresAt === undefined ? {} : { expiresAt: secretExpiresAt }), + expectedRevision: expectedRevisionInput, + }); + if (!rotated.accepted) return rejected(mapDomainCode(rotated.code)); + try { + await transaction.replaceServiceAccount(context, rotated.value, current.revision); + return accepted( + Object.freeze({ account: safeView(rotated.value), secret: secret.secret }), + ); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); + } + + public async revoke( + context: IamTenantContextV1, + serviceAccountIdInput: unknown, + expectedRevisionInput: unknown, + ): Promise> { + const serviceAccountId = identifier(serviceAccountIdInput); + if (!serviceAccountId) return rejected('INVALID_IDENTIFIER'); + if ( + typeof expectedRevisionInput !== 'number' || + !Number.isSafeInteger(expectedRevisionInput) || + expectedRevisionInput < 1 + ) + return rejected('CONFLICT'); + return this.repository + .withTransaction(context, async (transaction) => { + const current = await transaction.findServiceAccount(context, serviceAccountId); + if (!current) return rejected('NOT_FOUND'); + const authorization = await this.authorize( + context, + accountScope(current), + PERMISSIONS_V1.SERVICE_ACCOUNT_REVOKE, + ); + if (authorization !== 'ALLOWED') return rejected(authorization); + const now = this.now(); + if (!now) return rejected('UNAVAILABLE'); + const revoked = revokeServiceAccountV1(current, now, expectedRevisionInput); + if (!revoked.accepted) return rejected(mapDomainCode(revoked.code)); + try { + await transaction.replaceServiceAccount(context, revoked.value, current.revision); + return accepted(safeView(revoked.value)); + } catch (error) { + return rejected(mapRepositoryError(error)); + } + }) + .catch((error) => rejected(mapRepositoryError(error))); + } + + public validateSecret( + account: ServiceAccountV1, + nowInput: unknown, + ): ServiceAccountApplicationResultV1 { + const result = isServiceAccountSecretUsableV1(account, nowInput); + return result.accepted ? result : rejected(mapDomainCode(result.code)); + } + + private now(): string | undefined { + try { + const now = this.clock(); + return now instanceof Date && Number.isFinite(now.getTime()) ? now.toISOString() : undefined; + } catch { + return undefined; + } + } + + private async authorize( + context: IamTenantContextV1, + targetScope: TenantScopeV1, + permission: PermissionV1, + ): Promise<'ALLOWED' | 'SCOPE_DENIED' | 'UNAVAILABLE'> { + if (!tenantScopeContainsV1(context.tenantScope, targetScope)) return 'SCOPE_DENIED'; + try { + const membership = await this.iamRepository.findMembership(context, context.actorId); + if ( + !membership || + !tenantScopeContainsV1(membership.scope, targetScope) || + !roleHasPermissionV1(membership.roleId, permission) + ) + return 'SCOPE_DENIED'; + return 'ALLOWED'; + } catch { + return 'UNAVAILABLE'; + } + } +} + +/** Safe default for hosts that have not composed an IAM membership repository yet. */ +export class UnavailableServiceAccountService { + public create( + _context: IamTenantContextV1, + _input: CreateServiceAccountInputV1, + ): Promise> { + void _context; + void _input; + return Promise.resolve(unavailable()); + } + + public list( + _context: IamTenantContextV1, + ): Promise> { + void _context; + return Promise.resolve(unavailable()); + } + + public rotate( + _context: IamTenantContextV1, + _serviceAccountId: unknown, + _expectedRevision: unknown, + _secretExpiresAt?: unknown, + ): Promise> { + void _context; + void _serviceAccountId; + void _expectedRevision; + void _secretExpiresAt; + return Promise.resolve(unavailable()); + } + + public revoke( + _context: IamTenantContextV1, + _serviceAccountId: unknown, + _expectedRevision: unknown, + ): Promise> { + void _context; + void _serviceAccountId; + void _expectedRevision; + return Promise.resolve(unavailable()); + } + + public authenticate( + _context: IamTenantContextV1, + _presentedSecret: unknown, + _now: unknown, + ): Promise> { + void _context; + void _presentedSecret; + void _now; + return Promise.resolve(unavailable()); + } +} diff --git a/services/api/src/features/iam/application/tenant-context.ts b/services/api/src/features/iam/application/tenant-context.ts index fac5078d..5e7e0498 100644 --- a/services/api/src/features/iam/application/tenant-context.ts +++ b/services/api/src/features/iam/application/tenant-context.ts @@ -12,6 +12,7 @@ export interface IamTenantContextV1 { readonly idempotencyKey: string; readonly authorizationEpoch: number; readonly mfaRequired?: boolean; + readonly mfaReenrollmentRequired?: boolean; readonly expectedRevision?: number; } @@ -37,6 +38,7 @@ export function createIamTenantContextV1(input: { readonly idempotencyKey: unknown; readonly authorizationEpoch: unknown; readonly mfaRequired?: unknown; + readonly mfaReenrollmentRequired?: unknown; readonly expectedRevision?: unknown; }): IamContextResultV1 { const tenantScope = parseTenantScopeV1(input.tenantScope); @@ -59,6 +61,11 @@ export function createIamTenantContextV1(input: { return rejected('INVALID_EPOCH'); if (input.mfaRequired !== undefined && typeof input.mfaRequired !== 'boolean') return rejected('INVALID_TEXT'); + if ( + input.mfaReenrollmentRequired !== undefined && + typeof input.mfaReenrollmentRequired !== 'boolean' + ) + return rejected('INVALID_TEXT'); if ( input.expectedRevision !== undefined && (typeof input.expectedRevision !== 'number' || @@ -75,6 +82,9 @@ export function createIamTenantContextV1(input: { idempotencyKey: input.idempotencyKey, authorizationEpoch: input.authorizationEpoch, ...(input.mfaRequired === undefined ? {} : { mfaRequired: input.mfaRequired }), + ...(input.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: input.mfaReenrollmentRequired }), ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), }), }); diff --git a/services/api/src/features/iam/iam.module.ts b/services/api/src/features/iam/iam.module.ts index 9a12cecd..c6dfd3bf 100644 --- a/services/api/src/features/iam/iam.module.ts +++ b/services/api/src/features/iam/iam.module.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { type DynamicModule, Module } from '@nestjs/common'; import { AuthenticationController } from './api/authentication.controller.js'; @@ -45,6 +45,21 @@ import { } 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 { + IAM_INVITATION_SERVICE, + IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, + IamInvitationService, + type IamInvitationClockV1, + type IamInvitationDeliveryPortV1, + type IamInvitationDigestPortV1, + type IamInvitationIdGeneratorV1, + type IamPrincipalEmailLookupPortV1, + type IamInvitationTokenGeneratorV1, +} from './application/invitation.service.js'; +import { + IAM_INVITATION_REPOSITORY_PORT, + type IamInvitationRepositoryPortV1, +} from './application/invitation-repository.port.js'; import type { PasswordCredentialService } from './application/password-credential.service.js'; import { UnavailableAuthenticationAdapter } from './adapter/unavailable-authentication.adapter.js'; import { @@ -67,12 +82,76 @@ import { PrismaIamRepositoryAdapter, type IamDatabaseClientV1, } from './adapter/prisma-iam-repository.adapter.js'; +import { + PrismaIamInvitationRepositoryAdapter, + type IamInvitationDatabaseClientV1, +} from './adapter/prisma-iam-invitation-repository.adapter.js'; +import { + HmacSha256IamInvitationDigestAdapter, + randomIamInvitationIdV1, + randomIamInvitationTokenV1, + type IamInvitationDigestKeyV1, +} from './adapter/iam-invitation-crypto.adapter.js'; +import { + PrismaIamPrincipalEmailLookupAdapter, + type IamPrincipalEmailDatabaseClientV1, +} from './adapter/prisma-principal-email-lookup.adapter.js'; +import { + PrismaRegistrationRepositoryAdapter, + type RegistrationDatabaseClientV1, +} from './adapter/prisma-registration-repository.adapter.js'; +import { + IAM_REGISTRATION_REPOSITORY_PORT, + type RegistrationRepositoryPortV1, +} from './application/registration-repository.port.js'; +import { + IAM_REGISTRATION_SERVICE, + RegistrationService, + type RegistrationClockV1, + type RegistrationIdGeneratorV1, +} from './application/registration.service.js'; +import { + IAM_RECOVERY_SERVICE, + RecoveryService, + type RecoveryClockV1, + type RecoveryIdGeneratorV1, + type RecoveryTokenGeneratorV1, +} from './application/recovery.service.js'; +import { + IAM_RECOVERY_REPOSITORY_PORT, + IAM_RECOVERY_ADMISSION_PORT, + IAM_RECOVERY_COMPLETION_ADMISSION_PORT, + type RecoveryAdmissionPortV1, + type RecoveryDigestPortV1, + type RecoveryDeliveryPortV1, + type RecoveryRepositoryPortV1, +} from './application/recovery-repository.port.js'; +import { + HmacSha256IamRecoveryDigestAdapter, + randomIamRecoveryIdV1, + randomIamRecoveryTokenV1, + type IamRecoveryDigestKeyV1, +} from './adapter/iam-recovery-crypto.adapter.js'; +import { InMemoryRecoveryAdmissionAdapter } from './adapter/in-memory-recovery-admission.adapter.js'; +import { + RedisRecoveryAdmissionAdapter, + type RecoveryAdmissionCounterPortV1, + type RedisRecoveryAdmissionOptionsV1, +} from './adapter/redis-recovery-admission.adapter.js'; +import { + PrismaRecoveryRepositoryAdapter, + type RecoveryDatabaseClientV1, +} from './adapter/prisma-recovery-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 { IamInvitationController } from './api/invitation.controller.js'; +import { RegistrationController } from './api/registration.controller.js'; +import { RecoveryController } from './api/recovery.controller.js'; +import { ServiceAccountController } from './api/service-account.controller.js'; import { InMemoryDeviceIdentityRepositoryAdapter } from './adapter/in-memory-device-identity-repository.adapter.js'; import { PrismaDeviceIdentityRepositoryAdapter, @@ -88,6 +167,24 @@ import { DEVICE_IDENTITY_REPOSITORY_PORT, type DeviceIdentityRepositoryPortV1, } from './application/device-identity-repository.port.js'; +import { + SERVICE_ACCOUNT_REPOSITORY_PORT, + type ServiceAccountRepositoryPortV1, +} from './application/service-account-repository.port.js'; +import { + SERVICE_ACCOUNT_SERVICE, + ServiceAccountService, + UnavailableServiceAccountService, + type ServiceAccountClockV1, + type ServiceAccountIdGeneratorV1, + type ServiceAccountSecretIssuerV1, +} from './application/service-account.service.js'; +import { InMemoryServiceAccountRepositoryAdapter } from './adapter/in-memory-service-account-repository.adapter.js'; +import { + PrismaServiceAccountRepositoryAdapter, + type ServiceAccountDatabaseClientV1, +} from './adapter/prisma-service-account-repository.adapter.js'; +import { RandomServiceAccountSecretIssuer } from './adapter/random-service-account-secret.adapter.js'; import { REQUEST_TENANT_CONTEXT, type RequestTenantContextPortV1, @@ -118,10 +215,47 @@ export interface IamModuleOptions { readonly hierarchyDatabase?: IamHierarchyDatabaseClientV1; readonly hierarchyService?: IamHierarchyService; readonly membershipService?: IamMembershipService; + readonly invitationRepository?: IamInvitationRepositoryPortV1; + readonly invitationDatabase?: IamInvitationDatabaseClientV1; + readonly invitationService?: IamInvitationService; + readonly invitationPrincipalEmails?: IamPrincipalEmailLookupPortV1; + readonly invitationPrincipalEmailDatabase?: IamPrincipalEmailDatabaseClientV1; + readonly invitationDelivery?: IamInvitationDeliveryPortV1; + readonly invitationDigest?: IamInvitationDigestPortV1; + readonly invitationDigestKey?: IamInvitationDigestKeyV1; + readonly invitationIdGenerator?: IamInvitationIdGeneratorV1; + readonly invitationTokenGenerator?: IamInvitationTokenGeneratorV1; + readonly invitationClock?: IamInvitationClockV1; + readonly registrationRepository?: RegistrationRepositoryPortV1; + readonly registrationDatabase?: RegistrationDatabaseClientV1; + readonly registrationService?: RegistrationService; + readonly registrationIdGenerator?: RegistrationIdGeneratorV1; + readonly registrationClock?: RegistrationClockV1; + readonly recoveryRepository?: RecoveryRepositoryPortV1; + readonly recoveryDatabase?: RecoveryDatabaseClientV1; + readonly recoveryService?: RecoveryService; + readonly recoveryDelivery?: RecoveryDeliveryPortV1; + readonly recoveryDigest?: RecoveryDigestPortV1; + readonly recoveryDigestKey?: IamRecoveryDigestKeyV1; + readonly recoveryIdGenerator?: RecoveryIdGeneratorV1; + readonly recoveryTokenGenerator?: RecoveryTokenGeneratorV1; + readonly recoveryClock?: RecoveryClockV1; + readonly recoveryAdmission?: RecoveryAdmissionPortV1; + readonly recoveryAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly recoveryAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; + readonly recoveryCompletionAdmission?: RecoveryAdmissionPortV1; + readonly recoveryCompletionAdmissionCounter?: RecoveryAdmissionCounterPortV1; + readonly recoveryCompletionAdmissionOptions?: RedisRecoveryAdmissionOptionsV1; readonly deviceIdentityService?: DeviceIdentityService; readonly deviceIdentityRepository?: DeviceIdentityRepositoryPortV1; readonly deviceIdentityDatabase?: DeviceIdentityDatabaseClientV1; readonly deviceEnrollmentProofVerifier?: DeviceEnrollmentProofVerifierV1; + readonly serviceAccountService?: ServiceAccountService; + readonly serviceAccountRepository?: ServiceAccountRepositoryPortV1; + readonly serviceAccountDatabase?: ServiceAccountDatabaseClientV1; + readonly serviceAccountSecretIssuer?: ServiceAccountSecretIssuerV1; + readonly serviceAccountClock?: ServiceAccountClockV1; + readonly serviceAccountIdGenerator?: ServiceAccountIdGeneratorV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -204,6 +338,96 @@ export class IamModule { const membershipService = options.membershipService ?? (iamRepository === undefined ? undefined : new IamMembershipService(iamRepository)); + const invitationRepository = + options.invitationRepository ?? + (options.invitationDatabase === undefined + ? undefined + : new PrismaIamInvitationRepositoryAdapter(options.invitationDatabase)); + const invitationDigest = + options.invitationDigest ?? + (options.invitationDigestKey === undefined + ? undefined + : new HmacSha256IamInvitationDigestAdapter(options.invitationDigestKey)); + const invitationPrincipalEmails = + options.invitationPrincipalEmails ?? + (options.invitationPrincipalEmailDatabase === undefined + ? undefined + : new PrismaIamPrincipalEmailLookupAdapter(options.invitationPrincipalEmailDatabase)); + const invitationService = + options.invitationService ?? + (invitationRepository && + invitationPrincipalEmails && + options.invitationDelivery && + invitationDigest + ? new IamInvitationService( + invitationRepository, + invitationPrincipalEmails, + options.invitationIdGenerator ?? randomIamInvitationIdV1, + options.invitationTokenGenerator ?? randomIamInvitationTokenV1, + invitationDigest, + options.invitationDelivery, + options.invitationClock, + ) + : undefined); + const registrationRepository = + options.registrationRepository ?? + (options.registrationDatabase === undefined + ? undefined + : new PrismaRegistrationRepositoryAdapter(options.registrationDatabase)); + const registrationService = + options.registrationService ?? + (registrationRepository && options.passwordCredentials + ? new RegistrationService({ + repository: registrationRepository, + passwordCredentials: options.passwordCredentials, + ids: options.registrationIdGenerator ?? { next: () => randomUUID() }, + ...(options.registrationClock ? { clock: options.registrationClock } : {}), + }) + : undefined); + const recoveryRepository = + options.recoveryRepository ?? + (options.recoveryDatabase === undefined + ? undefined + : new PrismaRecoveryRepositoryAdapter(options.recoveryDatabase)); + const recoveryDigest = + options.recoveryDigest ?? + (options.recoveryDigestKey === undefined + ? undefined + : new HmacSha256IamRecoveryDigestAdapter(options.recoveryDigestKey)); + const recoveryAdmission = + options.recoveryAdmission ?? + (options.recoveryAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter() + : new RedisRecoveryAdmissionAdapter( + options.recoveryAdmissionCounter, + options.recoveryAdmissionOptions, + )); + const recoveryCompletionAdmission = + options.recoveryCompletionAdmission ?? + (options.recoveryCompletionAdmissionCounter === undefined + ? new InMemoryRecoveryAdmissionAdapter() + : new RedisRecoveryAdmissionAdapter(options.recoveryCompletionAdmissionCounter, { + keyPrefix: 'databreeze:iam:recovery:completion:v1:', + ...options.recoveryCompletionAdmissionOptions, + })); + const recoveryService = + options.recoveryService ?? + (recoveryRepository && + options.passwordCredentials && + options.recoveryDelivery && + recoveryDigest + ? new RecoveryService({ + repository: recoveryRepository, + passwordCredentials: options.passwordCredentials, + digest: recoveryDigest, + delivery: options.recoveryDelivery, + ids: options.recoveryIdGenerator ?? randomIamRecoveryIdV1, + tokens: options.recoveryTokenGenerator ?? randomIamRecoveryTokenV1, + admission: recoveryAdmission, + completionAdmission: recoveryCompletionAdmission, + ...(options.recoveryClock ? { clock: options.recoveryClock } : {}), + }) + : undefined); const authentication = options.authentication ?? (credentials && sessions @@ -220,11 +444,28 @@ export class IamModule { deviceIdentityRepository, options.deviceEnrollmentProofVerifier ?? new UnavailableDeviceEnrollmentProofVerifier(), ); + const serviceAccountRepository = + options.serviceAccountRepository ?? + (options.serviceAccountDatabase === undefined + ? new InMemoryServiceAccountRepositoryAdapter() + : new PrismaServiceAccountRepositoryAdapter(options.serviceAccountDatabase)); + const serviceAccountService = + options.serviceAccountService ?? + (options.iamRepository === undefined + ? new UnavailableServiceAccountService() + : new ServiceAccountService( + serviceAccountRepository, + options.iamRepository, + options.serviceAccountSecretIssuer ?? new RandomServiceAccountSecretIssuer(), + options.serviceAccountClock, + options.serviceAccountIdGenerator, + )); const exports = [ DEVICE_IDENTITY_REPOSITORY_PORT, DEVICE_IDENTITY_SERVICE, IAM_HIERARCHY_REPOSITORY, IAM_HIERARCHY_SERVICE, + SERVICE_ACCOUNT_REPOSITORY_PORT, ]; if (credentials) exports.unshift(CREDENTIAL_LOOKUP_PORT); if (sessions) exports.unshift(SESSION_LIFECYCLE_PORT); @@ -234,6 +475,16 @@ export class IamModule { if (mfaService) exports.unshift(MFA_SERVICE); if (iamRepository) exports.unshift(IAM_REPOSITORY_PORT); if (membershipService) exports.unshift(IAM_MEMBERSHIP_SERVICE); + if (invitationRepository) exports.unshift(IAM_INVITATION_REPOSITORY_PORT); + if (invitationService) exports.unshift(IAM_INVITATION_SERVICE); + if (invitationPrincipalEmails) exports.unshift(IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); + if (registrationRepository) exports.unshift(IAM_REGISTRATION_REPOSITORY_PORT); + if (registrationService) exports.unshift(IAM_REGISTRATION_SERVICE); + if (recoveryRepository) exports.unshift(IAM_RECOVERY_REPOSITORY_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_ADMISSION_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_COMPLETION_ADMISSION_PORT); + if (recoveryService) exports.unshift(IAM_RECOVERY_SERVICE); + exports.unshift(SERVICE_ACCOUNT_SERVICE); return { module: IamModule, controllers: [ @@ -242,7 +493,11 @@ export class IamModule { MfaController, IamHierarchyController, IamMembershipController, + IamInvitationController, + RegistrationController, + RecoveryController, IamBootstrapController, + ServiceAccountController, ], providers: [ { @@ -321,6 +576,70 @@ export class IamModule { }, ] : []), + ...(invitationRepository + ? [ + { + provide: IAM_INVITATION_REPOSITORY_PORT, + useValue: invitationRepository, + }, + ] + : []), + ...(invitationService + ? [ + { + provide: IAM_INVITATION_SERVICE, + useValue: invitationService, + }, + ] + : []), + ...(invitationPrincipalEmails + ? [ + { + provide: IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, + useValue: invitationPrincipalEmails, + }, + ] + : []), + ...(registrationRepository + ? [ + { + provide: IAM_REGISTRATION_REPOSITORY_PORT, + useValue: registrationRepository, + }, + ] + : []), + ...(registrationService + ? [ + { + provide: IAM_REGISTRATION_SERVICE, + useValue: registrationService, + }, + ] + : []), + ...(recoveryRepository + ? [ + { + provide: IAM_RECOVERY_REPOSITORY_PORT, + useValue: recoveryRepository, + }, + ] + : []), + ...(recoveryService + ? [ + { + provide: IAM_RECOVERY_SERVICE, + useValue: recoveryService, + }, + { + provide: IAM_RECOVERY_ADMISSION_PORT, + useValue: recoveryAdmission, + }, + { + provide: IAM_RECOVERY_COMPLETION_ADMISSION_PORT, + useValue: recoveryCompletionAdmission, + }, + ] + : []), { provide: DEVICE_IDENTITY_REPOSITORY_PORT, useValue: deviceIdentityRepository, @@ -329,6 +648,14 @@ export class IamModule { provide: DEVICE_IDENTITY_SERVICE, useValue: deviceIdentityService, }, + { + provide: SERVICE_ACCOUNT_REPOSITORY_PORT, + useValue: serviceAccountRepository, + }, + { + provide: SERVICE_ACCOUNT_SERVICE, + useValue: serviceAccountService, + }, { provide: REQUEST_TENANT_CONTEXT, useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), diff --git a/services/api/src/features/jra/application/approval.service.ts b/services/api/src/features/jra/application/approval.service.ts index 0bb433e7..7a1383f3 100644 --- a/services/api/src/features/jra/application/approval.service.ts +++ b/services/api/src/features/jra/application/approval.service.ts @@ -14,7 +14,12 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { ApprovalRepositoryPortV1 } from './approval-repository.port.js'; function rejected( - code: 'INVALID_IDENTIFIER' | 'INVALID_ROLE' | 'REQUEST_NOT_OPEN' | 'SUBJECT_HASH_MISMATCH', + code: + | 'INVALID_IDENTIFIER' + | 'INVALID_ROLE' + | 'REQUEST_NOT_OPEN' + | 'SUBJECT_HASH_MISMATCH' + | 'MFA_REENROLLMENT_REQUIRED', ): ApprovalResultV1 { return Object.freeze({ accepted: false, code }); } @@ -72,6 +77,7 @@ export class ApprovalService { readonly decision: ApprovalDecisionRecordV1; }> > { + if (context.mfaReenrollmentRequired === true) return rejected('MFA_REENROLLMENT_REQUIRED'); return this.repository.withTransaction(context, async (transaction) => { const request = await transaction.findRequest(context, input.requestId); if (!request) return rejected('INVALID_IDENTIFIER'); diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 9112e266..61ab19b9 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -12,6 +12,9 @@ import { SessionProblemError } from '../../features/iam/application/session-prob import { MfaProblemError } from '../../features/iam/application/mfa-problem.error.js'; import { EntitlementProblemError } from '../../features/bua/application/entitlement-problem.error.js'; import { DeviceIdentityProblemError } from '../../features/iam/application/device-identity-problem.error.js'; +import { InvitationProblemError } from '../../features/iam/application/invitation-problem.error.js'; +import { RegistrationProblemError } from '../../features/iam/application/registration-problem.error.js'; +import { RecoveryProblemError } from '../../features/iam/application/recovery-problem.error.js'; import { AuditProblemError } from '../../features/aud/application/audit-problem.error.js'; import { ArtifactExportProblemError } from '../../features/iae/application/artifact-export-problem.error.js'; import { RequestTenantContextProblemError } from './request-tenant-context.port.js'; @@ -106,16 +109,81 @@ function describe(error: unknown, correlationId: string): ProblemInput { status, }; } + if (error instanceof InvitationProblemError) { + const status = + error.code === 'INVITATION_UNAVAILABLE' || error.code === 'INVITATION_DELIVERY_UNAVAILABLE' + ? HttpStatus.SERVICE_UNAVAILABLE + : error.code === 'INVITATION_SCOPE_DENIED' + ? HttpStatus.FORBIDDEN + : error.code === 'INVITATION_NOT_FOUND' + ? HttpStatus.NOT_FOUND + : error.code === 'INVITATION_CONFLICT' + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST; + return { + code: error.code, + correlationId, + messageKey: `api.error.${error.code.toLowerCase()}`, + retryable: + error.code === 'INVITATION_UNAVAILABLE' || error.code === 'INVITATION_DELIVERY_UNAVAILABLE', + status, + }; + } + if (error instanceof RegistrationProblemError) { + const unavailable = error.code === 'REGISTRATION_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.registration_unavailable' + : 'api.error.registration_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } + if (error instanceof RecoveryProblemError) { + const unavailable = error.code === 'RECOVERY_UNAVAILABLE'; + return { + code: error.code, + correlationId, + messageKey: unavailable + ? 'api.error.recovery_unavailable' + : error.code === 'RECOVERY_TOKEN_INVALID' + ? 'api.error.recovery_token_invalid' + : 'api.error.recovery_request_rejected', + retryable: unavailable, + status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + }; + } if (error instanceof AuditProblemError) { + const attestationUnavailable = error.code === 'AUDIT_ATTESTATION_UNAVAILABLE'; + const attestationNotFound = error.code === 'AUDIT_ATTESTATION_NOT_FOUND'; + const attestationInvalid = error.code === 'AUDIT_ATTESTATION_REQUEST_INVALID'; const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { code: error.code, correlationId, messageKey: integrityInvalid ? 'api.error.audit_integrity_invalid' - : 'api.error.audit_unavailable', - retryable: !integrityInvalid, - status: integrityInvalid ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.SERVICE_UNAVAILABLE, + : attestationUnavailable + ? 'api.error.audit_attestation_unavailable' + : attestationNotFound + ? 'api.error.audit_attestation_not_found' + : attestationInvalid + ? 'api.error.audit_attestation_invalid' + : 'api.error.audit_unavailable', + retryable: + attestationUnavailable || + (!integrityInvalid && !attestationNotFound && !attestationInvalid), + status: integrityInvalid + ? HttpStatus.INTERNAL_SERVER_ERROR + : attestationUnavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : attestationNotFound + ? HttpStatus.NOT_FOUND + : attestationInvalid + ? HttpStatus.BAD_REQUEST + : HttpStatus.SERVICE_UNAVAILABLE, }; } if (error instanceof ArtifactExportProblemError) { diff --git a/services/api/src/platform/http/session-tenant-context.adapter.ts b/services/api/src/platform/http/session-tenant-context.adapter.ts index 00199e9b..6ca30025 100644 --- a/services/api/src/platform/http/session-tenant-context.adapter.ts +++ b/services/api/src/platform/http/session-tenant-context.adapter.ts @@ -93,6 +93,9 @@ export class SessionRequestTenantContextAdapter implements RequestTenantContextP idempotencyKey: idempotencyKey(input), authorizationEpoch: principal.securityEpoch, mfaRequired: principal.mfaRequired, + ...(principal.mfaReenrollmentRequired === undefined + ? {} + : { mfaReenrollmentRequired: principal.mfaReenrollmentRequired }), }); if (!context.accepted) throw new RequestTenantContextProblemError('CONTEXT_INVALID'); return context.value; diff --git a/services/api/test/features/aud/aud.module.test.ts b/services/api/test/features/aud/aud.module.test.ts new file mode 100644 index 00000000..4e41bd2a --- /dev/null +++ b/services/api/test/features/aud/aud.module.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AudModule } from '../../../src/features/aud/aud.module.js'; +import { AUDIT_ATTESTATION_REPOSITORY_PORT } from '../../../src/features/aud/application/audit-attestation-repository.port.js'; +import { AUDIT_ATTESTATION_SERVICE } from '../../../src/features/aud/application/audit-attestation.service.js'; +import { AUDIT_LEDGER_SERVICE } from '../../../src/features/aud/aud.module.js'; + +void test('[AUD-015, AUD-016] module composition keeps attestations behind replaceable ports', () => { + const dynamic = AudModule.register({ + auditAttestationSigner: { + sign: (payload) => payload, + verify: (payload, signature) => payload === signature, + }, + }); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === AUDIT_ATTESTATION_REPOSITORY_PORT, + ), + true, + ); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === AUDIT_ATTESTATION_SERVICE, + ), + true, + ); + assert.equal(dynamic.exports?.includes(AUDIT_LEDGER_SERVICE), true); +}); diff --git a/services/api/test/features/aud/audit-attestation-contract.test.ts b/services/api/test/features/aud/audit-attestation-contract.test.ts new file mode 100644 index 00000000..76bd9dd1 --- /dev/null +++ b/services/api/test/features/aud/audit-attestation-contract.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AuditSealAttestationV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { sameAuditSealAttestationV1 } from '../../../src/features/aud/application/audit-equality.js'; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid identifier'); + return result.value; +} + +function timestamp(value: string) { + const result = parseStrictUtcTimestampV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid timestamp'); + return result.value; +} + +void test('[AUD-015, AUD-016] attestation equality includes signer binding and signature bytes', () => { + const base: AuditSealAttestationV1 = { + schemaVersion: 1, + attestationId: stable('00000000-0000-4000-8000-000000000801'), + tenantScope: { + scopeType: 'organization' as const, + organizationId: stable('00000000-0000-4000-8000-000000000802'), + }, + firstSequence: 1, + lastSequence: 2, + eventCount: 2, + rootDigest: 'root', + sealedAt: timestamp('2026-01-01T00:00:00.000Z'), + signerKeyId: 'key-1', + payload: 'payload', + signature: 'signature', + }; + assert.equal(sameAuditSealAttestationV1(base, { ...base }), true); + assert.equal(sameAuditSealAttestationV1(base, { ...base, signerKeyId: 'key-2' }), false); + assert.equal(sameAuditSealAttestationV1(base, { ...base, signature: 'tampered' }), false); +}); diff --git a/services/api/test/features/aud/audit-attestation-repository.test.ts b/services/api/test/features/aud/audit-attestation-repository.test.ts new file mode 100644 index 00000000..aec06a2e --- /dev/null +++ b/services/api/test/features/aud/audit-attestation-repository.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAuditSealAttestationV1, + type AuditSealAttestationV1, + type AuditSealV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryAuditAttestationRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000811'; +const workspaceId = '00000000-0000-4000-8000-000000000812'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000813'; +const actorId = '00000000-0000-4000-8000-000000000814'; +const correlationId = '00000000-0000-4000-8000-000000000815'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context(workspace = workspaceId, idempotencyKey = 'attestation') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function attestation(): AuditSealAttestationV1 { + const seal: AuditSealV1 = { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 3, + eventCount: 3, + rootDigest: 'root-digest', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + const created = createAuditSealAttestationV1( + seal, + { attestationId: '00000000-0000-4000-8000-000000000816', signerKeyId: 'key-1' }, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + ); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid attestation'); + return created.value; +} + +void test('[AUD-015, AUD-016] attestation storage is immutable and scope isolated', async () => { + const repository = new InMemoryAuditAttestationRepositoryAdapter(); + const value = attestation(); + await repository.saveAttestation(context(), value); + assert.deepEqual(await repository.findAttestation(context(), stable(value.attestationId)), value); + assert.deepEqual(await repository.listAttestations(context(siblingWorkspaceId)), []); + await repository.saveAttestation(context(), value); + await assert.rejects( + repository.saveAttestation(context(), { ...value, signature: 'tampered' }), + /AUD_IMMUTABLE_ATTESTATION/, + ); +}); + +void test('[AUD-007, AUD-015] attestation writes roll back transactionally', async () => { + const repository = new InMemoryAuditAttestationRepositoryAdapter(); + const value = attestation(); + await assert.rejects( + repository.withTransaction(context(), async (transaction) => { + await transaction.saveAttestation(context(), value); + throw new Error('rollback'); + }), + /rollback/, + ); + assert.deepEqual(await repository.listAttestations(context()), []); +}); diff --git a/services/api/test/features/aud/audit-attestation.controller.test.ts b/services/api/test/features/aud/audit-attestation.controller.test.ts new file mode 100644 index 00000000..00eecb5a --- /dev/null +++ b/services/api/test/features/aud/audit-attestation.controller.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AuditAttestationController } from '../../../src/features/aud/api/audit-attestation.controller.js'; +import { AuditProblemError } from '../../../src/features/aud/application/audit-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000841'; +const workspaceId = '00000000-0000-4000-8000-000000000842'; +const attestationId = '00000000-0000-4000-8000-000000000843'; +const actorId = '00000000-0000-4000-8000-000000000844'; +const correlationId = '00000000-0000-4000-8000-000000000845'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'attestation-controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const service = { + create: () => Promise.resolve({ accepted: true as const, value: { attestationId } }), + verify: () => Promise.resolve({ accepted: true as const, value: true as const }), + ...overrides, + }; + return new AuditAttestationController(service as never, { + resolve: () => Promise.resolve(context()), + }); +} + +void test('[AUD-015, AUD-016] controller exposes create and verify operations', async () => { + const instance = controller(); + assert.deepEqual( + await instance.create( + {}, + { + signerKeyId: 'key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root', + }, + ), + { attestationId }, + ); + assert.deepEqual(await instance.verify({}, attestationId), { valid: true }); +}); + +void test('[AUD-015] controller maps not-found and unavailable results', async () => { + await assert.rejects( + controller({ + verify: () => Promise.resolve({ accepted: false as const, code: 'NOT_FOUND' as const }), + }).verify({}, attestationId), + (error: unknown) => + error instanceof AuditProblemError && error.code === 'AUDIT_ATTESTATION_NOT_FOUND', + ); + await assert.rejects( + controller({ + create: () => Promise.resolve({ accepted: false as const, code: 'UNAVAILABLE' as const }), + }).create( + {}, + { + signerKeyId: 'key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root', + }, + ), + (error: unknown) => + error instanceof AuditProblemError && error.code === 'AUDIT_ATTESTATION_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/aud/audit-attestation.service.test.ts b/services/api/test/features/aud/audit-attestation.service.test.ts new file mode 100644 index 00000000..39449071 --- /dev/null +++ b/services/api/test/features/aud/audit-attestation.service.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { type AuditSealV1 } from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryAuditAttestationRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-attestation-repository.adapter.js'; +import { AuditAttestationService } from '../../../src/features/aud/application/audit-attestation.service.js'; +import { InMemoryAuditRepositoryAdapter } from '../../../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000831'; +const workspaceId = '00000000-0000-4000-8000-000000000832'; +const actorId = '00000000-0000-4000-8000-000000000833'; +const correlationId = '00000000-0000-4000-8000-000000000834'; +const attestationId = '00000000-0000-4000-8000-000000000836'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context(idempotencyKey = 'attestation-service') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function seal(): AuditSealV1 { + return { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 3, + eventCount: 3, + rootDigest: 'root-digest', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; +} + +async function setup() { + const auditRepository = new InMemoryAuditRepositoryAdapter(); + await auditRepository.saveSeal(context(), seal()); + const attestationRepository = new InMemoryAuditAttestationRepositoryAdapter(); + const service = new AuditAttestationService( + auditRepository, + attestationRepository, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + () => attestationId, + ); + return { service, attestationRepository }; +} + +void test('[AUD-015, AUD-016] service signs only a persisted exact-scope seal and verifies it', async () => { + const { service, attestationRepository } = await setup(); + const created = await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'root-digest', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + assert.deepEqual(await service.verify(context(), { attestationId }), { + accepted: true, + value: true, + }); + assert.deepEqual( + await attestationRepository.findAttestation(context(), stable(attestationId)), + created.value, + ); +}); + +void test('[AUD-015] service rejects missing seals and malformed selectors before signing', async () => { + const { service } = await setup(); + assert.deepEqual( + await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 2, + lastSequence: 1, + rootDigest: 'root-digest', + }), + { accepted: false, code: 'INVALID_SEQUENCE' }, + ); + assert.deepEqual( + await service.create(context(), { + signerKeyId: 'audit-key-1', + firstSequence: 1, + lastSequence: 3, + rootDigest: 'missing', + }), + { accepted: false, code: 'NOT_FOUND' }, + ); +}); diff --git a/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts new file mode 100644 index 00000000..a88f04ef --- /dev/null +++ b/services/api/test/features/aud/prisma-audit-attestation-repository.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAuditSealAttestationV1, + type AuditSealAttestationV1, + type AuditSealV1, +} from '@databreeze/domain/audit/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaAuditAttestationRepositoryAdapter, + type AuditAttestationDatabaseClientV1, +} from '../../../src/features/aud/adapter/prisma-audit-attestation-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000821'; +const workspaceId = '00000000-0000-4000-8000-000000000822'; +const actorId = '00000000-0000-4000-8000-000000000823'; +const correlationId = '00000000-0000-4000-8000-000000000824'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid timestamp'); + return parsed.value; +} + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'prisma-attestation', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function attestation(): AuditSealAttestationV1 { + const seal: AuditSealV1 = { + schemaVersion: 1, + tenantScope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, + firstSequence: 1, + lastSequence: 2, + eventCount: 2, + rootDigest: 'root', + sealedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + const created = createAuditSealAttestationV1( + seal, + { attestationId: '00000000-0000-4000-8000-000000000825', signerKeyId: 'key-1' }, + { + sign: (payload) => `sig:${payload}`, + verify: (payload, signature) => signature === `sig:${payload}`, + }, + ); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid attestation'); + return created.value; +} + +function delegate(rows: Record[]) { + const matches = ( + row: Record, + where: Readonly>, + ): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) + return value.some((candidate) => + matches(row, candidate as Readonly>), + ); + return row[key] === value; + }); + return { + create({ data }: { readonly data: Record }) { + const row = { ...data }; + rows.push(row); + return Promise.resolve(row); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve(rows.find((row) => matches(row, where)) ?? null); + }, + findMany({ where }: { readonly where: Readonly> }) { + return Promise.resolve(rows.filter((row) => matches(row, where))); + }, + }; +} + +function client(rows: Record[] = []): AuditAttestationDatabaseClientV1 { + const database = { + auditSealAttestationRecord: delegate(rows), + } as unknown as AuditAttestationDatabaseClientV1; + return { + ...database, + async $transaction( + work: (transaction: AuditAttestationDatabaseClientV1) => Promise, + ) { + return work(database); + }, + }; +} + +void test('[AUD-015, AUD-016] Prisma attestation adapter persists immutable rows and scopes reads', async () => { + const repository = new PrismaAuditAttestationRepositoryAdapter(client()); + const value = attestation(); + await repository.saveAttestation(context(), value); + assert.deepEqual(await repository.findAttestation(context(), stable(value.attestationId)), value); + assert.deepEqual(await repository.listAttestations(context()), [value]); + await repository.saveAttestation(context(), value); + await assert.rejects( + repository.saveAttestation(context(), { ...value, signature: 'tampered' }), + /AUD_IMMUTABLE_ATTESTATION/, + ); +}); diff --git a/services/api/test/features/bua/bua.module.test.ts b/services/api/test/features/bua/bua.module.test.ts new file mode 100644 index 00000000..8b1da4fb --- /dev/null +++ b/services/api/test/features/bua/bua.module.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BuaModule } from '../../../src/features/bua/bua.module.js'; +import { ENTITLEMENT_LEASE_SERVICE } from '../../../src/features/bua/application/entitlement-lease.service.js'; + +void test('[BUA-017, BUA-018] module composes lease service from secret-manager key material', () => { + const dynamic = BuaModule.register({ entitlementLeaseSigningKey: 'a'.repeat(32) }); + assert.equal( + dynamic.providers?.some( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === ENTITLEMENT_LEASE_SERVICE, + ), + true, + ); +}); diff --git a/services/api/test/features/bua/entitlement-lease-repository.test.ts b/services/api/test/features/bua/entitlement-lease-repository.test.ts new file mode 100644 index 00000000..9a50695e --- /dev/null +++ b/services/api/test/features/bua/entitlement-lease-repository.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, +} from '@databreeze/domain/entitlements/v1'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000761'; +const leaseId = '00000000-0000-4000-8000-000000000762'; +const actorId = '00000000-0000-4000-8000-000000000763'; + +function context(scope = { scopeType: 'organization', organizationId }) { + const result = createIamTenantContextV1({ + actorId, + correlationId: '00000000-0000-4000-8000-000000000764', + tenantScope: scope, + idempotencyKey: 'lease-repository', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function lease() { + const plan = createPlanV1({ + planCode: 'free', + displayNameKey: 'plan.free', + features: [], + quotas: [{ metric: 'job_count', limit: 1 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const snapshot = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000765', + tenantScope: { scopeType: 'organization', organizationId }, + plan: plan.value, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(snapshot.accepted, true); + if (!snapshot.accepted) throw new Error('invalid snapshot'); + const issued = createEntitlementLeaseV1( + snapshot.value, + { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, + { sign: (payload) => payload }, + ); + assert.equal(issued.accepted, true); + if (!issued.accepted) throw new Error('invalid lease'); + return issued.value; +} + +void test('[BUA-017, BUA-018] in-memory lease persistence is immutable and scoped', async () => { + const repository = new InMemoryEntitlementLeaseRepositoryAdapter(); + await repository.saveLease(context(), lease()); + assert.equal((await repository.findLease(context(), lease().leaseId))?.leaseId, leaseId); + assert.equal( + await repository.findLease( + context({ + scopeType: 'organization', + organizationId: '00000000-0000-4000-8000-000000000799', + }), + lease().leaseId, + ), + undefined, + ); + await assert.rejects( + repository.saveLease(context(), { ...lease(), signature: 'changed' }), + /BUA_IMMUTABLE_LEASE/u, + ); +}); diff --git a/services/api/test/features/bua/entitlement-lease.service.test.ts b/services/api/test/features/bua/entitlement-lease.service.test.ts new file mode 100644 index 00000000..ae057ac4 --- /dev/null +++ b/services/api/test/features/bua/entitlement-lease.service.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createPlanV1, + createEntitlementSnapshotV1, + type EntitlementPlanV1, + type EntitlementSnapshotV1, +} from '@databreeze/domain/entitlements/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { EntitlementLeaseService } from '../../../src/features/bua/application/entitlement-lease.service.js'; +import { InMemoryEntitlementLeaseRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-lease-repository.adapter.js'; +import { InMemoryEntitlementRepositoryAdapter } from '../../../src/features/bua/adapter/in-memory-entitlement-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000301'; +const workspaceId = '00000000-0000-4000-8000-000000000302'; +const snapshotId = '00000000-0000-4000-8000-000000000303'; +const leaseId = '00000000-0000-4000-8000-000000000304'; +const actorId = '00000000-0000-4000-8000-000000000305'; +const correlationId = '00000000-0000-4000-8000-000000000306'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(workspace = workspaceId) { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId: workspace }, + idempotencyKey: 'lease-service', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function plan(): EntitlementPlanV1 { + const result = createPlanV1({ + planCode: 'development', + displayNameKey: 'plan.development', + features: ['job.execute'], + quotas: [{ metric: 'job_count', limit: 2 }], + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid plan'); + return result.value; +} + +function snapshot(): EntitlementSnapshotV1 { + const result = createEntitlementSnapshotV1({ + snapshotId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + plan: plan(), + status: 'ACTIVE', + revision: 4, + securityEpoch: 2, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid snapshot'); + return result.value; +} + +function signer() { + return { + sign(payload: string) { + return `sig:${payload}`; + }, + verify(payload: string, signature: string) { + return signature === `sig:${payload}`; + }, + }; +} + +async function setup() { + const entitlementRepository = new InMemoryEntitlementRepositoryAdapter(); + await entitlementRepository.saveSnapshot(context(), snapshot()); + const leaseRepository = new InMemoryEntitlementLeaseRepositoryAdapter(); + const service = new EntitlementLeaseService( + leaseRepository, + entitlementRepository, + signer(), + () => new Date('2026-01-01T00:05:00.000Z'), + () => leaseId, + ); + return { service, leaseRepository }; +} + +void test('[BUA-017] issues one bounded lease from a visible immutable snapshot', async () => { + const { service, leaseRepository } = await setup(); + const result = await service.issue(context(), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.snapshotRevision, 4); + assert.equal( + (await leaseRepository.findLease(context(), stable(leaseId)))?.signature, + result.value.signature, + ); +}); + +void test('[BUA-017] rejects a lease for a hidden snapshot without writing it', async () => { + const { service } = await setup(); + const result = await service.issue(context('00000000-0000-4000-8000-000000000399'), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.deepEqual(result, { accepted: false, code: 'ENTITLEMENT_NOT_FOUND' }); +}); + +void test('[BUA-018] verifies signature, scope, revision, epoch, and time through the repository', async () => { + const { service } = await setup(); + const issued = await service.issue(context(), { + snapshotId, + expiresAt: '2026-01-01T01:05:00.000Z', + }); + assert.equal(issued.accepted, true); + if (!issued.accepted) return; + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: '2026-01-01T00:10:00.000Z', + snapshotRevision: 4, + securityEpoch: 2, + }), + { accepted: true, value: true }, + ); + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: '2026-01-01T00:10:00.000Z', + snapshotRevision: 3, + securityEpoch: 2, + }), + { accepted: false, code: 'LEASE_STALE' }, + ); +}); + +void test('[BUA-018] rejects invalid generated IDs and malformed verification timestamps', async () => { + const entitlementRepository = new InMemoryEntitlementRepositoryAdapter(); + await entitlementRepository.saveSnapshot(context(), snapshot()); + const service = new EntitlementLeaseService( + new InMemoryEntitlementLeaseRepositoryAdapter(), + entitlementRepository, + signer(), + () => new Date('2026-01-01T00:05:00.000Z'), + () => 'not-an-id', + ); + assert.deepEqual( + await service.issue(context(), { snapshotId, expiresAt: '2026-01-01T01:05:00.000Z' }), + { accepted: false, code: 'INVALID_IDENTIFIER' }, + ); + assert.deepEqual( + await service.verify(context(), { + leaseId, + now: 'invalid', + snapshotRevision: 4, + securityEpoch: 2, + }), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); diff --git a/services/api/test/features/bua/entitlement.controller.test.ts b/services/api/test/features/bua/entitlement.controller.test.ts new file mode 100644 index 00000000..e7893780 --- /dev/null +++ b/services/api/test/features/bua/entitlement.controller.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { EntitlementController } from '../../../src/features/bua/api/entitlement.controller.js'; +import { EntitlementProblemError } from '../../../src/features/bua/application/entitlement-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000401'; +const workspaceId = '00000000-0000-4000-8000-000000000402'; +const snapshotId = '00000000-0000-4000-8000-000000000403'; +const leaseId = '00000000-0000-4000-8000-000000000404'; +const actorId = '00000000-0000-4000-8000-000000000405'; +const correlationId = '00000000-0000-4000-8000-000000000406'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + idempotencyKey: 'bua-controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const leases = { + issue: () => + Promise.resolve({ + accepted: true as const, + value: { leaseId, signature: 'signed' }, + }), + verify: () => Promise.resolve({ accepted: true as const, value: true as const }), + ...overrides, + }; + const repository = { + findSnapshot: () => Promise.resolve(undefined), + listUsageState: () => Promise.resolve({ entries: [], reservations: [] }), + }; + const requestContext = { resolve: () => Promise.resolve(context()) }; + return new EntitlementController(repository as never, requestContext, leases as never); +} + +void test('[BUA-017, BUA-018] controller exposes lease issue and verification endpoints', async () => { + const instance = controller(); + assert.deepEqual( + await instance.issueLease({}, snapshotId, { expiresAt: '2026-01-01T01:00:00.000Z' }), + { leaseId, signature: 'signed' }, + ); + assert.deepEqual( + await instance.verifyLease({}, leaseId, { snapshotRevision: 4, securityEpoch: 2 }), + { valid: true }, + ); +}); + +void test('[BUA-018] controller maps stale and unavailable lease results', async () => { + await assert.rejects( + controller({ + verify: () => Promise.resolve({ accepted: false as const, code: 'LEASE_STALE' as const }), + }).verifyLease({}, leaseId, { snapshotRevision: 3, securityEpoch: 2 }), + (error: unknown) => + error instanceof EntitlementProblemError && error.code === 'ENTITLEMENT_LEASE_STALE', + ); + await assert.rejects( + controller({ + issue: () => Promise.resolve({ accepted: false as const, code: 'UNAVAILABLE' as const }), + }).issueLease({}, snapshotId, { expiresAt: '2026-01-01T01:00:00.000Z' }), + (error: unknown) => + error instanceof EntitlementProblemError && error.code === 'ENTITLEMENT_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts b/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts new file mode 100644 index 00000000..574bcce2 --- /dev/null +++ b/services/api/test/features/bua/hmac-entitlement-lease-signer.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HmacEntitlementLeaseSignerAdapter } from '../../../src/features/bua/adapter/hmac-entitlement-lease-signer.adapter.js'; + +const key = 'a'.repeat(32); + +void test('[BUA-018] HMAC lease signatures verify exact payloads and reject tampering', () => { + const signer = new HmacEntitlementLeaseSignerAdapter(key); + const signature = signer.sign('{"lease":1}'); + assert.equal(signer.verify('{"lease":1}', signature), true); + assert.equal(signer.verify('{"lease":2}', signature), false); + assert.equal(signer.verify('{"lease":1}', `${signature}x`), false); +}); + +void test('[BUA-018] HMAC lease signing requires a non-trivial key', () => { + assert.throws(() => new HmacEntitlementLeaseSignerAdapter('short'), /KEY_TOO_SHORT/); +}); diff --git a/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts new file mode 100644 index 00000000..40c92ecc --- /dev/null +++ b/services/api/test/features/bua/prisma-entitlement-lease-repository.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createEntitlementLeaseV1, + createEntitlementSnapshotV1, + createPlanV1, + type EntitlementLeaseV1, +} from '@databreeze/domain/entitlements/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaEntitlementLeaseRepositoryAdapter, + type EntitlementLeaseDatabaseClientV1, +} from '../../../src/features/bua/adapter/prisma-entitlement-lease-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000771'; +const leaseId = '00000000-0000-4000-8000-000000000772'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context() { + const result = createIamTenantContextV1({ + actorId: '00000000-0000-4000-8000-000000000773', + correlationId: '00000000-0000-4000-8000-000000000774', + tenantScope: { scopeType: 'organization', organizationId }, + idempotencyKey: 'prisma-lease', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function lease(): EntitlementLeaseV1 { + const plan = createPlanV1({ + planCode: 'free', + displayNameKey: 'plan.free', + features: [], + quotas: [{ metric: 'job_count', limit: 1 }], + }); + assert.equal(plan.accepted, true); + if (!plan.accepted) throw new Error('invalid plan'); + const snapshot = createEntitlementSnapshotV1({ + snapshotId: '00000000-0000-4000-8000-000000000775', + tenantScope: { scopeType: 'organization', organizationId }, + plan: plan.value, + status: 'ACTIVE', + revision: 1, + securityEpoch: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(snapshot.accepted, true); + if (!snapshot.accepted) throw new Error('invalid snapshot'); + const issued = createEntitlementLeaseV1( + snapshot.value, + { leaseId, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-01T01:00:00.000Z' }, + { sign: (payload) => payload }, + ); + assert.equal(issued.accepted, true); + if (!issued.accepted) throw new Error('invalid lease'); + return issued.value; +} + +function delegate(rows: Record[]) { + return { + create({ data }: { readonly data: Record }) { + const row = { ...data }; + rows.push(row); + return Promise.resolve(row); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => + Object.entries(where).every(([key, value]) => + key !== 'OR' + ? row[key] === value + : (where['OR'] as readonly Record[]).some((candidate) => + Object.entries(candidate).every( + ([candidateKey, candidateValue]) => row[candidateKey] === candidateValue, + ), + ), + ), + ) ?? null, + ); + }, + }; +} + +function client(rows: Record[] = []): EntitlementLeaseDatabaseClientV1 { + const database = { + entitlementLeaseRecord: delegate(rows), + } as unknown as EntitlementLeaseDatabaseClientV1; + return { + ...database, + async $transaction( + work: (transaction: EntitlementLeaseDatabaseClientV1) => Promise, + ) { + return work(database); + }, + }; +} + +void test('[BUA-017, BUA-018] Prisma lease adapter stores signed rows and enforces scope', async () => { + const repository = new PrismaEntitlementLeaseRepositoryAdapter(client()); + await repository.saveLease(context(), lease()); + assert.equal( + (await repository.findLease(context(), stable(leaseId)))?.signature, + lease().signature, + ); +}); diff --git a/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts b/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts new file mode 100644 index 00000000..c2cff028 --- /dev/null +++ b/services/api/test/features/iam/iam-invitation-crypto.adapter.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + HmacSha256IamInvitationDigestAdapter, + randomIamInvitationIdV1, + randomIamInvitationTokenV1, +} from '../../../src/features/iam/adapter/iam-invitation-crypto.adapter.js'; + +void test('[IAM-010] invitation digests are deterministic, keyed, domain-separated, and hex bounded', () => { + const digest = new HmacSha256IamInvitationDigestAdapter('test-key-v1'); + const token = digest.digestToken('raw-token-abcdefghijklmnopqrstuvwxyz123456'); + const email = digest.digestEmail('invitee@example.com'); + assert.match(token, /^[a-f0-9]{64}$/u); + assert.match(email, /^[a-f0-9]{64}$/u); + assert.equal(token, digest.digestToken('raw-token-abcdefghijklmnopqrstuvwxyz123456')); + assert.notEqual(token, email); + assert.notEqual( + token, + new HmacSha256IamInvitationDigestAdapter('other-key-v1').digestToken( + 'raw-token-abcdefghijklmnopqrstuvwxyz123456', + ), + ); +}); + +void test('[IAM-010] invitation crypto adapters reject unusable key material', () => { + assert.throws(() => new HmacSha256IamInvitationDigestAdapter(''), /IAM_INVITATION_KEY_INVALID/); + assert.throws( + () => new HmacSha256IamInvitationDigestAdapter(new Uint8Array()), + /IAM_INVITATION_KEY_INVALID/, + ); +}); + +void test('[IAM-010] generated invitation identifiers and tokens are fresh and non-guessable', () => { + const invitationId = randomIamInvitationIdV1(); + const parsed = parseStableIdentifierV1(invitationId); + assert.equal(parsed.accepted, true); + const token = randomIamInvitationTokenV1(); + assert.ok(token.length >= 43); + // This assertion intentionally checks the full C0/control range. + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(token, /[\u0000-\u001f\u007f]/u); + assert.notEqual(token, randomIamInvitationTokenV1()); +}); diff --git a/services/api/test/features/iam/in-memory-invitation-repository.test.ts b/services/api/test/features/iam/in-memory-invitation-repository.test.ts new file mode 100644 index 00000000..a4006e35 --- /dev/null +++ b/services/api/test/features/iam/in-memory-invitation-repository.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryIamInvitationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-invitation-repository.adapter.js'; +import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../../../src/features/iam/application/invitation-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000321', + invitee: '00000000-0000-4000-8000-000000000322', + organization: '00000000-0000-4000-8000-000000000323', + membership: '00000000-0000-4000-8000-000000000324', + invitation: '00000000-0000-4000-8000-000000000325', + correlation: '00000000-0000-4000-8000-000000000326', +}; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture identifier'); + return result.value; +} + +function timestamp(value: string) { + const result = parseStrictUtcTimestampV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture timestamp'); + return result.value; +} + +function context( + actorId = ids.owner, + scope: unknown = { scopeType: 'organization', organizationId: ids.organization }, +) { + const result = createIamTenantContextV1({ + tenantScope: scope, + actorId, + correlationId: ids.correlation, + idempotencyKey: `invitation-repository-${actorId}`, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture context'); + return result.value; +} + +function membership(status: IamMembershipRecordV1['status'] = 'INVITED'): IamMembershipRecordV1 { + return { + id: stable(ids.membership), + principalId: stable(ids.invitee), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'viewer', + status, + ...(status === 'INVITED' + ? { + startsAt: timestamp('2026-08-03T00:00:00.000Z'), + expiresAt: timestamp('2026-08-04T00:00:00.000Z'), + } + : {}), + revision: 1, + }; +} + +function invitation() { + const result = createInvitationTokenV1({ + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.invitee, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:00:00.000Z', + expiresAt: '2026-08-10T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid repository fixture invitation'); + return result.value; +} + +void test('[IAM-010] in-memory invitation repository scopes digest and membership lookups', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + assert.equal( + (await transaction.findInvitationByDigest(context(), 'a'.repeat(64)))?.id, + stable(ids.invitation), + ); + assert.equal( + await transaction.findInvitationByDigest( + context(ids.owner, { + scopeType: 'workspace', + organizationId: ids.organization, + workspaceId: '00000000-0000-4000-8000-000000000399', + }), + 'a'.repeat(64), + ), + undefined, + ); + }, + ); +}); + +void test('[IAM-010] invitation repository enforces immutable identity and compare-and-set revisions', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + await assert.rejects( + transaction.saveInvitation(context(), { ...invitation(), roleId: 'admin' }), + /IAM_INVITATION_SCOPE_IMMUTABLE/, + ); + await assert.rejects( + transaction.saveMembership(context(), { ...membership('ACTIVE'), revision: 3 }), + /IAM_REVISION_CONFLICT/, + ); + }, + ); +}); + +void test('[IAM-010] invitation repository transaction rolls back token and membership together', async () => { + const repository: IamInvitationRepositoryPortV1 = new InMemoryIamInvitationRepositoryAdapter([ + membership(), + ]); + await assert.rejects( + repository.withTransaction(context(), async (transaction: IamInvitationTransactionPortV1) => { + await transaction.saveInvitation(context(), invitation()); + const next = { ...membership('ACTIVE'), revision: 2 }; + await transaction.saveMembership(context(), next); + throw new Error('simulated delivery acknowledgement failure'); + }), + /simulated delivery acknowledgement failure/, + ); + await repository.withTransaction( + context(), + async (transaction: IamInvitationTransactionPortV1) => { + assert.equal(await transaction.findInvitationByDigest(context(), 'a'.repeat(64)), undefined); + assert.equal( + (await transaction.findMembershipById(context(), stable(ids.membership)))?.status, + 'INVITED', + ); + }, + ); +}); diff --git a/services/api/test/features/iam/invitation-composition.test.ts b/services/api/test/features/iam/invitation-composition.test.ts new file mode 100644 index 00000000..dd48f795 --- /dev/null +++ b/services/api/test/features/iam/invitation-composition.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IAM_INVITATION_REPOSITORY_PORT } from '../../../src/features/iam/application/invitation-repository.port.js'; +import { + IAM_INVITATION_SERVICE, + IamInvitationService, + IAM_PRINCIPAL_EMAIL_LOOKUP_PORT, +} from '../../../src/features/iam/application/invitation.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; +import { PrismaIamInvitationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; +import { PrismaIamPrincipalEmailLookupAdapter } from '../../../src/features/iam/adapter/prisma-principal-email-lookup.adapter.js'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +void test('[IAM-010] explicitly supplied invitation service is exported by IAM composition', () => { + const service = {} as IamInvitationService; + const registered = IamModule.register({ invitationService: service }); + const configured = provider(registered, IAM_INVITATION_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok( + registered.controllers?.some((controller) => controller.name === 'IamInvitationController'), + ); +}); + +void test('[IAM-010] durable invitation composition requires all secret and delivery ports', () => { + const registered = IamModule.register({ invitationDatabase: {} as never }); + assert.equal(provider(registered, IAM_INVITATION_SERVICE), undefined); + const repository = provider(registered, IAM_INVITATION_REPOSITORY_PORT); + assert.ok(repository && 'useValue' in repository); + if (!repository || !('useValue' in repository)) return; + assert.ok(repository.useValue instanceof PrismaIamInvitationRepositoryAdapter); +}); + +void test('[IAM-010] durable invitation composition selects Prisma persistence when configured', () => { + const registered = IamModule.register({ + invitationDatabase: {} as never, + invitationPrincipalEmails: { + findEmail: async () => { + await Promise.resolve(); + return 'invitee@example.com'; + }, + }, + invitationDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + invitationDigest: { + digestToken: () => 'a'.repeat(64), + digestEmail: () => 'b'.repeat(64), + }, + }); + const repository = provider(registered, IAM_INVITATION_REPOSITORY_PORT); + const service = provider(registered, IAM_INVITATION_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 PrismaIamInvitationRepositoryAdapter); + assert.ok(service.useValue instanceof IamInvitationService); +}); + +void test('[IAM-010] durable invitation composition can source principal email from IAM', () => { + const registered = IamModule.register({ + invitationPrincipalEmailDatabase: {} as never, + }); + assert.equal(provider(registered, IAM_INVITATION_SERVICE), undefined); + const lookup = provider(registered, IAM_PRINCIPAL_EMAIL_LOOKUP_PORT); + assert.ok(lookup && 'useValue' in lookup); + if (!lookup || !('useValue' in lookup)) return; + assert.ok(lookup.useValue instanceof PrismaIamPrincipalEmailLookupAdapter); +}); diff --git a/services/api/test/features/iam/invitation-controller.test.ts b/services/api/test/features/iam/invitation-controller.test.ts new file mode 100644 index 00000000..b9297ddc --- /dev/null +++ b/services/api/test/features/iam/invitation-controller.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IamInvitationController } from '../../../src/features/iam/api/invitation.controller.js'; +import { InvitationProblemError } from '../../../src/features/iam/application/invitation-problem.error.js'; +import type { IamInvitationService } from '../../../src/features/iam/application/invitation.service.js'; + +void test('[IAM-010] invitation controller forwards bounded issue and accept commands without returning bearer material', async () => { + const calls: Array = []; + const service = { + issue: async (...input: unknown[]) => { + await Promise.resolve(); + calls.push(input); + return { + accepted: true as const, + value: { + invitationId: 'invitation-id', + membershipId: 'membership-id', + expiresAt: '2026-08-10T00:00:00.000Z', + deliveryStatus: 'DELIVERED' as const, + }, + }; + }, + accept: async (...input: unknown[]) => { + await Promise.resolve(); + calls.push(input); + return { + accepted: true as const, + value: { id: 'membership-id', status: 'ACTIVE' }, + }; + }, + } as unknown as IamInvitationService; + const context = { actorId: 'actor', tenantScope: { scopeType: 'organization' } } as never; + const controller = new IamInvitationController(service, { + resolve: async () => { + await Promise.resolve(); + return context; + }, + }); + const issued = await controller.issue( + {}, + { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }, + ); + const accepted = await controller.accept( + {}, + { token: 'raw-token-abcdefghijklmnopqrstuvwxyz123456' }, + ); + assert.deepEqual(issued, { + invitationId: 'invitation-id', + membershipId: 'membership-id', + expiresAt: '2026-08-10T00:00:00.000Z', + deliveryStatus: 'DELIVERED', + }); + assert.deepEqual(accepted, { id: 'membership-id', status: 'ACTIVE' }); + assert.equal(calls.length, 2); + assert.equal( + (calls[0]?.[1] as { readonly recipientEmail?: string }).recipientEmail, + 'invitee@example.com', + ); + assert.equal(calls[1]?.[1], 'raw-token-abcdefghijklmnopqrstuvwxyz123456'); +}); + +void test('[IAM-010] invitation controller maps rejected application outcomes to safe problem codes', async () => { + const service = { + issue: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'SCOPE_DENIED' as const }; + }, + accept: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_TOKEN' as const }; + }, + } as unknown as IamInvitationService; + const controller = new IamInvitationController(service, { + resolve: async () => { + await Promise.resolve(); + return {} as never; + }, + }); + await assert.rejects( + controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_SCOPE_DENIED', + ); + await assert.rejects( + controller.accept({}, { token: 'raw-token-abcdefghijklmnopqrstuvwxyz123456' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_REQUEST_REJECTED', + ); +}); + +void test('[IAM-010] invitation controller fails closed when service composition is incomplete', async () => { + const controller = new IamInvitationController(undefined, { + resolve: async () => { + await Promise.resolve(); + return {} as never; + }, + }); + await assert.rejects( + controller.issue({}, { membershipId: 'membership-id', recipientEmail: 'invitee@example.com' }), + (error: unknown) => + error instanceof InvitationProblemError && error.code === 'INVITATION_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/iam/invitation-service.test.ts b/services/api/test/features/iam/invitation-service.test.ts new file mode 100644 index 00000000..9a8bfbac --- /dev/null +++ b/services/api/test/features/iam/invitation-service.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { + IamInvitationService, + type IamInvitationDigestPortV1, + type IamInvitationDeliveryPortV1, + type IamInvitationIdGeneratorV1, + type IamInvitationTokenGeneratorV1, + type IamPrincipalEmailLookupPortV1, +} from '../../../src/features/iam/application/invitation.service.js'; +import type { + IamInvitationRepositoryPortV1, + IamInvitationTransactionPortV1, +} from '../../../src/features/iam/application/invitation-repository.port.js'; +import type { IamMembershipRecordV1 } from '../../../src/features/iam/application/iam-repository.port.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000311', + invitee: '00000000-0000-4000-8000-000000000312', + organization: '00000000-0000-4000-8000-000000000313', + ownerMembership: '00000000-0000-4000-8000-000000000314', + invitedMembership: '00000000-0000-4000-8000-000000000315', + invitation: '00000000-0000-4000-8000-000000000316', +}; +const now = new Date('2026-08-03T00:00:00.000Z'); +const RAW_TOKEN = 'raw-token-abcdefghijklmnopqrstuvwxyz123456'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture timestamp'); + return parsed.value; +} + +function context(actorId: string, key: string) { + const parsed = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: ids.organization }, + actorId, + correlationId: '00000000-0000-4000-8000-000000000317', + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid invitation fixture context'); + return parsed.value; +} + +class Repository implements IamInvitationRepositoryPortV1 { + memberships: IamMembershipRecordV1[] = [ + { + id: stable(ids.ownerMembership), + principalId: stable(ids.owner), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: stable(ids.invitedMembership), + principalId: stable(ids.invitee), + scope: { scopeType: 'organization', organizationId: stable(ids.organization) }, + roleId: 'viewer', + status: 'INVITED', + startsAt: timestamp(now.toISOString()), + expiresAt: timestamp(new Date(now.getTime() + 86_400_000).toISOString()), + revision: 1, + }, + ]; + invitations: InvitationTokenV1[] = []; + private tail: Promise = Promise.resolve(); + + async withTransaction( + _context: Parameters[0], + work: (transaction: IamInvitationTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const prior = this.tail; + this.tail = new Promise((resolve) => (release = resolve)); + await prior; + const memberships = this.memberships.map((item) => ({ ...item, scope: { ...item.scope } })); + const invitations = [...this.invitations]; + try { + return await work({ + findMembershipForPrincipal: async (_context, principalId) => { + await Promise.resolve(); + return this.memberships.find( + (membership) => + membership.principalId === principalId && membership.status === 'ACTIVE', + ); + }, + findMembershipById: async (_context, id) => { + await Promise.resolve(); + return this.memberships.find((membership) => membership.id === id); + }, + findInvitationByDigest: async (_context, digest) => { + await Promise.resolve(); + return this.invitations.find((invitation) => invitation.tokenDigest === digest); + }, + findActiveInvitationForMembership: async (_context, membershipId) => { + await Promise.resolve(); + return this.invitations.find( + (invitation) => + invitation.membershipId === membershipId && invitation.status === 'ACTIVE', + ); + }, + saveInvitation: async (_context, invitation) => { + await Promise.resolve(); + const index = this.invitations.findIndex((item) => item.id === invitation.id); + if (index >= 0) { + if (this.invitations[index]?.revision !== invitation.revision - 1) + throw new Error('IAM_INVITATION_REVISION_CONFLICT'); + this.invitations[index] = invitation; + } else { + if (this.invitations.some((item) => item.tokenDigest === invitation.tokenDigest)) + throw new Error('IAM_INVITATION_CONFLICT'); + this.invitations.push(invitation); + } + }, + saveMembership: async (_context, membership) => { + await Promise.resolve(); + const index = this.memberships.findIndex((item) => item.id === membership.id); + if (index < 0 || this.memberships[index]?.revision !== membership.revision - 1) + throw new Error('IAM_REVISION_CONFLICT'); + this.memberships[index] = membership; + }, + }); + } catch (error) { + this.memberships = memberships; + this.invitations = invitations; + throw error; + } finally { + release(); + } + } +} + +class EmailLookup implements IamPrincipalEmailLookupPortV1 { + async findEmail(principalId: string): Promise { + await Promise.resolve(); + return principalId === ids.invitee ? 'invitee@example.com' : 'owner@example.com'; + } +} + +class Digest implements IamInvitationDigestPortV1 { + digestToken(value: string): string { + return value === RAW_TOKEN ? 'a'.repeat(64) : 'c'.repeat(64); + } + + digestEmail(value: string): string { + return value === 'invitee@example.com' ? 'b'.repeat(64) : 'd'.repeat(64); + } +} + +class Delivery implements IamInvitationDeliveryPortV1 { + readonly sent: Array<{ readonly token: string; readonly email: string }> = []; + + async deliver(input: { + readonly rawToken: string; + readonly recipientEmail: string; + }): Promise { + await Promise.resolve(); + this.sent.push({ token: input.rawToken, email: input.recipientEmail }); + } +} + +function service(repository: Repository, delivery = new Delivery()) { + const idsQueue: string[] = [ids.invitation, ids.invitation]; + const idGenerator: IamInvitationIdGeneratorV1 = () => { + const next = idsQueue.shift(); + if (!next) throw new Error('invitation id generator exhausted'); + return next; + }; + const tokenGenerator: IamInvitationTokenGeneratorV1 = () => RAW_TOKEN; + return { + service: new IamInvitationService( + repository, + new EmailLookup(), + idGenerator, + tokenGenerator, + new Digest(), + delivery, + () => now, + ), + delivery, + }; +} + +void test('[IAM-010] issuing an invitation delivers a raw token but returns only safe metadata', async () => { + const repository = new Repository(); + const composed = service(repository); + const result = await composed.service.issue(context(ids.owner, 'invitation-issue-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'INVITEE@example.com', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.membershipId, stable(ids.invitedMembership)); + assert.equal('rawToken' in result.value, false); + assert.equal('tokenDigest' in result.value, false); + assert.deepEqual(composed.delivery.sent, [{ token: RAW_TOKEN, email: 'invitee@example.com' }]); +}); + +void test('[IAM-010] email mismatch and non-owner issuance are denied without persistence', async () => { + const repository = new Repository(); + const composed = service(repository); + assert.deepEqual( + await composed.service.issue(context(ids.owner, 'invitation-issue-002'), { + membershipId: ids.invitedMembership, + recipientEmail: 'other@example.com', + }), + { accepted: false, code: 'RECIPIENT_MISMATCH' }, + ); + assert.deepEqual( + await composed.service.issue(context(ids.invitee, 'invitation-issue-003'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + assert.equal(repository.invitations.length, 0); +}); + +void test('[IAM-010] acceptance binds token, principal, email, role, and scope then consumes once', async () => { + const repository = new Repository(); + const composed = service(repository); + const issued = await composed.service.issue(context(ids.owner, 'invitation-accept-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }); + assert.equal(issued.accepted, true); + const accepted = await composed.service.accept( + context(ids.invitee, 'invitation-accept-002'), + RAW_TOKEN, + ); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + assert.equal(accepted.value.status, 'ACTIVE'); + assert.equal(repository.invitations[0]?.status, 'REDEEMED'); + assert.deepEqual( + await composed.service.accept(context(ids.invitee, 'invitation-accept-003'), RAW_TOKEN), + { accepted: false, code: 'INVALID_TOKEN' }, + ); +}); + +void test('[IAM-010] concurrent acceptance has one winner and no duplicate activation', async () => { + const repository = new Repository(); + const composed = service(repository); + await composed.service.issue(context(ids.owner, 'invitation-race-001'), { + membershipId: ids.invitedMembership, + recipientEmail: 'invitee@example.com', + }); + const results = await Promise.all([ + composed.service.accept(context(ids.invitee, 'invitation-race-002'), RAW_TOKEN), + composed.service.accept(context(ids.invitee, 'invitation-race-003'), RAW_TOKEN), + ]); + assert.equal(results.filter((result) => result.accepted).length, 1); + assert.equal(results.filter((result) => !result.accepted).length, 1); + assert.equal( + repository.memberships.find((item) => item.id === stable(ids.invitedMembership))?.revision, + 2, + ); +}); diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index e196387c..bf4db01b 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -14,6 +14,7 @@ const at = '2026-01-01T00:00:00.000Z'; void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revisioned', async () => { const repository = new InMemoryMfaRepositoryAdapter(); + repository.setRecoveryReenrollmentRequired(userId as never); const service = new MfaService( repository, { @@ -41,6 +42,7 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis if (verified.accepted) { assert.equal(verified.value.factors[0]?.status, 'ACTIVE'); assert.equal(verified.value.factors[0]?.verifiedAt, at); + assert.equal(repository.isRecoveryReenrollmentRequired(userId as never), false); } const secondVerify = await service.verifyFactor(userId, factorId, '654321'); assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); @@ -128,6 +130,19 @@ void test('[IAM-012] high-risk operations require a fresh step-up assertion', () service.requireStepUp('HIGH', assertion, userId as never, '2026-01-01T00:05:00.000Z').accepted, true, ); + assert.deepEqual(service.requireStepUp('HIGH', assertion, userId as never, at, true), { + accepted: false, + code: 'MFA_REENROLLMENT_REQUIRED', + }); + assert.deepEqual( + service.requireStepUpForContext( + { actorId: userId as never, mfaReenrollmentRequired: true }, + 'HIGH', + assertion, + at, + ), + { accepted: false, code: 'MFA_REENROLLMENT_REQUIRED' }, + ); }); void test('[IAM-015] default recovery-code matching compares normalized bytes safely', () => { diff --git a/services/api/test/features/iam/prisma-credential-lookup.test.ts b/services/api/test/features/iam/prisma-credential-lookup.test.ts index c82b5805..f15a9423 100644 --- a/services/api/test/features/iam/prisma-credential-lookup.test.ts +++ b/services/api/test/features/iam/prisma-credential-lookup.test.ts @@ -108,6 +108,26 @@ void test('[IAM-001, IAM-009] lookup fails closed when persisted tenancy is inac assert.equal(await malformed.findCredential('user@example.com'), undefined); }); +void test('[IAM-015] credential lookup carries the live MFA re-enrollment gate when recovery set it', async () => { + const adapter = new PrismaCredentialLookupAdapter( + database({ + userIdentity: { + findUnique: async () => ({ + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 4, + mfaReenrollmentRequired: true, + }), + }, + }), + ); + assert.equal( + (await adapter.findCredential('user@example.com'))?.principal.mfaReenrollmentRequired, + true, + ); +}); + void test('[IAM-001, IAM-002] lookup does not authenticate users without an active workspace membership', async () => { const adapter = new PrismaCredentialLookupAdapter( database({ diff --git a/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts new file mode 100644 index 00000000..64eb9d64 --- /dev/null +++ b/services/api/test/features/iam/prisma-iam-invitation-repository.test.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInvitationTokenV1, type InvitationTokenV1 } from '@databreeze/domain/invitation/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaIamInvitationRepositoryAdapter, + type IamInvitationDatabaseClientV1, + type IamInvitationDatabaseRowV1, + type IamInvitationMembershipDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-iam-invitation-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const ids = { + owner: '00000000-0000-4000-8000-000000000331', + invitee: '00000000-0000-4000-8000-000000000332', + organization: '00000000-0000-4000-8000-000000000333', + membership: '00000000-0000-4000-8000-000000000334', + invitation: '00000000-0000-4000-8000-000000000335', + correlation: '00000000-0000-4000-8000-000000000336', +}; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid Prisma invitation fixture identifier'); + return parsed.value; +} + +function context() { + const parsed = createIamTenantContextV1({ + tenantScope: { scopeType: 'organization', organizationId: ids.organization }, + actorId: ids.owner, + correlationId: ids.correlation, + idempotencyKey: 'prisma-invitation-001', + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid Prisma invitation fixture context'); + return parsed.value; +} + +function membershipRow(status = 'INVITED'): IamInvitationMembershipDatabaseRowV1 { + return { + id: ids.membership, + principalType: 'USER', + principalId: ids.invitee, + scopeType: 'ORGANIZATION', + organizationId: ids.organization, + workspaceId: null, + projectId: null, + roleId: 'viewer', + status, + startsAt: status === 'INVITED' ? new Date('2026-08-03T00:00:00.000Z') : null, + expiresAt: status === 'INVITED' ? new Date('2026-08-04T00:00:00.000Z') : null, + revision: status === 'INVITED' ? 1 : 2, + }; +} + +function token(): InvitationTokenV1 { + const created = createInvitationTokenV1({ + id: ids.invitation, + membershipId: ids.membership, + principalId: ids.invitee, + scope: { scopeType: 'organization', organizationId: ids.organization }, + roleId: 'viewer', + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: '2026-08-03T00:00:00.000Z', + expiresAt: '2026-08-10T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('invalid Prisma invitation token fixture'); + return created.value; +} + +function client(options: { readonly updateCount?: number } = {}) { + const memberships: IamInvitationMembershipDatabaseRowV1[] = [membershipRow()]; + const invitations: IamInvitationDatabaseRowV1[] = []; + const calls: Array<{ readonly operation: string; readonly input: unknown }> = []; + const database: IamInvitationDatabaseClientV1 = { + membershipIdentity: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); + calls.push({ operation: 'membership.findUnique', input: where }); + return memberships.find((row) => row.id === where['id']) ?? null; + }, + findMany: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); + calls.push({ operation: 'membership.findMany', input: where }); + return memberships.filter((row) => + Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value), + ); + }, + create: async ({ data }: { readonly data: IamInvitationMembershipDatabaseRowV1 }) => { + await Promise.resolve(); + memberships.push(data); + return data; + }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + await Promise.resolve(); + const index = memberships.findIndex( + (row) => row.id === where['id'] && row.revision === where['revision'], + ); + if (index < 0) return { count: 0 }; + memberships[index] = { + ...memberships[index], + ...data, + } as IamInvitationMembershipDatabaseRowV1; + return { count: options.updateCount ?? 1 }; + }, + }, + invitationTokenRecord: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); + calls.push({ operation: 'invitation.findUnique', input: where }); + return ( + invitations.find( + (row) => row.tokenDigest === where['tokenDigest'] || row.id === where['id'], + ) ?? null + ); + }, + findFirst: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); + calls.push({ operation: 'invitation.findFirst', input: where }); + return ( + invitations.find( + (row) => row.membershipId === where['membershipId'] && row.status === where['status'], + ) ?? null + ); + }, + create: async ({ data }: { readonly data: IamInvitationDatabaseRowV1 }) => { + await Promise.resolve(); + invitations.push(data); + return data; + }, + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Partial; + }) => { + await Promise.resolve(); + const index = invitations.findIndex( + (row) => + row.id === where['id'] && + row.revision === where['revision'] && + row.status === where['status'], + ); + if (index < 0) return { count: 0 }; + invitations[index] = { ...invitations[index], ...data } as IamInvitationDatabaseRowV1; + return { count: options.updateCount ?? 1 }; + }, + }, + $transaction: async (work: (transaction: typeof database) => Promise) => { + await Promise.resolve(); + return work(database); + }, + }; + return { database, memberships, invitations, calls }; +} + +void test('[IAM-010] Prisma invitation adapter stores and resolves only exact scoped digests', async () => { + const fixture = client(); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + const membership = await transaction.findMembershipById(context(), stable(ids.membership)); + assert.equal(membership?.status, 'INVITED'); + const created = token(); + await transaction.saveInvitation(context(), created); + assert.equal( + (await transaction.findInvitationByDigest(context(), created.tokenDigest))?.id, + stable(ids.invitation), + ); + assert.equal( + (await transaction.findActiveInvitationForMembership(context(), stable(ids.membership)))?.id, + stable(ids.invitation), + ); + }); + assert.equal(fixture.invitations[0]?.tokenDigest, 'a'.repeat(64)); + assert.equal(fixture.invitations[0]?.emailDigest, 'b'.repeat(64)); + assert.equal(fixture.invitations[0]?.status, 'ACTIVE'); +}); + +void test('[IAM-010] Prisma invitation adapter rejects stale token updates and hides sibling scopes', async () => { + const fixture = client(); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + await transaction.saveInvitation(context(), token()); + await assert.rejects( + transaction.saveInvitation(context(), { ...token(), status: 'REDEEMED', revision: 3 }), + /IAM_INVITATION_REVISION_CONFLICT/, + ); + const siblingContext = createIamTenantContextV1({ + tenantScope: { + scopeType: 'organization', + organizationId: '00000000-0000-4000-8000-000000000399', + }, + actorId: ids.owner, + correlationId: ids.correlation, + idempotencyKey: 'prisma-invitation-sibling', + authorizationEpoch: 1, + }); + assert.equal(siblingContext.accepted, true); + if (!siblingContext.accepted) return; + assert.equal( + await transaction.findInvitationByDigest(siblingContext.value, 'a'.repeat(64)), + undefined, + ); + }); +}); + +void test('[IAM-010] Prisma invitation adapter maps a conditional update race to a conflict', async () => { + const fixture = client({ updateCount: 0 }); + const repository = new PrismaIamInvitationRepositoryAdapter(fixture.database); + await repository.withTransaction(context(), async (transaction) => { + await transaction.saveInvitation(context(), token()); + await assert.rejects( + transaction.saveInvitation(context(), { ...token(), status: 'REDEEMED', revision: 2 }), + /IAM_INVITATION_REVISION_CONFLICT/, + ); + }); +}); diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index 115535be..bcf288e3 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -28,7 +28,25 @@ function createDatabase(): { } { const factors = new Map(); const recoveryCodes = new Map(); + const users = new Map([ + [userId, { mfaReenrollmentRequired: true }], + ]); const client = { + userIdentity: { + updateMany: async ({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Readonly>; + }) => { + const user = users.get(String(where['id'])); + if (!user || user.mfaReenrollmentRequired !== where['mfaReenrollmentRequired']) + return { count: 0 }; + user.mfaReenrollmentRequired = Boolean(data['mfaReenrollmentRequired']); + return { count: 1 }; + }, + }, mfaFactor: { findMany: async ({ where }: { readonly where: Readonly> }) => [...factors.values()].filter((row) => @@ -158,6 +176,14 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence round-trips opaque factors assert.deepEqual(await adapter.findState(factor.userId), input); }); +void test('[IAM-015] Prisma MFA transaction clears the recovery re-enrollment gate by compare-and-set', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const transaction = await adapter.withTransaction(async (current) => current); + assert.equal(await transaction.clearRecoveryReenrollment?.(userId as never), true); + assert.equal(await transaction.clearRecoveryReenrollment?.(userId as never), false); +}); + void test('[IAM-012, IAM-014] status transitions persist by revision while immutable secrets and digests remain fixed', async () => { const { client } = createDatabase(); const adapter = new PrismaMfaRepositoryAdapter(client); diff --git a/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts new file mode 100644 index 00000000..fba1ed24 --- /dev/null +++ b/services/api/test/features/iam/prisma-principal-email-lookup.adapter.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaIamPrincipalEmailLookupAdapter, + type IamPrincipalEmailDatabaseRowV1, +} from '../../../src/features/iam/adapter/prisma-principal-email-lookup.adapter.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +const principalId = '00000000-0000-4000-8000-000000000341'; + +function stable(value: string) { + const result = parseStableIdentifierV1(value); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid principal-email fixture identifier'); + return result.value; +} + +function client(row: IamPrincipalEmailDatabaseRowV1 | null) { + const calls: unknown[] = []; + return { + calls, + userIdentity: { + findUnique: async ({ where }: { readonly where: Readonly> }) => { + await Promise.resolve(); + calls.push(where); + return row; + }, + }, + }; +} + +void test('[IAM-010] Prisma principal email lookup returns only a normalized active identity', async () => { + const database = client({ id: principalId, email: 'Invitee@Example.com', status: 'ACTIVE' }); + const adapter = new PrismaIamPrincipalEmailLookupAdapter(database); + assert.equal(await adapter.findEmail(stable(principalId)), 'invitee@example.com'); + assert.deepEqual(database.calls, [{ id: principalId }]); +}); + +void test('[IAM-010] Prisma principal email lookup fails closed for inactive or malformed rows', async () => { + const inactive = new PrismaIamPrincipalEmailLookupAdapter( + client({ id: principalId, email: 'invitee@example.com', status: 'SUSPENDED' }), + ); + assert.equal(await inactive.findEmail(stable(principalId)), undefined); + const malformed = new PrismaIamPrincipalEmailLookupAdapter( + client({ id: principalId, email: 'not-an-email', status: 'ACTIVE' }), + ); + assert.equal(await malformed.findEmail(stable(principalId)), undefined); +}); + +void test('[IAM-010] Prisma principal email lookup does not accept a row for another principal', async () => { + const adapter = new PrismaIamPrincipalEmailLookupAdapter( + client({ + id: '00000000-0000-4000-8000-000000000342', + email: 'invitee@example.com', + status: 'ACTIVE', + }), + ); + assert.equal(await adapter.findEmail(stable(principalId)), undefined); +}); diff --git a/services/api/test/features/iam/prisma-recovery-repository.test.ts b/services/api/test/features/iam/prisma-recovery-repository.test.ts new file mode 100644 index 00000000..9d809e45 --- /dev/null +++ b/services/api/test/features/iam/prisma-recovery-repository.test.ts @@ -0,0 +1,292 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRecoveryChallengeV1 } from '@databreeze/domain/recovery/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import { + PrismaRecoveryRepositoryAdapter, + type RecoveryDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-recovery-repository.adapter.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const challengeId = '00000000-0000-4000-8000-000000000002'; +const issuedAt = new Date('2026-08-03T00:00:00.000Z'); + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('fixture identifier invalid'); + return parsed.value; +} + +function challenge(status: 'ACTIVE' | 'CONSUMED' = 'ACTIVE') { + const created = createRecoveryChallengeV1({ + id: challengeId, + userId, + tokenDigest: 'a'.repeat(64), + emailDigest: 'b'.repeat(64), + issuedAt: issuedAt.toISOString(), + expiresAt: '2026-08-03T00:30:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) throw new Error('fixture invalid'); + if (status === 'ACTIVE') return created.value; + const consumed = { + ...created.value, + status: 'CONSUMED' as const, + consumedAt: '2026-08-03T00:10:00.000Z', + revision: 2, + }; + return consumed; +} + +function database() { + const users = new Map>([ + [ + userId, + { + id: userId, + email: 'user@example.com', + status: 'ACTIVE', + securityEpoch: 1, + mfaReenrollmentRequired: false, + }, + ], + ]); + const challenges = new Map>(); + const credentials = new Map>([[userId, { userId }]]); + const sessionId = '00000000-0000-4000-8000-000000000003'; + const sessions = new Map>([ + [ + sessionId, + { + id: sessionId, + userId, + familyId: 'family-1', + status: 'ACTIVE', + }, + ], + ]); + const calls = { transactions: 0, refresh: 0, access: 0, sessions: 0, mfa: 0 }; + const unique = (records: Map>) => ({ + findUnique: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); + if (where['id']) return records.get(where['id']) ?? null; + if (where['email']) + return [...records.values()].find((row) => row['email'] === where['email']) ?? null; + if (where['tokenDigest']) + return ( + [...records.values()].find((row) => row['tokenDigest'] === where['tokenDigest']) ?? null + ); + return null; + }, + }); + const client = { + userIdentity: { + ...unique(users), + updateMany: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + await Promise.resolve(); + const row = users.get(String(where['id'])); + if (!row || row['securityEpoch'] !== where['securityEpoch']) return { count: 0 }; + users.set(String(where['id']), { ...row, ...data }); + return { count: 1 }; + }, + }, + recoveryChallenge: { + ...unique(challenges), + findMany: async ({ where }: { where: Record }) => { + await Promise.resolve(); + return [...challenges.values()].filter( + (row) => row['userId'] === where['userId'] && row['status'] === where['status'], + ); + }, + create: async ({ data }: { data: Record }) => { + await Promise.resolve(); + challenges.set(String(data['id']), data); + return data; + }, + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + await Promise.resolve(); + const row = challenges.get(String(where['id'])); + if (!row) throw new Error('missing'); + challenges.set(String(where['id']), { ...row, ...data }); + return { ...row, ...data }; + }, + updateMany: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + await Promise.resolve(); + const row = challenges.get(String(where['id'])); + if (!row || row['revision'] !== where['revision']) return { count: 0 }; + challenges.set(String(where['id']), { ...row, ...data }); + return { count: 1 }; + }, + }, + passwordCredential: { + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + await Promise.resolve(); + const row = credentials.get(String(where['userId'])); + if (!row) throw new Error('credential missing'); + credentials.set(String(where['userId']), { ...row, ...data }); + return { ...row, ...data }; + }, + }, + sessionRecord: { + findMany: async () => { + await Promise.resolve(); + return [...sessions.values()]; + }, + update: async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + await Promise.resolve(); + calls.sessions += 1; + const row = sessions.get(String(where['id'])); + if (!row) throw new Error('session missing'); + sessions.set(String(where['id']), { ...row, ...data }); + return { ...row, ...data }; + }, + }, + refreshTokenRecord: { + updateMany: async () => { + await Promise.resolve(); + calls.refresh += 1; + return { count: 1 }; + }, + }, + accessTokenRecord: { + updateMany: async () => { + await Promise.resolve(); + calls.access += 1; + return { count: 1 }; + }, + }, + mfaFactor: { + updateMany: async () => { + await Promise.resolve(); + calls.mfa += 1; + return { count: 1 }; + }, + }, + $transaction: async (work: (transaction: RecoveryDatabaseClientV1) => Promise) => { + await Promise.resolve(); + calls.transactions += 1; + return work(client); + }, + } as unknown as RecoveryDatabaseClientV1; + return { client, users, challenges, credentials, sessions, calls }; +} + +void test('[IAM-015] Prisma recovery adapter persists and reads exact challenge versions', async () => { + const state = database(); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + assert.equal(state.calls.transactions, 1); + assert.equal( + await adapter.withTransaction((transaction) => + transaction.findUserIdByEmail('USER@example.com'), + ), + stable(userId), + ); + assert.equal( + ( + await adapter.withTransaction((transaction) => + transaction.findChallengeByTokenDigest('a'.repeat(64)), + ) + )?.status, + 'ACTIVE', + ); + assert.equal( + ( + await adapter.withTransaction((transaction) => + transaction.findActiveChallengeForUser(stable(userId)), + ) + )?.id, + challengeId, + ); +}); + +void test('[IAM-015] Prisma recovery completion rotates credential, epoch, MFA state, sessions, and challenge atomically', async () => { + const state = database(); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + await adapter.withTransaction((transaction) => + transaction.completeRecovery({ + challenge: challenge('CONSUMED'), + credentialId: stable('00000000-0000-4000-8000-000000000004'), + credential: { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + }, + }), + ); + assert.equal(state.users.get(userId)?.['securityEpoch'], 2); + assert.equal(state.users.get(userId)?.['mfaReenrollmentRequired'], true); + assert.equal( + state.credentials.get(userId)?.['encodedHash'], + '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + ); + assert.equal(state.sessions.get('00000000-0000-4000-8000-000000000003')?.['status'], 'REVOKED'); + assert.equal(state.calls.refresh, 1); + assert.equal(state.calls.access, 1); + assert.equal(state.calls.mfa, 1); + assert.equal(state.challenges.get(challengeId)?.['status'], 'CONSUMED'); +}); + +void test('[IAM-015] Prisma recovery challenge compare-and-set rejects a stale terminal transition', async () => { + const state = database(); + const adapter = new PrismaRecoveryRepositoryAdapter(state.client); + await adapter.withTransaction((transaction) => transaction.saveChallenge(challenge())); + const stale = challenge(); + state.challenges.set(challengeId, { + ...state.challenges.get(challengeId), + revision: 2, + status: 'REVOKED', + revokedAt: new Date('2026-08-03T00:05:00.000Z'), + }); + state.client.recoveryChallenge.updateMany = async () => { + await Promise.resolve(); + return { count: 0 }; + }; + await assert.rejects( + adapter.withTransaction((transaction) => + transaction.saveChallenge({ + ...stale, + status: 'CONSUMED', + consumedAt: '2026-08-03T00:10:00.000Z', + revision: 3, + }), + ), + /IAM_RECOVERY_REVISION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/iam/prisma-registration-repository.test.ts b/services/api/test/features/iam/prisma-registration-repository.test.ts new file mode 100644 index 00000000..3f3454e2 --- /dev/null +++ b/services/api/test/features/iam/prisma-registration-repository.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PrismaRegistrationRepositoryAdapter, + type RegistrationDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-registration-repository.adapter.js'; +import { + RegistrationConflictError, + type RegistrationPersistenceInputV1, +} from '../../../src/features/iam/application/registration-repository.port.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const organizationId = '00000000-0000-4000-8000-000000000002'; +const workspaceId = '00000000-0000-4000-8000-000000000003'; +const projectId = '00000000-0000-4000-8000-000000000004'; +const membershipId = '00000000-0000-4000-8000-000000000005'; +const credentialId = '00000000-0000-4000-8000-000000000006'; +const createdAt = new Date('2026-08-03T00:00:00.000Z'); + +const input: RegistrationPersistenceInputV1 = { + email: 'user@example.com', + credentialId, + credential: { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }, + bootstrap: { + user: { + schemaVersion: 1, + id: userId as never, + status: 'ACTIVE', + displayName: 'Nguyen An', + locale: 'vi-VN', + securityEpoch: 1, + createdAt: createdAt.toISOString() as never, + }, + organization: { + schemaVersion: 1, + id: organizationId as never, + name: "Nguyen An's DataBreeze", + personal: true, + status: 'ACTIVE', + createdAt: createdAt.toISOString() as never, + }, + workspace: { + schemaVersion: 1, + id: workspaceId as never, + organizationId: organizationId as never, + name: 'Personal workspace', + status: 'ACTIVE', + authorizationEpoch: 1, + createdAt: createdAt.toISOString() as never, + }, + project: { + schemaVersion: 1, + id: projectId as never, + organizationId: organizationId as never, + workspaceId: workspaceId as never, + kind: 'INTERNAL', + name: 'Personal project', + status: 'ACTIVE', + createdAt: createdAt.toISOString() as never, + }, + membership: { + schemaVersion: 1, + id: membershipId as never, + principalType: 'USER', + principalId: userId as never, + scope: { scopeType: 'organization', organizationId: organizationId as never }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + }, +}; + +interface State { + users: Map>; + credentials: Map>; + organizations: Map>; + workspaces: Map>; + projects: Map>; + memberships: Map>; +} + +function cloneState(state: State): State { + return { + users: new Map([...state.users].map(([id, row]) => [id, { ...row }])), + credentials: new Map([...state.credentials].map(([id, row]) => [id, { ...row }])), + organizations: new Map([...state.organizations].map(([id, row]) => [id, { ...row }])), + workspaces: new Map([...state.workspaces].map(([id, row]) => [id, { ...row }])), + projects: new Map([...state.projects].map(([id, row]) => [id, { ...row }])), + memberships: new Map([...state.memberships].map(([id, row]) => [id, { ...row }])), + }; +} + +function createDatabase() { + const state: State = { + users: new Map(), + credentials: new Map(), + organizations: new Map(), + workspaces: new Map(), + projects: new Map(), + memberships: new Map(), + }; + const transactionCalls = { value: 0 }; + const makeIdentityDelegate = (records: Map>) => ({ + findUnique: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); + const id = where['id']; + if (id) return records.get(id) ?? null; + const email = where['email']; + if (email) return [...records.values()].find((row) => row['email'] === email) ?? null; + return null; + }, + create: async ({ data }: { readonly data: Record }) => { + await Promise.resolve(); + if (records.has(String(data['id']))) + throw Object.assign(new Error('P2002'), { code: 'P2002' }); + records.set(String(data['id']), data); + return data; + }, + findMany: async ({ where }: { readonly where: Record }) => { + await Promise.resolve(); + return [...records.values()].filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + }, + }); + const client = { + userIdentity: makeIdentityDelegate(state.users), + passwordCredential: makeIdentityDelegate(state.credentials), + organizationIdentity: makeIdentityDelegate(state.organizations), + workspaceIdentity: makeIdentityDelegate(state.workspaces), + projectIdentity: makeIdentityDelegate(state.projects), + membershipIdentity: makeIdentityDelegate(state.memberships), + $transaction: async ( + work: (transaction: RegistrationDatabaseClientV1) => Promise, + ) => { + transactionCalls.value += 1; + const before = cloneState(state); + try { + return await work(client); + } catch (error) { + for (const key of Object.keys(state) as (keyof State)[]) { + state[key].clear(); + for (const [id, row] of before[key]) state[key].set(id, row); + } + throw error; + } + }, + } as unknown as RegistrationDatabaseClientV1; + return { client, state, transactionCalls }; +} + +void test('[IAM-001, IAM-009] Prisma registration persists the user, credential, and hierarchy in one transaction', async () => { + const database = createDatabase(); + const adapter = new PrismaRegistrationRepositoryAdapter(database.client); + await adapter.withTransaction((transaction) => transaction.save(input)); + assert.equal(database.transactionCalls.value, 1); + assert.equal(database.state.users.size, 1); + assert.equal(database.state.credentials.size, 1); + assert.equal(database.state.organizations.size, 1); + assert.equal(database.state.workspaces.size, 1); + assert.equal(database.state.projects.size, 1); + assert.equal(database.state.memberships.size, 1); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail(input.email)), + true, + ); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail('other@example.com')), + false, + ); +}); + +void test('[IAM-001] Prisma registration maps an existing normalized email and concurrent unique race to a conflict', async () => { + const database = createDatabase(); + const adapter = new PrismaRegistrationRepositoryAdapter(database.client); + await adapter.withTransaction((transaction) => transaction.save(input)); + assert.equal( + await adapter.withTransaction((transaction) => transaction.findByEmail('user@example.com')), + true, + ); + await assert.rejects( + adapter.withTransaction((transaction) => transaction.save(input)), + RegistrationConflictError, + ); +}); + +void test('[IAM-001] Prisma registration rolls back user and credential when hierarchy persistence fails', async () => { + const database = createDatabase(); + const failing = { + ...database.client, + projectIdentity: { + ...database.client.projectIdentity, + create: async () => { + await Promise.resolve(); + throw new Error('project write failed'); + }, + }, + $transaction: async (work) => { + const before = cloneState(database.state); + try { + return await work(failing); + } catch (error) { + for (const key of Object.keys(database.state) as (keyof State)[]) { + database.state[key].clear(); + for (const [id, row] of before[key]) database.state[key].set(id, row); + } + throw error; + } + }, + } as RegistrationDatabaseClientV1; + const adapter = new PrismaRegistrationRepositoryAdapter(failing); + await assert.rejects( + adapter.withTransaction((transaction) => transaction.save(input)), + /project write failed/, + ); + assert.equal(database.state.users.size, 0); + assert.equal(database.state.credentials.size, 0); + assert.equal(database.state.organizations.size, 0); +}); diff --git a/services/api/test/features/iam/prisma-service-account-repository.test.ts b/services/api/test/features/iam/prisma-service-account-repository.test.ts new file mode 100644 index 00000000..0e8690cd --- /dev/null +++ b/services/api/test/features/iam/prisma-service-account-repository.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createServiceAccountV1, + type ServiceAccountV1, +} from '@databreeze/domain/service-account/v1'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import { + PrismaServiceAccountRepositoryAdapter, + type ServiceAccountDatabaseClientV1, +} from '../../../src/features/iam/adapter/prisma-service-account-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000721'; +const workspaceId = '00000000-0000-4000-8000-000000000722'; +const siblingWorkspaceId = '00000000-0000-4000-8000-000000000723'; +const accountId = '00000000-0000-4000-8000-000000000724'; +const actorId = '00000000-0000-4000-8000-000000000725'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function context(scope: unknown, key = 'prisma-service-account') { + const result = createIamTenantContextV1({ + actorId, + correlationId: '00000000-0000-4000-8000-000000000726', + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function account(): ServiceAccountV1 { + const result = createServiceAccountV1({ + id: accountId, + organizationId, + workspaceId, + name: 'Import worker', + permissions: ['artifact.record.read'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid account'); + return result.value; +} + +function delegate(rows: Record[], forceConflict = false) { + return { + create({ data }: { readonly data: Record }) { + const persisted = { ...data }; + rows.push(persisted); + return Promise.resolve(persisted); + }, + findFirst({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.find((row) => { + if (where['OR']) { + const alternatives = where['OR'] as readonly Record[]; + const base = Object.fromEntries(Object.entries(where).filter(([key]) => key !== 'OR')); + return ( + Object.entries(base).every(([key, value]) => row[key] === value) && + alternatives.some((candidate) => + Object.entries(candidate).every(([key, value]) => row[key] === value), + ) + ); + } + return Object.entries(where).every(([key, value]) => row[key] === value); + }) ?? null, + ); + }, + findMany({ where }: { readonly where: Readonly> }) { + return Promise.resolve( + rows.filter((row) => { + if (where['OR']) { + const alternatives = where['OR'] as readonly Record[]; + const base = Object.fromEntries(Object.entries(where).filter(([key]) => key !== 'OR')); + return ( + Object.entries(base).every(([key, value]) => row[key] === value) && + alternatives.some((candidate) => + Object.entries(candidate).every(([key, value]) => row[key] === value), + ) + ); + } + return Object.entries(where).every(([key, value]) => row[key] === value); + }), + ); + }, + updateMany({ + where, + data, + }: { + readonly where: Readonly>; + readonly data: Record; + }) { + if (forceConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, + }; +} + +function rowFor(value = account()): Record { + return { + id: value.id, + organizationId: value.organizationId, + workspaceId: value.workspaceId ?? null, + name: value.name, + permissions: value.permissions, + status: value.status, + secretDigest: value.secretDigest, + secretVersion: value.secretVersion, + secretIssuedAt: new Date(value.secretIssuedAt), + secretExpiresAt: value.secretExpiresAt ? new Date(value.secretExpiresAt) : null, + lastUsedAt: value.lastUsedAt ? new Date(value.lastUsedAt) : null, + createdAt: new Date(value.createdAt), + revokedAt: value.revokedAt ? new Date(value.revokedAt) : null, + revision: value.revision, + }; +} + +function client( + rows: Record[] = [], + forceConflict = false, +): ServiceAccountDatabaseClientV1 { + const database = { + serviceAccount: delegate(rows, forceConflict), + async $transaction( + work: (transaction: ServiceAccountDatabaseClientV1) => Promise, + ) { + return work(database as unknown as ServiceAccountDatabaseClientV1); + }, + }; + return database as unknown as ServiceAccountDatabaseClientV1; +} + +void test('[IAM-013] Prisma service-account adapter persists and filters workspace scope', async () => { + const rows: Record[] = []; + const repository = new PrismaServiceAccountRepositoryAdapter(client(rows)); + await repository.saveServiceAccount( + context({ scopeType: 'organization', organizationId }), + account(), + ); + assert.equal( + ( + await repository.findServiceAccount( + context({ scopeType: 'workspace', organizationId, workspaceId }), + stable(accountId), + ) + )?.name, + 'Import worker', + ); + assert.equal( + await repository.findServiceAccount( + context({ scopeType: 'workspace', organizationId, workspaceId: siblingWorkspaceId }), + stable(accountId), + ), + undefined, + ); + assert.equal( + ( + await repository.findServiceAccountByDigest( + context({ scopeType: 'organization', organizationId }), + 'a'.repeat(64), + ) + )?.id, + stable(accountId), + ); + assert.equal( + (await repository.listServiceAccounts(context({ scopeType: 'organization', organizationId }))) + .length, + 1, + ); +}); + +void test('[IAM-013] Prisma service-account adapter uses optimistic revisions and rejects races', async () => { + const repository = new PrismaServiceAccountRepositoryAdapter(client([rowFor()])); + const next = Object.freeze({ ...account(), name: 'Changed', revision: 2 }); + await repository.replaceServiceAccount( + context({ scopeType: 'organization', organizationId }), + next, + 1, + ); + await assert.rejects( + new PrismaServiceAccountRepositoryAdapter(client([rowFor()], true)).replaceServiceAccount( + context({ scopeType: 'organization', organizationId }), + next, + 1, + ), + /REVISION_CONFLICT/u, + ); +}); + +void test('[IAM-013] Prisma service-account adapter fails closed on malformed persisted state', async () => { + const malformed = rowFor(); + malformed['secretDigest'] = 'not-a-digest'; + const repository = new PrismaServiceAccountRepositoryAdapter(client([malformed])); + await assert.rejects( + repository.findServiceAccount( + context({ scopeType: 'organization', organizationId }), + stable(accountId), + ), + /IAM_PERSISTED_SERVICE_ACCOUNT_INVALID/u, + ); +}); diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index acad931c..4ead346c 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -172,6 +172,23 @@ void test('[IAM-005, IAM-006] Prisma sessions persist opaque bounded access and assert.equal(await adapter.findPrincipalByAccessToken('not-a-token'), undefined); }); +void test('[IAM-015] live session lookup carries the MFA re-enrollment gate from the user record', async () => { + const { client } = createDatabase(); + const adapter = new PrismaSessionLifecycleAdapter(client, { + clock: () => new Date('2026-01-01T00:00:00.000Z'), + }); + const original = client.userIdentity.findUnique.bind(client.userIdentity); + client.userIdentity.findUnique = async () => ({ + id: userId, + status: 'ACTIVE', + securityEpoch: 4, + mfaReenrollmentRequired: true, + }); + const session = await adapter.issue(principal, 'web'); + assert.equal((await adapter.findPrincipal(session.sessionId))?.mfaReenrollmentRequired, true); + client.userIdentity.findUnique = original; +}); + void test('[IAM-005] refresh rotation is transactional and reuse revokes the complete family', async () => { const { client, refreshTokens } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client, { diff --git a/services/api/test/features/iam/recovery-admission.test.ts b/services/api/test/features/iam/recovery-admission.test.ts new file mode 100644 index 00000000..f3376872 --- /dev/null +++ b/services/api/test/features/iam/recovery-admission.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; + +const digest = 'a'.repeat(64); + +void test('[IAM-015] in-memory recovery admission bounds attempts and expires a window', async () => { + const admission = new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 2, windowSeconds: 60 }); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:00.000Z'), true); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:01.000Z'), true); + assert.equal(await admission.allow(digest, '2026-08-03T00:00:02.000Z'), false); + assert.equal(await admission.allow(digest, '2026-08-03T00:01:01.000Z'), true); +}); + +void test('[IAM-015] in-memory recovery admission rejects malformed keys and unsafe configuration', async () => { + const admission = new InMemoryRecoveryAdmissionAdapter(); + assert.equal(await admission.allow('not-a-digest', '2026-08-03T00:00:00.000Z'), false); + assert.equal(await admission.allow(digest, 'not-a-timestamp'), false); + assert.throws( + () => new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 0 }), + /IAM_RECOVERY_ADMISSION_INVALID/u, + ); +}); diff --git a/services/api/test/features/iam/recovery-composition.test.ts b/services/api/test/features/iam/recovery-composition.test.ts new file mode 100644 index 00000000..96b1fc1d --- /dev/null +++ b/services/api/test/features/iam/recovery-composition.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PrismaRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-recovery-repository.adapter.js'; +import { RedisRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; +import { + IAM_RECOVERY_ADMISSION_PORT, + IAM_RECOVERY_COMPLETION_ADMISSION_PORT, + IAM_RECOVERY_REPOSITORY_PORT, +} from '../../../src/features/iam/application/recovery-repository.port.js'; +import { + IAM_RECOVERY_SERVICE, + RecoveryService, +} from '../../../src/features/iam/application/recovery.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +const passwordCredentials = new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, +}); + +void test('[IAM-015] explicitly supplied recovery service is exported with a public controller', () => { + const service = {} as RecoveryService; + const registered = IamModule.register({ recoveryService: service }); + const configured = provider(registered, IAM_RECOVERY_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok(registered.controllers?.some((controller) => controller.name === 'RecoveryController')); +}); + +void test('[IAM-015] durable recovery composition requires password, digest, delivery, and persistence ports', () => { + const incomplete = IamModule.register({ recoveryDatabase: {} as never }); + assert.equal(provider(incomplete, IAM_RECOVERY_SERVICE), undefined); + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + }); + const repository = provider(configured, IAM_RECOVERY_REPOSITORY_PORT); + const service = provider(configured, IAM_RECOVERY_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 PrismaRecoveryRepositoryAdapter); + assert.ok(service.useValue instanceof RecoveryService); +}); + +void test('[IAM-015] recovery composition selects the shared admission adapter when a Redis counter is provided', () => { + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + recoveryAdmissionCounter: { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, + recoveryAdmissionOptions: { maxAttempts: 5, windowSeconds: 30 }, + }); + const admission = provider(configured, IAM_RECOVERY_ADMISSION_PORT); + assert.ok(admission && 'useValue' in admission); + if (!admission || !('useValue' in admission)) return; + assert.ok(admission.useValue instanceof RedisRecoveryAdmissionAdapter); +}); + +void test('[IAM-015] recovery composition gives completion counters a separate Redis namespace', () => { + const configured = IamModule.register({ + recoveryDatabase: {} as never, + passwordCredentials, + recoveryDigestKey: 'test-recovery-key', + recoveryDelivery: { + deliver: async () => { + await Promise.resolve(); + }, + }, + recoveryCompletionAdmissionCounter: { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, + }); + const admission = provider(configured, IAM_RECOVERY_COMPLETION_ADMISSION_PORT); + assert.ok(admission && 'useValue' in admission); + if (!admission || !('useValue' in admission)) return; + assert.ok(admission.useValue instanceof RedisRecoveryAdmissionAdapter); +}); diff --git a/services/api/test/features/iam/recovery-controller.test.ts b/services/api/test/features/iam/recovery-controller.test.ts new file mode 100644 index 00000000..54f3d5f0 --- /dev/null +++ b/services/api/test/features/iam/recovery-controller.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RecoveryController } from '../../../src/features/iam/api/recovery.controller.js'; +import { RecoveryProblemError } from '../../../src/features/iam/application/recovery-problem.error.js'; +import type { RecoveryService } from '../../../src/features/iam/application/recovery.service.js'; + +void test('[IAM-015] recovery controller returns generic request and safe completion values', async () => { + const controller = new RecoveryController({ + request: async () => { + await Promise.resolve(); + return { accepted: true as const, value: { requested: true as const } }; + }, + complete: async () => { + await Promise.resolve(); + return { + accepted: true as const, + value: { userId: 'user-id' as never, mfaReenrollmentRequired: true as const }, + }; + }, + } as unknown as RecoveryService); + assert.deepEqual(await controller.request({ email: 'user@example.com' }), { requested: true }); + assert.deepEqual( + await controller.complete({ + token: 'a'.repeat(32), + newPassword: 'correct horse battery staple', + }), + { userId: 'user-id', mfaReenrollmentRequired: true }, + ); +}); + +void test('[IAM-015] recovery controller maps rejected and unavailable outcomes without account disclosure', async () => { + const controller = new RecoveryController({ + request: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_INPUT' as const }; + }, + complete: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'INVALID_TOKEN' as const }; + }, + } as unknown as RecoveryService); + await assert.rejects( + controller.request({ email: 'bad' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_REQUEST_REJECTED', + ); + await assert.rejects( + controller.complete({ token: 'a'.repeat(32), newPassword: 'correct horse battery staple' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_TOKEN_INVALID', + ); + await assert.rejects( + new RecoveryController(undefined).request({ email: 'user@example.com' }), + (error: unknown) => + error instanceof RecoveryProblemError && error.code === 'RECOVERY_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/iam/recovery-crypto.test.ts b/services/api/test/features/iam/recovery-crypto.test.ts new file mode 100644 index 00000000..35a85d09 --- /dev/null +++ b/services/api/test/features/iam/recovery-crypto.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HmacSha256IamRecoveryDigestAdapter } from '../../../src/features/iam/adapter/iam-recovery-crypto.adapter.js'; + +void test('[IAM-015] recovery HMAC digests are deterministic, keyed, and domain-separated', () => { + const first = new HmacSha256IamRecoveryDigestAdapter('recovery-key'); + const second = new HmacSha256IamRecoveryDigestAdapter('recovery-key'); + const other = new HmacSha256IamRecoveryDigestAdapter('other-key'); + const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; + assert.equal(first.digestToken(token), second.digestToken(token)); + assert.equal(first.digestEmail('user@example.com').length, 64); + assert.notEqual(first.digestToken(token), first.digestEmail(token)); + assert.notEqual(first.digestToken(token), other.digestToken(token)); + assert.throws(() => first.digestToken(''), /IAM_RECOVERY_INPUT_INVALID/u); +}); diff --git a/services/api/test/features/iam/recovery-http.test.ts b/services/api/test/features/iam/recovery-http.test.ts new file mode 100644 index 00000000..7ded3b84 --- /dev/null +++ b/services/api/test/features/iam/recovery-http.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { HmacSha256IamRecoveryDigestAdapter } from '../../../src/features/iam/adapter/iam-recovery-crypto.adapter.js'; +import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-repository.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const challengeId = '00000000-0000-4000-8000-000000000002'; +const rawToken = 'recovery-token-abcdefghijklmnopqrstuvwxyz-1234567890'; + +function credentials() { + return new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=1,p=1,t=1$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, + }); +} + +void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and consumes a link once', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId, activeSessionFamilies: ['family-1'] }); + const delivered: Array<{ readonly rawToken: string }> = []; + const { app } = await createApiApplication({ + recoveryRepository: repository, + passwordCredentials: credentials(), + recoveryDigest: new HmacSha256IamRecoveryDigestAdapter('test-recovery-key'), + recoveryDelivery: { + deliver: async ({ rawToken: deliveredToken }) => { + await Promise.resolve(); + delivered.push({ rawToken: deliveredToken }); + }, + }, + recoveryIdGenerator: { next: () => challengeId }, + recoveryTokenGenerator: { next: () => rawToken }, + recoveryClock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); + try { + const known = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'User@example.com' }, + }); + const unknown = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'missing@example.com' }, + }); + assert.equal(known.statusCode, 202); + assert.deepEqual(known.json(), { requested: true }); + assert.equal(unknown.statusCode, 202); + assert.deepEqual(unknown.json(), { requested: true }); + assert.equal(delivered.length, 1); + + const completed = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery/complete', + payload: { token: rawToken, newPassword: 'correct horse battery staple' }, + }); + assert.equal(completed.statusCode, 200); + assert.deepEqual(completed.json(), { userId, mfaReenrollmentRequired: true }); + + const replay = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery/complete', + payload: { token: rawToken, newPassword: 'correct horse battery staple' }, + }); + assert.equal(replay.statusCode, 400); + assert.equal(replay.json<{ code: string }>().code, 'RECOVERY_TOKEN_INVALID'); + } finally { + await app.close(); + } +}); + +void test('[IAM-015] recovery HTTP fails closed without a composed service', async () => { + const { app } = await createApiApplication(); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/recovery', + payload: { email: 'user@example.com' }, + }); + assert.equal(response.statusCode, 503); + assert.equal(response.json<{ code: string }>().code, 'RECOVERY_UNAVAILABLE'); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/features/iam/recovery.service.test.ts b/services/api/test/features/iam/recovery.service.test.ts new file mode 100644 index 00000000..5c41a053 --- /dev/null +++ b/services/api/test/features/iam/recovery.service.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRecoveryRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-repository.adapter.js'; +import { InMemoryRecoveryAdmissionAdapter } from '../../../src/features/iam/adapter/in-memory-recovery-admission.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; +import { RecoveryService } from '../../../src/features/iam/application/recovery.service.js'; +import type { RecoveryDeliveryPortV1 } from '../../../src/features/iam/application/recovery-repository.port.js'; + +const userId = '00000000-0000-4000-8000-000000000001'; +const token = 'recovery-token-abcdefghijklmnopqrstuvwxyz-123456'; + +function credentials() { + return new PasswordCredentialService({ + hash: async (password) => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, + }); +} + +function countingCredentials(counter: { value: number }) { + return new PasswordCredentialService({ + hash: async (password) => { + await Promise.resolve(); + counter.value += 1; + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: `$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$${Buffer.from(password).toString('base64')}`, + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, + }); +} + +function service( + repository: InMemoryRecoveryRepositoryAdapter, + delivery: RecoveryDeliveryPortV1 = { + deliver: async () => { + await Promise.resolve(); + }, + }, + admission?: InMemoryRecoveryAdmissionAdapter, + passwordCredentials: PasswordCredentialService = credentials(), + completionAdmission?: InMemoryRecoveryAdmissionAdapter, +) { + let id = 2; + return new RecoveryService({ + repository, + passwordCredentials, + digest: { + digestToken: () => 'a'.repeat(64), + digestEmail: () => 'b'.repeat(64), + }, + delivery, + ids: { next: () => `00000000-0000-4000-8000-${String(id++).padStart(12, '0')}` }, + tokens: { next: () => token }, + clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + ...(admission ? { admission } : {}), + ...(completionAdmission ? { completionAdmission } : {}), + }); +} + +void test('[IAM-015] invalid recovery tokens do not invoke the password hasher', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + const counter = { value: 0 }; + const recovery = service(repository, undefined, undefined, countingCredentials(counter)); + + assert.deepEqual( + await recovery.complete( + 'invalid-token-abcdefghijklmnopqrstuvwxyz-123456', + 'new correct horse battery staple', + ), + { + accepted: false, + code: 'INVALID_TOKEN', + }, + ); + assert.equal(counter.value, 0); +}); + +void test('[IAM-015] completion admission consumes unknown token attempts before challenge lookup', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const completionAdmission = new InMemoryRecoveryAdmissionAdapter({ + maxAttempts: 1, + windowSeconds: 60, + }); + const recovery = service(repository, undefined, undefined, credentials(), completionAdmission); + + assert.deepEqual(await recovery.complete(token, 'new correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); + assert.equal((await recovery.request('user@example.com')).accepted, true); + assert.deepEqual(await recovery.complete(token, 'new correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); + +void test('[IAM-015] recovery request is generic for unknown email and stores a delivered hashed challenge for a known account', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const delivered: string[] = []; + const recovery = service(repository, { + deliver: async (input: Parameters[0]) => { + await Promise.resolve(); + delivered.push(input.rawToken); + }, + }); + assert.deepEqual(await recovery.request('unknown@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(await recovery.request('USER@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(delivered, [token]); + assert.equal(repository.challenge('a'.repeat(64))?.userId, userId); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); + +void test('[IAM-015] recovery admission throttles known and unknown requests through one generic outcome', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const delivered: string[] = []; + const admission = new InMemoryRecoveryAdmissionAdapter({ maxAttempts: 1, windowSeconds: 60 }); + const recovery = service( + repository, + { + deliver: async ({ rawToken }) => { + await Promise.resolve(); + delivered.push(rawToken); + }, + }, + admission, + ); + assert.deepEqual(await recovery.request('unknown@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: true, + value: { requested: true }, + }); + assert.deepEqual(delivered, []); + assert.equal(repository.challenge('a'.repeat(64)), undefined); +}); + +void test('[IAM-015] completion atomically consumes the challenge, rotates the credential, advances the epoch, revokes sessions, and requires MFA re-enrollment', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ + email: 'user@example.com', + userId, + activeSessionFamilies: ['family-1', 'family-2'], + }); + const recovery = service(repository); + assert.equal((await recovery.request('user@example.com')).accepted, true); + const result = await recovery.complete(token, 'new correct horse battery staple'); + assert.deepEqual(result, { + accepted: true, + value: { userId, mfaReenrollmentRequired: true }, + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'CONSUMED'); + assert.equal(repository.account(userId)?.securityEpoch, 2); + assert.equal(repository.account(userId)?.mfaReenrollmentRequired, true); + assert.equal(repository.account(userId)?.activeSessionFamilies.size, 0); + assert.deepEqual(await recovery.complete(token, 'another correct horse battery staple'), { + accepted: false, + code: 'INVALID_TOKEN', + }); +}); + +void test('[IAM-015] recovery delivery failures do not persist a usable challenge', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + const recovery = service(repository, { + deliver: async () => { + await Promise.resolve(); + throw new Error('provider down'); + }, + }); + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: false, + code: 'RECOVERY_UNAVAILABLE', + }); + assert.equal(repository.challenge('a'.repeat(64)), undefined); +}); + +void test('[IAM-015] recovery delivery failure preserves an existing active challenge', async () => { + const repository = new InMemoryRecoveryRepositoryAdapter(); + repository.seed({ email: 'user@example.com', userId }); + let failDelivery = false; + const recovery = service(repository, { + deliver: async () => { + await Promise.resolve(); + if (failDelivery) throw new Error('provider down'); + }, + }); + assert.equal((await recovery.request('user@example.com')).accepted, true); + failDelivery = true; + assert.deepEqual(await recovery.request('user@example.com'), { + accepted: false, + code: 'RECOVERY_UNAVAILABLE', + }); + assert.equal(repository.challenge('a'.repeat(64))?.status, 'ACTIVE'); +}); diff --git a/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts new file mode 100644 index 00000000..41f35d6e --- /dev/null +++ b/services/api/test/features/iam/redis-recovery-admission.adapter.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + RedisEvalRecoveryAdmissionCounterAdapter, + RedisRecoveryAdmissionAdapter, +} from '../../../src/features/iam/adapter/redis-recovery-admission.adapter.js'; + +const digest = 'a'.repeat(64); +const issuedAt = '2026-08-03T00:00:00.000Z'; + +void test('[IAM-015] shared recovery admission uses a namespaced digest key and bounded counter', async () => { + const calls: Array<{ readonly key: string; readonly ttlMs: number }> = []; + let count = 0; + const admission = new RedisRecoveryAdmissionAdapter( + { + incrementWindow: async (input) => { + await Promise.resolve(); + calls.push(input); + count += 1; + return count; + }, + }, + { maxAttempts: 2, windowSeconds: 60 }, + ); + + assert.equal(await admission.allow(digest, issuedAt), true); + assert.equal(await admission.allow(digest, issuedAt), true); + assert.equal(await admission.allow(digest, issuedAt), false); + assert.deepEqual(calls, [ + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + { key: `databreeze:iam:recovery:admission:v1:${digest}`, ttlMs: 60_000 }, + ]); +}); + +void test('[IAM-015] shared recovery admission fails closed for malformed input and counter outages', async () => { + let calls = 0; + const admission = new RedisRecoveryAdmissionAdapter({ + incrementWindow: async () => { + await Promise.resolve(); + calls += 1; + throw new Error('redis unavailable'); + }, + }); + + assert.equal(await admission.allow('not-a-digest', issuedAt), false); + assert.equal(await admission.allow(digest, 'not-a-timestamp'), false); + assert.equal(await admission.allow(digest, issuedAt), false); + assert.equal(calls, 1); +}); + +void test('[IAM-015] shared recovery admission rejects unsafe configuration', () => { + assert.throws( + () => + new RedisRecoveryAdmissionAdapter( + { + incrementWindow: async () => { + await Promise.resolve(); + return 1; + }, + }, + { keyPrefix: 'raw email ' }, + ), + /IAM_RECOVERY_ADMISSION_INVALID/u, + ); +}); + +void test('[IAM-015] Redis counter wrapper uses one atomic script and validates returned counts', async () => { + const calls: Array<{ + readonly script: string; + readonly keys: readonly string[]; + readonly args: readonly string[]; + }> = []; + const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ + eval: async (script, keys, args) => { + await Promise.resolve(); + calls.push({ script, keys, args }); + return '4'; + }, + }); + + assert.equal(await counter.incrementWindow({ key: 'databreeze:key', ttlMs: 15_000 }), 4); + assert.deepEqual(calls, [ + { + script: REDIS_RECOVERY_ADMISSION_INCREMENT_SCRIPT_V1, + keys: ['databreeze:key'], + args: ['15000'], + }, + ]); +}); + +void test('[IAM-015] Redis counter wrapper rejects invalid TTLs and malformed replies', async () => { + const counter = new RedisEvalRecoveryAdmissionCounterAdapter({ + eval: async () => { + await Promise.resolve(); + return 'not-a-count'; + }, + }); + await assert.rejects( + counter.incrementWindow({ key: 'databreeze:key', ttlMs: 999 }), + /IAM_RECOVERY_ADMISSION_COUNTER_INVALID/u, + ); + await assert.rejects( + counter.incrementWindow({ key: 'databreeze:key', ttlMs: 15_000 }), + /IAM_RECOVERY_ADMISSION_COUNTER_INVALID/u, + ); +}); diff --git a/services/api/test/features/iam/registration-composition.test.ts b/services/api/test/features/iam/registration-composition.test.ts new file mode 100644 index 00000000..27e7805e --- /dev/null +++ b/services/api/test/features/iam/registration-composition.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PrismaRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/prisma-registration-repository.adapter.js'; +import { IAM_REGISTRATION_REPOSITORY_PORT } from '../../../src/features/iam/application/registration-repository.port.js'; +import { + IAM_REGISTRATION_SERVICE, + RegistrationService, +} from '../../../src/features/iam/application/registration.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +function provider(module: ReturnType, token: symbol) { + return module.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === token, + ); +} + +const passwordCredentials = new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, +}); + +void test('[IAM-001] registration composition exports an explicitly supplied service and controller', () => { + const service = {} as RegistrationService; + const registered = IamModule.register({ registrationService: service }); + const configured = provider(registered, IAM_REGISTRATION_SERVICE); + assert.ok(configured && 'useValue' in configured); + if (!configured || !('useValue' in configured)) return; + assert.equal(configured.useValue, service); + assert.ok( + registered.controllers?.some((controller) => controller.name === 'RegistrationController'), + ); +}); + +void test('[IAM-001] durable registration requires password credentials before composing the service', () => { + const withoutPassword = IamModule.register({ registrationDatabase: {} as never }); + assert.equal(provider(withoutPassword, IAM_REGISTRATION_SERVICE), undefined); + const configured = IamModule.register({ + registrationDatabase: {} as never, + passwordCredentials, + }); + const repository = provider(configured, IAM_REGISTRATION_REPOSITORY_PORT); + const service = provider(configured, IAM_REGISTRATION_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 PrismaRegistrationRepositoryAdapter); + assert.ok(service.useValue instanceof RegistrationService); +}); diff --git a/services/api/test/features/iam/registration-controller.test.ts b/services/api/test/features/iam/registration-controller.test.ts new file mode 100644 index 00000000..df534fe7 --- /dev/null +++ b/services/api/test/features/iam/registration-controller.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RegistrationController } from '../../../src/features/iam/api/registration.controller.js'; +import { RegistrationProblemError } from '../../../src/features/iam/application/registration-problem.error.js'; +import type { RegistrationService } from '../../../src/features/iam/application/registration.service.js'; + +const value = { + bootstrap: { + user: { id: 'user-id', locale: 'vi-VN' }, + organization: { id: 'organization-id' }, + workspace: { id: 'workspace-id' }, + project: { id: 'project-id' }, + membership: { id: 'membership-id' }, + }, + email: 'user@example.com', +} as never; + +void test('[IAM-001] registration controller returns hierarchy identifiers without bearer material', async () => { + const controller = new RegistrationController({ + register: async () => { + await Promise.resolve(); + return { accepted: true as const, value }; + }, + } as unknown as RegistrationService); + const response = await controller.register({ + email: 'user@example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }); + assert.deepEqual(response, { + userId: 'user-id', + organizationId: 'organization-id', + workspaceId: 'workspace-id', + projectId: 'project-id', + membershipId: 'membership-id', + locale: 'vi-VN', + }); + assert.equal('email' in response, false); + assert.equal('accessToken' in response, false); +}); + +void test('[IAM-001] registration controller maps rejected and unavailable outcomes to stable problems', async () => { + const rejected = new RegistrationController({ + register: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'REGISTRATION_REJECTED' as const }; + }, + } as unknown as RegistrationService); + await assert.rejects( + rejected.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_REQUEST_REJECTED', + ); + const unavailable = new RegistrationController({ + register: async () => { + await Promise.resolve(); + return { accepted: false as const, code: 'REGISTRATION_UNAVAILABLE' as const }; + }, + } as unknown as RegistrationService); + await assert.rejects( + unavailable.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_UNAVAILABLE', + ); +}); + +void test('[IAM-001] registration controller fails closed when registration is not composed', async () => { + const controller = new RegistrationController(undefined); + await assert.rejects( + controller.register({ + email: 'user@example.com', + displayName: 'Name', + password: 'valid password here', + }), + (error: unknown) => + error instanceof RegistrationProblemError && error.code === 'REGISTRATION_UNAVAILABLE', + ); +}); diff --git a/services/api/test/features/iam/registration-http.test.ts b/services/api/test/features/iam/registration-http.test.ts new file mode 100644 index 00000000..3793f49d --- /dev/null +++ b/services/api/test/features/iam/registration-http.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-registration-repository.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; + +function credentials() { + return new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1, + algorithm: 'argon2id', + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, + }); +} + +void test('[IAM-001, IAM-009, IAM-016] registration HTTP creates a personal hierarchy and rejects a duplicate generically', async () => { + const { app } = await createApiApplication({ + registrationRepository: new InMemoryRegistrationRepositoryAdapter(), + passwordCredentials: credentials(), + }); + try { + const first = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'User@example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }, + }); + assert.equal(first.statusCode, 201); + const body = first.json>(); + assert.match(String(body['userId']), /^[0-9a-f-]{36}$/u); + assert.equal(body['locale'], 'vi-VN'); + assert.equal('accessToken' in body, false); + assert.equal('email' in body, false); + + const duplicate = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'user@example.com', + displayName: 'Different', + password: 'correct horse battery staple', + }, + }); + assert.equal(duplicate.statusCode, 400); + assert.match(duplicate.headers['content-type'] ?? '', /^application\/problem\+json/u); + assert.equal(duplicate.json<{ code: string }>().code, 'REGISTRATION_REQUEST_REJECTED'); + } finally { + await app.close(); + } +}); + +void test('[IAM-001] registration HTTP fails closed when durable registration is not configured', async () => { + const { app } = await createApiApplication(); + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { + email: 'user@example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }, + }); + assert.equal(response.statusCode, 503); + assert.equal(response.json<{ code: string }>().code, 'REGISTRATION_UNAVAILABLE'); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/features/iam/registration.service.test.ts b/services/api/test/features/iam/registration.service.test.ts new file mode 100644 index 00000000..10d47406 --- /dev/null +++ b/services/api/test/features/iam/registration.service.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemoryRegistrationRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-registration-repository.adapter.js'; +import { PasswordCredentialService } from '../../../src/features/iam/application/password-credential.service.js'; +import { RegistrationService } from '../../../src/features/iam/application/registration.service.js'; + +function ids() { + let next = 1; + return { + next: () => `00000000-0000-4000-8000-${String(next++).padStart(12, '0')}`, + }; +} + +function passwordCredentials() { + return new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + return { + schemaVersion: 1 as const, + algorithm: 'argon2id' as const, + encodedHash: '$argon2id$v=19$m=65536,p=1,t=3$YWJjZA==$ZWZmZw==', + }; + }, + verify: async () => { + await Promise.resolve(); + return true; + }, + }); +} + +void test('[IAM-001, IAM-009, IAM-016] registration atomically creates a Vietnamese personal owner hierarchy', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + clock: { now: () => new Date('2026-08-03T00:00:00.000Z') }, + }); + const result = await service.register({ + email: 'User@Example.com', + displayName: 'Nguyen An', + password: 'correct horse battery staple', + }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.email, 'user@example.com'); + assert.equal(result.value.bootstrap.user.locale, 'vi-VN'); + assert.equal(result.value.bootstrap.membership.roleId, 'owner'); + assert.equal(result.value.bootstrap.organization.personal, true); + assert.equal(repository.has('user@example.com'), true); + assert.equal( + repository.get('user@example.com')?.credential.encodedHash.includes('password'), + false, + ); +}); + +void test('[IAM-001] registration rejects duplicate email without disclosing account state', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.equal( + ( + await service.register({ + email: 'same@example.com', + displayName: 'One', + password: 'valid password here', + }) + ).accepted, + true, + ); + assert.deepEqual( + await service.register({ + email: 'SAME@example.com', + displayName: 'Two', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_REJECTED' }, + ); +}); + +void test('[IAM-001] registration validates input before persistence and maps hash failure safely', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.deepEqual( + await service.register({ + email: 'not-an-email', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'INVALID_INPUT' }, + ); + const unavailable = new RegistrationService({ + repository, + passwordCredentials: new PasswordCredentialService({ + hash: async () => { + await Promise.resolve(); + throw new Error('hash'); + }, + verify: async () => { + await Promise.resolve(); + return false; + }, + }), + ids: ids(), + }); + assert.deepEqual( + await unavailable.register({ + email: 'new@example.com', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_UNAVAILABLE' }, + ); +}); + +void test('[IAM-001] registration rolls back when persistence fails after staging', async () => { + const repository = new InMemoryRegistrationRepositoryAdapter(); + const service = new RegistrationService({ + repository: { + withTransaction: async (work) => + work({ + findByEmail: async () => { + await Promise.resolve(); + return false; + }, + save: async () => { + await Promise.resolve(); + throw new Error('database unavailable'); + }, + }), + }, + passwordCredentials: passwordCredentials(), + ids: ids(), + }); + assert.deepEqual( + await service.register({ + email: 'new@example.com', + displayName: 'Name', + password: 'valid password here', + }), + { accepted: false, code: 'REGISTRATION_UNAVAILABLE' }, + ); + assert.equal(repository.has('new@example.com'), false); +}); diff --git a/services/api/test/features/iam/service-account-composition.test.ts b/services/api/test/features/iam/service-account-composition.test.ts new file mode 100644 index 00000000..5f14c5e2 --- /dev/null +++ b/services/api/test/features/iam/service-account-composition.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ServiceAccountController } from '../../../src/features/iam/api/service-account.controller.js'; +import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { SERVICE_ACCOUNT_REPOSITORY_PORT } from '../../../src/features/iam/application/service-account-repository.port.js'; +import { + SERVICE_ACCOUNT_SERVICE, + ServiceAccountService, +} from '../../../src/features/iam/application/service-account.service.js'; +import { IamModule } from '../../../src/features/iam/iam.module.js'; + +void test('[IAM-013] IAM composition registers a replaceable service-account repository and lifecycle service', () => { + const service = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + { findMembership: () => Promise.resolve(undefined) } as never, + { issue: () => ({ secret: 'dbsa', digest: 'a'.repeat(64) }) }, + ); + const registered = IamModule.register({ serviceAccountService: service }); + assert.ok(registered.controllers?.includes(ServiceAccountController)); + assert.ok(registered.exports?.includes(SERVICE_ACCOUNT_REPOSITORY_PORT)); + assert.ok(registered.exports?.includes(SERVICE_ACCOUNT_SERVICE)); + assert.ok( + registered.providers?.some( + (provider) => + typeof provider === 'object' && + provider !== null && + 'provide' in provider && + provider.provide === SERVICE_ACCOUNT_SERVICE, + ), + ); +}); diff --git a/services/api/test/features/iam/service-account-repository.test.ts b/services/api/test/features/iam/service-account-repository.test.ts new file mode 100644 index 00000000..2d4dbe8b --- /dev/null +++ b/services/api/test/features/iam/service-account-repository.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createServiceAccountV1 } from '@databreeze/domain/service-account/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000701'; +const otherOrganizationId = '00000000-0000-4000-8000-000000000702'; +const workspaceId = '00000000-0000-4000-8000-000000000703'; +const accountId = '00000000-0000-4000-8000-000000000704'; +const correlationId = '00000000-0000-4000-8000-000000000705'; +const actorId = '00000000-0000-4000-8000-000000000706'; + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +const stableAccountId = stable(accountId); + +function context(scope: unknown, key = 'service-account-repository') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function account(overrides: Record = {}) { + const result = createServiceAccountV1({ + id: accountId, + organizationId, + workspaceId, + name: 'Import worker', + permissions: ['artifact.record.read'], + secretDigest: 'a'.repeat(64), + secretIssuedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid account'); + return result.value; +} + +void test('[IAM-013] service account repository preserves tenant scope and immutable copies', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }); + await repository.saveServiceAccount(organizationContext, account()); + + const found = await repository.findServiceAccount(organizationContext, stableAccountId); + assert.deepEqual(found, account()); + assert.notEqual(found, account()); + assert.equal( + (await repository.findServiceAccountByDigest(organizationContext, 'a'.repeat(64)))?.id, + stableAccountId, + ); + assert.equal( + await repository.findServiceAccountByDigest(organizationContext, 'b'.repeat(64)), + undefined, + ); + assert.equal( + await repository.findServiceAccount( + context({ scopeType: 'organization', organizationId: otherOrganizationId }), + stableAccountId, + ), + undefined, + ); + assert.equal((await repository.listServiceAccounts(organizationContext)).length, 1); +}); + +void test('[IAM-013] workspace scope is visible to its parent and child context but never another workspace', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'parent'); + await repository.saveServiceAccount(organizationContext, account()); + assert.equal( + ( + await repository.listServiceAccounts( + context({ scopeType: 'workspace', organizationId, workspaceId }, 'child'), + ) + ).length, + 1, + ); + assert.equal( + ( + await repository.listServiceAccounts( + context( + { scopeType: 'workspace', organizationId, workspaceId: otherOrganizationId }, + 'sibling', + ), + ) + ).length, + 0, + ); +}); + +void test('[IAM-013] replacement is revision guarded and transactions roll back on failure', async () => { + const repository = new InMemoryServiceAccountRepositoryAdapter(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'transaction'); + await repository.saveServiceAccount(organizationContext, account()); + const changed = Object.freeze({ ...account({ name: 'Changed' }), revision: 2 }); + await assert.rejects( + repository.replaceServiceAccount(organizationContext, changed, 2), + /REVISION_CONFLICT/, + ); + await assert.rejects( + repository.withTransaction(organizationContext, async (transaction) => { + await transaction.replaceServiceAccount(organizationContext, changed, 1); + throw new Error('ROLLBACK'); + }), + /ROLLBACK/, + ); + assert.equal( + (await repository.findServiceAccount(organizationContext, stableAccountId))?.name, + 'Import worker', + ); +}); diff --git a/services/api/test/features/iam/service-account-secret.adapter.test.ts b/services/api/test/features/iam/service-account-secret.adapter.test.ts new file mode 100644 index 00000000..1e64d961 --- /dev/null +++ b/services/api/test/features/iam/service-account-secret.adapter.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createHash } from 'node:crypto'; + +import { RandomServiceAccountSecretIssuer } from '../../../src/features/iam/adapter/random-service-account-secret.adapter.js'; + +void test('[IAM-013] random service-account secrets are high-entropy and digestable without retaining raw bytes', () => { + const issuer = new RandomServiceAccountSecretIssuer(() => Buffer.alloc(32, 7)); + const issued = issuer.issue(); + assert.match(issued.secret, /^dbsa_[A-Za-z0-9_-]{43}$/u); + assert.equal(issued.digest, createHash('sha256').update(issued.secret, 'utf8').digest('hex')); + assert.equal(issued.digest.length, 64); +}); + +void test('[IAM-013] malformed random sources fail closed instead of issuing a short credential', () => { + const issuer = new RandomServiceAccountSecretIssuer(() => Buffer.alloc(8, 1)); + assert.throws(() => issuer.issue(), /SECRET_GENERATION_FAILED/); +}); diff --git a/services/api/test/features/iam/service-account.controller.test.ts b/services/api/test/features/iam/service-account.controller.test.ts new file mode 100644 index 00000000..8e0ef183 --- /dev/null +++ b/services/api/test/features/iam/service-account.controller.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ServiceAccountController } from '../../../src/features/iam/api/service-account.controller.js'; +import { ServiceAccountProblemError } from '../../../src/features/iam/application/service-account-problem.error.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const actorId = '00000000-0000-4000-8000-000000000732'; +const correlationId = '00000000-0000-4000-8000-000000000733'; +const serviceAccountId = '00000000-0000-4000-8000-000000000734'; + +function context() { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: { scopeType: 'organization', organizationId }, + idempotencyKey: 'controller', + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function controller(overrides: Record = {}) { + const service = { + list: () => Promise.resolve({ accepted: true as const, value: [{ id: serviceAccountId }] }), + create: () => + Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'one-time' }, + }), + rotate: () => + Promise.resolve({ + accepted: true as const, + value: { account: { id: serviceAccountId }, secret: 'successor' }, + }), + revoke: () => Promise.resolve({ accepted: true as const, value: { id: serviceAccountId } }), + ...overrides, + }; + const requestContext = { resolve: () => Promise.resolve(context()) }; + return new ServiceAccountController(service as never, requestContext); +} + +void test('[IAM-013] controller exposes safe list/create/rotate/revoke results', async () => { + const instance = controller(); + assert.deepEqual(await instance.list({}, organizationId), [{ id: serviceAccountId }]); + assert.deepEqual( + await instance.create({}, 'request-key', { + name: 'Import worker', + permissions: ['artifact.record.read'], + }), + { account: { id: serviceAccountId }, secret: 'one-time' }, + ); + assert.deepEqual(await instance.rotate({}, serviceAccountId, { expectedRevision: 1 }), { + account: { id: serviceAccountId }, + secret: 'successor', + }); + assert.deepEqual(await instance.revoke({}, serviceAccountId, { expectedRevision: 2 }), { + id: serviceAccountId, + }); +}); + +void test('[IAM-013] controller rejects a path outside the authenticated organization', async () => { + await assert.rejects( + controller().list({}, '00000000-0000-4000-8000-000000000799'), + (error: unknown) => + error instanceof ServiceAccountProblemError && error.code === 'SERVICE_ACCOUNT_SCOPE_DENIED', + ); +}); + +void test('[IAM-013] controller maps lifecycle failures to stable problem codes', async () => { + await assert.rejects( + controller({ + revoke: () => Promise.resolve({ accepted: false as const, code: 'CONFLICT' as const }), + }).revoke({}, serviceAccountId, { expectedRevision: 1 }), + (error: unknown) => + error instanceof ServiceAccountProblemError && error.code === 'SERVICE_ACCOUNT_CONFLICT', + ); +}); diff --git a/services/api/test/features/iam/service-account.service.test.ts b/services/api/test/features/iam/service-account.service.test.ts new file mode 100644 index 00000000..0184d118 --- /dev/null +++ b/services/api/test/features/iam/service-account.service.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; + +import { InMemoryIamRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-iam-repository.adapter.js'; +import { InMemoryServiceAccountRepositoryAdapter } from '../../../src/features/iam/adapter/in-memory-service-account-repository.adapter.js'; +import { ServiceAccountService } from '../../../src/features/iam/application/service-account.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { + parseStableIdentifierV1, + parseTenantScopeV1, + type StableIdentifierV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +const organizationId = '00000000-0000-4000-8000-000000000711'; +const workspaceId = '00000000-0000-4000-8000-000000000712'; +const actorId = '00000000-0000-4000-8000-000000000713'; +const correlationId = '00000000-0000-4000-8000-000000000714'; +const accountId = '00000000-0000-4000-8000-000000000715'; + +function stable(value: string): StableIdentifierV1 { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid identifier'); + return parsed.value; +} + +function scopeValue(value: unknown): TenantScopeV1 { + const parsed = parseTenantScopeV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid scope'); + return parsed.value; +} + +function context(scope: unknown, key = 'service-account-service') { + const result = createIamTenantContextV1({ + actorId, + correlationId, + tenantScope: scope, + idempotencyKey: key, + authorizationEpoch: 1, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context'); + return result.value; +} + +function membership( + roleId = 'owner', + scope: unknown = { scopeType: 'organization', organizationId }, +) { + return { + id: stable('00000000-0000-4000-8000-000000000717'), + principalId: stable(actorId), + scope: scopeValue(scope), + roleId, + status: 'ACTIVE' as const, + revision: 1, + }; +} + +function service() { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership()]); + const digest = (secret: string) => createHash('sha256').update(secret, 'utf8').digest('hex'); + const secrets = [ + { secret: 'dbsa_first', digest: digest('dbsa_first') }, + { secret: 'dbsa_second', digest: digest('dbsa_second') }, + ]; + const service = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + iam, + { issue: () => secrets.shift() ?? { secret: 'dbsa_fallback', digest: 'c'.repeat(64) } }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + ); + return service; +} + +void test('[IAM-013] authorized creation returns a one-time secret but never the persisted digest', async () => { + const accountService = service(); + const result = await accountService.create( + context({ scopeType: 'organization', organizationId }), + { + name: 'Import worker', + permissions: ['artifact.record.read'], + }, + ); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.secret, 'dbsa_first'); + assert.equal('secretDigest' in result.value.account, false); + assert.equal(result.value.account.status, 'ACTIVE'); +}); + +void test('[IAM-013] service account management requires the delegated IAM permission and target scope', async () => { + const iam = new InMemoryIamRepositoryAdapter(); + iam.seed([membership('viewer')]); + const accountService = new ServiceAccountService( + new InMemoryServiceAccountRepositoryAdapter(), + iam, + { issue: () => ({ secret: 'dbsa', digest: 'd'.repeat(64) }) }, + () => new Date('2026-01-01T00:00:00.000Z'), + () => accountId, + ); + assert.deepEqual( + await accountService.create(context({ scopeType: 'organization', organizationId }), { + name: 'Denied', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); + iam.seed([membership('owner')]); + assert.deepEqual( + await accountService.create(context({ scopeType: 'workspace', organizationId, workspaceId }), { + name: 'Workspace worker', + workspaceId: '00000000-0000-4000-8000-000000000799', + permissions: ['artifact.record.read'], + }), + { accepted: false, code: 'SCOPE_DENIED' }, + ); +}); + +void test('[IAM-013] rotation is revision guarded and revocation is permanent', async () => { + const accountService = service(); + const organizationContext = context({ scopeType: 'organization', organizationId }, 'lifecycle'); + const created = await accountService.create(organizationContext, { + name: 'Lifecycle worker', + permissions: ['artifact.record.read'], + }); + assert.equal(created.accepted, true); + const rotated = await accountService.rotate(organizationContext, accountId, 1); + assert.equal(rotated.accepted, true); + if (!rotated.accepted) return; + assert.equal(rotated.value.secret, 'dbsa_second'); + assert.deepEqual(await accountService.rotate(organizationContext, accountId, 1), { + accepted: false, + code: 'CONFLICT', + }); + const revoked = await accountService.revoke(organizationContext, accountId, 2); + assert.equal(revoked.accepted, true); + assert.deepEqual(await accountService.revoke(organizationContext, accountId, 3), { + accepted: false, + code: 'REVOKED', + }); + assert.equal((await accountService.list(organizationContext)).accepted, true); +}); + +void test('[IAM-013] credential authentication is digest-bound, updates last use, and fails closed', async () => { + const accountService = service(); + const organizationContext = context( + { scopeType: 'organization', organizationId }, + 'authenticate', + ); + const created = await accountService.create(organizationContext, { + name: 'Auth worker', + permissions: ['artifact.record.read'], + }); + assert.equal(created.accepted, true); + const authenticated = await accountService.authenticate( + organizationContext, + 'dbsa_first', + '2026-01-01T00:01:00.000Z', + ); + assert.equal(authenticated.accepted, true); + if (!authenticated.accepted) return; + assert.equal(authenticated.value.id, accountId); + assert.equal(authenticated.value.lastUsedAt, '2026-01-01T00:01:00.000Z'); + assert.deepEqual( + await accountService.authenticate( + organizationContext, + 'wrong-secret', + '2026-01-01T00:02:00.000Z', + ), + { accepted: false, code: 'INVALID_CREDENTIALS' }, + ); + assert.deepEqual( + await accountService.authenticate( + organizationContext, + 'dbsa_first', + '2026-01-01T00:00:30.000Z', + ), + { accepted: false, code: 'INVALID_CREDENTIALS' }, + ); +}); diff --git a/services/api/test/features/jra/approval.service.test.ts b/services/api/test/features/jra/approval.service.test.ts index bac49efd..664ff6ea 100644 --- a/services/api/test/features/jra/approval.service.test.ts +++ b/services/api/test/features/jra/approval.service.test.ts @@ -41,7 +41,7 @@ const ids = { changedDecisionId: stable(changedDecisionId), }; -function context(key: string) { +function context(key: string, options: { readonly mfaReenrollmentRequired?: boolean } = {}) { const result = createIamTenantContextV1({ tenantScope: { scopeType: 'workspace', @@ -52,12 +52,39 @@ function context(key: string) { correlationId: ids.correlationId, idempotencyKey: key, authorizationEpoch: 1, + ...options, }); assert.equal(result.accepted, true); if (!result.accepted) throw new Error('invalid context'); return result.value; } +void test('[IAM-015, JRA-011] recovery-forced MFA re-enrollment blocks privileged approval decisions', async () => { + const service = new ApprovalService(new InMemoryApprovalRepositoryAdapter()); + assert.equal( + (await service.publishPolicy(context('policy-gated'), policyInput())).accepted, + true, + ); + assert.equal( + (await service.openRequest(context('request-gated'), requestInput())).accepted, + true, + ); + + assert.deepEqual( + await service.decide(context('decision-gated', { mfaReenrollmentRequired: true }), { + requestId: ids.requestId, + decisionId: ids.decisionId, + actorId: ids.approverId, + decision: 'APPROVE', + subjectHash, + mfaAssertionId: '00000000-0000-4000-8000-000000000023', + decidedAt: '2026-01-01T00:01:00.000Z', + actorRole: 'ADMIN', + }), + { accepted: false, code: 'MFA_REENROLLMENT_REQUIRED' }, + ); +}); + function policyInput() { return { policyId: ids.policyId, diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 556aa5eb..d94a81f1 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -84,6 +84,19 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '#/components/schemas/MembershipRejectedResponseDto', ); } + const invitationResponses = ( + firstDocument.paths['/v1/invitations']?.post as OperationLike | undefined + )?.responses; + for (const status of ['400', '403', '404', '409', '503']) { + assert.equal( + ( + invitationResponses?.[status]?.content?.['application/json'] as + | { readonly schema?: { readonly $ref?: string } } + | undefined + )?.schema?.$ref, + '#/components/schemas/InvitationRejectedResponseDto', + ); + } const paths = Object.keys(firstDocument.paths).sort(); assert.deepEqual(paths, [ @@ -111,13 +124,18 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifacts/inbox', '/v1/artifacts/inbox/{inboxItemId}', '/v1/artifacts/{versionId}/evidence/{evidenceId}/grants', + '/v1/audit/attestations', + '/v1/audit/attestations/{attestationId}/verify', '/v1/audit/events', '/v1/audit/seals', '/v1/auth/me', '/v1/auth/mfa/factors', '/v1/auth/mfa/factors/{factorId}/verify', '/v1/auth/mfa/recovery/redeem', + '/v1/auth/recovery', + '/v1/auth/recovery/complete', '/v1/auth/refresh', + '/v1/auth/register', '/v1/auth/sign-in', '/v1/auth/sign-out', '/v1/data-mode-policies', @@ -157,8 +175,12 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/devices/{deviceId}/grants', '/v1/devices/{deviceId}/key', '/v1/devices/{deviceId}/revoke', + '/v1/entitlements/leases/{leaseId}/verify', '/v1/entitlements/snapshots/{snapshotId}', + '/v1/entitlements/snapshots/{snapshotId}/leases', '/v1/entitlements/usage', + '/v1/invitations', + '/v1/invitations/accept', '/v1/me/bootstrap', '/v1/memberships', '/v1/memberships/{membershipId}/accept', @@ -166,6 +188,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/memberships/{membershipId}/transition', '/v1/organizations/{organizationId}', '/v1/organizations/{organizationId}/devices', + '/v1/organizations/{organizationId}/service-accounts', '/v1/organizations/{organizationId}/workspaces', '/v1/projects/{projectId}', '/v1/protected-document-unlocks', @@ -178,6 +201,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/reference-entities/{entityId}/resolutions', '/v1/reference-entities/{entityId}/versions', '/v1/reference-entities/{entityId}/versions/{versionId}', + '/v1/service-accounts', + '/v1/service-accounts/{serviceAccountId}/revoke', + '/v1/service-accounts/{serviceAccountId}/rotate', '/v1/spreadsheet-audits', '/v1/spreadsheet-audits/{auditId}', '/v1/system/compatibility', @@ -278,6 +304,9 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, 'GET /v1/system/compatibility', 'POST /v1/system/compatibility/check', 'POST /v1/auth/sign-in', + 'POST /v1/auth/register', + 'POST /v1/auth/recovery', + 'POST /v1/auth/recovery/complete', 'POST /v1/auth/refresh', ]); for (const [path, pathItem] of Object.entries(firstDocument.paths) as Array< diff --git a/services/api/test/platform/http/session-tenant-context.test.ts b/services/api/test/platform/http/session-tenant-context.test.ts index 6e6cc77b..b31c6ac9 100644 --- a/services/api/test/platform/http/session-tenant-context.test.ts +++ b/services/api/test/platform/http/session-tenant-context.test.ts @@ -83,6 +83,18 @@ void test('uses the request id for read-only calls and rejects unsafe principal ); }); +void test('[IAM-015] carries the live recovery re-enrollment gate into protected request context', async () => { + const adapter = new SessionRequestTenantContextAdapter({ + findPrincipalByAccessToken: () => + Promise.resolve({ ...principal, mfaReenrollmentRequired: true }), + }); + const context = await adapter.resolve({ + method: 'GET', + headers: { authorization: 'Bearer opaque-access-token-123456789' }, + }); + assert.equal(context.mfaReenrollmentRequired, true); +}); + void test('requires an explicit idempotency key for authenticated mutations', async () => { const adapter = new SessionRequestTenantContextAdapter({ findPrincipalByAccessToken: () => Promise.resolve(principal), diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 49a22a3a..816b3536 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -82,9 +82,14 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "sa"\."spreadsheet_audit_results"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_tokens"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."recovery_challenges"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."access_tokens"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."device_enrollment_challenges"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."device_grants"/); + assert.match(diff.stdout, /CREATE TABLE "iam"\."service_accounts"/); + assert.match(diff.stdout, /CREATE TABLE "bua"\."entitlement_leases"/); + assert.match(diff.stdout, /CREATE TABLE "aud"\."audit_seal_attestations"/); const migrationsDirectory = path.join(apiDirectory, 'prisma', 'migrations'); const inventory = (await readdir(migrationsDirectory)).sort(); @@ -125,6 +130,11 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260803010000_iam_session_scope_binding', '20260803020000_bua_project_usage_scope', '20260803030000_iam_membership_scope_uniqueness', + '20260803040000_iam_invitation_tokens', + '20260803050000_iam_recovery_challenges', + '20260803060000_iam_service_accounts', + '20260803070000_bua_entitlement_leases', + '20260803080000_aud_seal_attestations', 'migration_lock.toml', ]); const migration = await readFile( @@ -536,4 +546,18 @@ test('the schema diff and centrally ordered migration inventory establish platfo ); assert.match(membershipUniquenessMigration, /COALESCE\("workspace_id"::text, ''\)/u); assert.match(membershipUniquenessMigration, /COALESCE\("project_id"::text, ''\)/u); + const invitationMigration = await readFile( + path.join(migrationsDirectory, '20260803040000_iam_invitation_tokens', 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE TABLE "iam"."invitation_tokens"', + 'CREATE UNIQUE INDEX "invitation_tokens_token_digest_key"', + 'CREATE INDEX "invitation_tokens_membership_status_idx"', + ]) { + assert.match( + invitationMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\\]\\]/g, '\\$&')), + ); + } });