diff --git a/eslint.config.mjs b/eslint.config.mjs index d1aa84ad..b5ccad48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ export default tseslint.config( '**/out/**', 'packages/contracts/generated/**', 'tools/repo-cli/test/fixtures/**', + '.worktrees/**', ], }, eslint.configs.recommended, diff --git a/packages/domain/src/artifact-governance/v1.ts b/packages/domain/src/artifact-governance/v1.ts index a23d8957..ca06568b 100644 --- a/packages/domain/src/artifact-governance/v1.ts +++ b/packages/domain/src/artifact-governance/v1.ts @@ -119,8 +119,12 @@ export function createArtifactLineageV1(input: { return rejected('INVALID_IDENTIFIER'); if (new Set(sourceArtifactVersionIds).size !== sourceArtifactVersionIds.length) return rejected('DUPLICATE_IDENTIFIER'); + if (sourceArtifactVersionIds.includes(derivedArtifactVersionId)) + return rejected('DUPLICATE_IDENTIFIER'); if (input.sourceTenantScopes !== undefined) { if (!Array.isArray(input.sourceTenantScopes)) return rejected('INVALID_SCOPE'); + if (input.sourceTenantScopes.length !== sourceArtifactVersionIds.length) + return rejected('INVALID_SCOPE'); for (const candidate of input.sourceTenantScopes) { const sourceScope = scope(candidate); if (!sourceScope || !tenantScopesEqualV1(sourceScope, tenantScope)) @@ -129,6 +133,7 @@ export function createArtifactLineageV1(input: { } if (!Array.isArray(input.coordinateLineage)) return rejected('INVALID_LINEAGE'); const coordinateLineage: CoordinateLineageV1[] = []; + const coordinatePairs = new Set(); for (const candidate of input.coordinateLineage) { if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return rejected('INVALID_LINEAGE'); @@ -139,6 +144,9 @@ export function createArtifactLineageV1(input: { if (!sourceEvidenceId || !derivedEvidenceId) return rejected('INVALID_IDENTIFIER'); if (!['COPIED', 'NORMALIZED', 'AGGREGATED', 'REDACTED'].includes(transform as string)) return rejected('INVALID_TRANSFORM'); + const pair = `${sourceEvidenceId}:${derivedEvidenceId}`; + if (coordinatePairs.has(pair)) return rejected('DUPLICATE_IDENTIFIER'); + coordinatePairs.add(pair); coordinateLineage.push( Object.freeze({ sourceEvidenceId, diff --git a/packages/domain/src/artifact-intake/v1.ts b/packages/domain/src/artifact-intake/v1.ts index ef57f1e9..0a89dc3d 100644 --- a/packages/domain/src/artifact-intake/v1.ts +++ b/packages/domain/src/artifact-intake/v1.ts @@ -8,6 +8,9 @@ import { type TenantScopeV1, } from '../tenant-scope/v1.js'; import type { ArtifactVersionV1 } from '../artifact/v1.js'; +import type { ArtifactScanStateV1 } from '../artifact/v1.js'; + +export type { ArtifactScanStateV1 } from '../artifact/v1.js'; /** IAE-001, IAE-009, IAE-010, IAE-013: intake admission is explicit and idempotent. */ export const ARTIFACT_INTAKE_SCHEMA_VERSION_V1 = 1 as const; @@ -20,7 +23,6 @@ export type InboxItemStateV1 = | 'RESOLVED' | 'QUARANTINED' | 'ARCHIVED'; -export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; export type InboxPriorityV1 = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; export interface InboxItemV1 { diff --git a/packages/domain/src/artifact/v1.ts b/packages/domain/src/artifact/v1.ts index df93850b..df85eaba 100644 --- a/packages/domain/src/artifact/v1.ts +++ b/packages/domain/src/artifact/v1.ts @@ -14,6 +14,7 @@ export const ARTIFACT_SCHEMA_VERSION_V1 = 1 as const; export type ArtifactDataModeV1 = 'Local' | 'Hybrid' | 'Cloud'; export type ArtifactSourceKindV1 = 'FILE' | 'FOLDER' | 'CAPTURE' | 'GENERATED'; export type ArtifactVersionStatusV1 = 'QUARANTINED' | 'ACTIVE' | 'DELETED'; +export type ArtifactScanStateV1 = 'PENDING' | 'CLEAN' | 'MALICIOUS' | 'FAILED'; export type ArtifactPlacementKindV1 = 'LOCAL' | 'CLOUD'; export type EvidenceSourceStateV1 = 'AVAILABLE' | 'SOURCE_OFFLINE' | 'DELETED'; @@ -42,6 +43,7 @@ export interface ArtifactVersionV1 { readonly displayName: string; readonly createdAt: StrictUtcTimestampV1; readonly status: ArtifactVersionStatusV1; + readonly scanState: ArtifactScanStateV1; } export interface ContentPlacementV1 { @@ -83,6 +85,7 @@ export type ArtifactErrorCodeV1 = | 'INVALID_MEDIA_TYPE' | 'INVALID_NAME' | 'INVALID_STATUS' + | 'INVALID_SCAN_STATE' | 'INVALID_REVISION' | 'REVISION_CONFLICT' | 'INVALID_REFERENCE' @@ -172,6 +175,10 @@ function isStatus(input: unknown): input is ArtifactVersionStatusV1 { return input === 'QUARANTINED' || input === 'ACTIVE' || input === 'DELETED'; } +function isScanState(input: unknown): input is ArtifactScanStateV1 { + return input === 'PENDING' || input === 'CLEAN' || input === 'MALICIOUS' || input === 'FAILED'; +} + export function createArtifactVersionV1(input: { readonly artifactId: unknown; readonly versionId: unknown; @@ -184,6 +191,7 @@ export function createArtifactVersionV1(input: { readonly displayName: unknown; readonly createdAt: unknown; readonly status?: unknown; + readonly scanState?: unknown; }): ArtifactResultV1 { const artifactId = stableId(input.artifactId); const versionId = stableId(input.versionId); @@ -193,6 +201,7 @@ export function createArtifactVersionV1(input: { const displayNameValue = displayName(input.displayName); const createdAt = timestamp(input.createdAt); const status = input.status ?? 'ACTIVE'; + const scanState = input.scanState ?? 'PENDING'; if (!artifactId || !versionId) return rejected('INVALID_IDENTIFIER'); if (!tenantScope) return rejected('INVALID_SCOPE'); if (!isSourceKind(input.sourceKind)) return rejected('INVALID_KIND'); @@ -208,6 +217,7 @@ export function createArtifactVersionV1(input: { if (!displayNameValue) return rejected('INVALID_NAME'); if (!createdAt) return rejected('INVALID_TIMESTAMP'); if (!isStatus(status)) return rejected('INVALID_STATUS'); + if (!isScanState(scanState)) return rejected('INVALID_SCAN_STATE'); return accepted( Object.freeze({ schemaVersion: ARTIFACT_SCHEMA_VERSION_V1, @@ -222,6 +232,7 @@ export function createArtifactVersionV1(input: { displayName: displayNameValue, createdAt, status, + scanState, }), ); } @@ -330,12 +341,48 @@ function spreadsheetColumnNumber(value: string): number { return result; } +function nonNegativeCount(input: unknown): input is number { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0; +} + +function isEvidenceGeometry(input: unknown): input is EvidenceGeometryV1 { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return false; + const record = input as Record; + if (record['kind'] === 'SPREADSHEET') { + if (!Array.isArray(record['sheets']) || record['sheets'].length > 512) return false; + const names = new Set(); + return record['sheets'].every((candidate) => { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) + return false; + const sheet = candidate as Record; + const name = boundedText(sheet['name'], 255); + if ( + !name || + names.has(name) || + !nonNegativeCount(sheet['maxRow']) || + !nonNegativeCount(sheet['maxColumn']) || + sheet['maxRow'] > 1_000_000 || + sheet['maxColumn'] > 16_384 + ) + return false; + names.add(name); + return true; + }); + } + if (record['kind'] === 'PAGED') + return nonNegativeCount(record['maxPage']) && record['maxPage'] <= 10_000_000; + if (record['kind'] === 'TABULAR') + return nonNegativeCount(record['maxRow']) && record['maxRow'] <= 1_000_000_000; + return false; +} + /** IAE-006: evidence coordinates are checked against the exact source geometry. */ export function validateEvidenceCoordinateV1( coordinate: EvidenceCoordinateV1, geometry?: EvidenceGeometryV1, ): ArtifactResultV1 { if (!geometry) return accepted(true); + if (!isEvidenceGeometry(geometry)) return rejected('INVALID_COORDINATE'); if (coordinate.kind === 'CELL') { if (geometry.kind !== 'SPREADSHEET') return rejected('COORDINATE_OUT_OF_BOUNDS'); const sheet = geometry.sheets.find((candidate) => candidate.name === coordinate.sheet); diff --git a/packages/domain/src/dataset-quality/v1.ts b/packages/domain/src/dataset-quality/v1.ts index 60d76374..f3434d47 100644 --- a/packages/domain/src/dataset-quality/v1.ts +++ b/packages/domain/src/dataset-quality/v1.ts @@ -275,6 +275,11 @@ export function createDatasetQualityResultV1(input: { const typedFindings = findings as DatasetQualityFindingV1[]; if (new Set(typedFindings.map((candidate) => candidate.findingId)).size !== typedFindings.length) return rejected('DUPLICATE_FINDING'); + if ( + input.qualityState !== 'INCOMPLETE' && + qualityStateFromFindingsV1(typedFindings) !== input.qualityState + ) + return rejected('INVALID_QUALITY_STATE'); return accepted( Object.freeze({ schemaVersion: DATASET_QUALITY_SCHEMA_VERSION_V1, diff --git a/packages/domain/src/spreadsheet-audit/v1.ts b/packages/domain/src/spreadsheet-audit/v1.ts index 8e3ca9a2..7f170e1b 100644 --- a/packages/domain/src/spreadsheet-audit/v1.ts +++ b/packages/domain/src/spreadsheet-audit/v1.ts @@ -97,6 +97,12 @@ function count(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0 ? input : undefined; } +function columnNumber(value: string): number { + let result = 0; + for (const character of value) result = result * 26 + character.charCodeAt(0) - 64; + return result; +} + function sheet(input: unknown): SpreadsheetAuditSheetV1 | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; const record = input as Record; @@ -181,9 +187,17 @@ export function createSpreadsheetAuditResultV1(input: { const validFindings = findings as SpreadsheetAuditFindingV1[]; if (new Set(validFindings.map((candidate) => candidate.findingId)).size !== validFindings.length) return rejected('DUPLICATE_IDENTIFIER'); - const sheetIds = new Set(validSheets.map((candidate) => candidate.sheetId)); - if (validFindings.some((candidate) => !sheetIds.has(candidate.sheetId))) - return rejected('INVALID_IDENTIFIER'); + const sheetsById = new Map(validSheets.map((candidate) => [candidate.sheetId, candidate])); + for (const candidate of validFindings) { + const targetSheet = sheetsById.get(candidate.sheetId); + if (!targetSheet) return rejected('INVALID_IDENTIFIER'); + const address = /^([A-Z]{1,3})([1-9][0-9]*)$/u.exec(candidate.address); + if (!address) return rejected('INVALID_COORDINATE'); + const column = columnNumber(address[1] ?? ''); + const row = Number(address[2]); + if (column > targetSheet.maxColumn || row > targetSheet.maxRow) + return rejected('INVALID_COORDINATE'); + } if (!Array.isArray(input.blockedReasons) || input.blockedReasons.length > 3) return rejected('INVALID_BLOCKED_REASON'); const validBlockedReasons: SpreadsheetAuditBlockedReasonV1[] = []; diff --git a/packages/domain/test/artifact-governance-v1.test.mjs b/packages/domain/test/artifact-governance-v1.test.mjs index bbe404c8..0f2481b2 100644 --- a/packages/domain/test/artifact-governance-v1.test.mjs +++ b/packages/domain/test/artifact-governance-v1.test.mjs @@ -34,6 +34,41 @@ void test('[IAE-003, IAE-007, IAE-012] lineage pins source versions and typed tr if (result.accepted) assert.equal(result.value.coordinateLineage[0]?.transform, 'NORMALIZED'); }); +void test('[IAE-007] lineage rejects self-references, scope cardinality drift, and duplicate coordinate mappings', () => { + const base = { + lineageId: '00000000-0000-4000-8000-000000000015', + derivedArtifactVersionId: '00000000-0000-4000-8000-000000000016', + tenantScope: scope, + sourceArtifactVersionIds: ['00000000-0000-4000-8000-000000000017'], + processorVersion: 'normalizer@1', + coordinateLineage: [ + { + sourceEvidenceId: '00000000-0000-4000-8000-000000000018', + derivedEvidenceId: '00000000-0000-4000-8000-000000000019', + transform: 'COPIED', + }, + ], + }; + assert.deepEqual( + createArtifactLineageV1({ + ...base, + sourceArtifactVersionIds: [base.derivedArtifactVersionId], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); + assert.deepEqual(createArtifactLineageV1({ ...base, sourceTenantScopes: [] }), { + accepted: false, + code: 'INVALID_SCOPE', + }); + assert.deepEqual( + createArtifactLineageV1({ + ...base, + coordinateLineage: [...base.coordinateLineage, ...base.coordinateLineage], + }), + { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, + ); +}); + void test('[IAE-008] derived data mode cannot be wider than its least-permissive source', () => { const source = createArtifactVersionV1({ artifactId: '00000000-0000-4000-8000-000000000020', diff --git a/packages/domain/test/artifact-v1.test.mjs b/packages/domain/test/artifact-v1.test.mjs index b182283a..aac412b5 100644 --- a/packages/domain/test/artifact-v1.test.mjs +++ b/packages/domain/test/artifact-v1.test.mjs @@ -31,9 +31,21 @@ void test('[IAE-001, IAE-003] artifact versions normalize and freeze immutable m assert.equal(result.accepted, true); if (!result.accepted) return; assert.equal(result.value.contentSha256, 'a'.repeat(64)); + assert.equal(result.value.scanState, 'PENDING'); assert.equal(Object.isFrozen(result.value), true); }); +void test('[IAE-009, IAE-010] artifact scan state is bounded and immutable metadata includes it', () => { + const result = createArtifactVersionV1({ ...base, scanState: 'MALICIOUS' }); + assert.equal(result.accepted, true); + if (!result.accepted) return; + assert.equal(result.value.scanState, 'MALICIOUS'); + assert.deepEqual(createArtifactVersionV1({ ...base, scanState: 'UNKNOWN' }), { + accepted: false, + code: 'INVALID_SCAN_STATE', + }); +}); + void test('[IAE-002, DSO-003] Local artifacts accept only opaque local placements', () => { const artifact = createArtifactVersionV1(base); assert.equal(artifact.accepted, true); @@ -113,4 +125,15 @@ void test('[IAE-006] evidence coordinates are validated against exact source geo validateEvidenceCoordinateV1({ kind: 'PAGE', page: 4 }, { kind: 'PAGED', maxPage: 3 }), { accepted: false, code: 'COORDINATE_OUT_OF_BOUNDS' }, ); + assert.deepEqual( + validateEvidenceCoordinateV1( + { kind: 'CELL', sheet: 'Sheet1', address: 'B4' }, + { kind: 'SPREADSHEET', sheets: null }, + ), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); + assert.deepEqual( + validateEvidenceCoordinateV1({ kind: 'PAGE', page: 1 }, { kind: 'PAGED', maxPage: '3' }), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); }); diff --git a/packages/domain/test/dataset-quality-v1.test.mjs b/packages/domain/test/dataset-quality-v1.test.mjs index 2d7a621e..e378911d 100644 --- a/packages/domain/test/dataset-quality-v1.test.mjs +++ b/packages/domain/test/dataset-quality-v1.test.mjs @@ -68,6 +68,10 @@ void test('[DSM-020] quality state is deterministic from finding severity and co 'BLOCKED', ); assert.equal(qualityStateFromFindingsV1([], true), 'INCOMPLETE'); + assert.deepEqual(result({ qualityState: 'PASS' }), { + accepted: false, + code: 'INVALID_QUALITY_STATE', + }); }); void test('[DSM-013] quality result validation rejects malformed hashes, counts, and duplicate findings', () => { diff --git a/packages/domain/test/spreadsheet-audit-v1.test.mjs b/packages/domain/test/spreadsheet-audit-v1.test.mjs index 4ee400d5..44ed6045 100644 --- a/packages/domain/test/spreadsheet-audit-v1.test.mjs +++ b/packages/domain/test/spreadsheet-audit-v1.test.mjs @@ -54,3 +54,20 @@ void test('[SA-005] findings cannot reference an unknown sheet or duplicate IDs' { accepted: false, code: 'DUPLICATE_IDENTIFIER' }, ); }); + +void test('[SA-006] findings must stay inside the exact sheet geometry', () => { + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], address: 'E1' }], + }), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); + assert.deepEqual( + createSpreadsheetAuditResultV1({ + ...base, + findings: [{ ...base.findings[0], address: 'A11' }], + }), + { accepted: false, code: 'INVALID_COORDINATE' }, + ); +}); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index b9ffee49..87e58e83 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -1897,6 +1897,76 @@ "tags": ["artifacts"] } }, + "/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" } }, + { + "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": "Resolve one exact evidence reference to a safe opaque action", + "tags": ["artifacts"] + } + }, "/v1/artifact-versions/{versionId}/lineage": { "get": { "operationId": "ArtifactLineageController.forDerived", @@ -2113,6 +2183,75 @@ "tags": ["artifacts"] } }, + "/v1/artifact-deletion-requests/{requestId}": { + "get": { + "operationId": "ArtifactRetentionController.find", + "parameters": [ + { "name": "requestId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "400": { + "description": "The request was malformed or failed closed validation.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read one governed artifact deletion request", + "tags": ["artifacts"] + } + }, "/v1/artifact-versions/{versionId}/deletion-requests": { "post": { "operationId": "ArtifactRetentionController.request", diff --git a/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql b/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql new file mode 100644 index 00000000..d8e50779 --- /dev/null +++ b/services/api/prisma/migrations/20260803000000_iae_lineage_uniqueness/migration.sql @@ -0,0 +1,3 @@ +-- IAE-007: one immutable lineage record is authoritative for each derived version. +CREATE UNIQUE INDEX "artifact_lineage_derived_version_key" + ON "iae"."artifact_lineage"("derived_artifact_version_id"); diff --git a/services/api/prisma/schema/iae.prisma b/services/api/prisma/schema/iae.prisma index dd254b72..1752f715 100644 --- a/services/api/prisma/schema/iae.prisma +++ b/services/api/prisma/schema/iae.prisma @@ -62,7 +62,7 @@ model ArtifactLineageRecord { coordinateLineage Json @map("coordinate_lineage") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - @@index([derivedArtifactVersionId], map: "artifact_lineage_derived_version_idx") + @@unique([derivedArtifactVersionId], map: "artifact_lineage_derived_version_key") @@index([organizationId, workspaceId, projectId], map: "artifact_lineage_scope_idx") @@map("artifact_lineage") @@schema("iae") diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 66d9dc54..c3fed050 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -9,6 +9,8 @@ import type { IamModuleOptions } from './features/iam/iam.module.js'; import type { IaeModuleOptions } from './features/iae/iae.module.js'; import type { DsmModuleOptions } from './features/dsm/dsm.module.js'; import type { DsoModuleOptions } from './features/dso/dso.module.js'; +import type { AudModuleOptions } from './features/aud/aud.module.js'; +import type { BuaModuleOptions } from './features/bua/bua.module.js'; import type { SaModuleOptions } from './features/sa/sa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; @@ -30,6 +32,8 @@ export interface ApiApplicationOptions IaeModuleOptions, DsmModuleOptions, DsoModuleOptions, + AudModuleOptions, + BuaModuleOptions, SaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index 13d5019f..b73bc1b5 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -121,6 +121,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: Readonly>; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; } export interface EntitlementDatabaseClientV1 { @@ -562,11 +566,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV reservation.revision !== current.revision + 1 ) throw new Error('BUA_RESERVATION_CONFLICT'); - if (!this.client.usageReservationRecord.update) throw new Error('BUA_UPDATE_UNAVAILABLE'); - await this.client.usageReservationRecord.update({ - where: { id: reservation.reservationId }, + if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); + const result = await this.client.usageReservationRecord.updateMany({ + where: { id: reservation.reservationId, revision: current.revision }, data: { status: reservation.status, revision: reservation.revision, updatedAt: new Date() }, }); + if (result.count !== 1) throw new Error('BUA_RESERVATION_CONFLICT'); } } } diff --git a/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts index 3bd2c6d7..87cd4d3e 100644 --- a/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-governed-dataset-repository.adapter.ts @@ -27,6 +27,7 @@ export interface GovernedDatasetDatabaseRowV1 { readonly status: string; readonly createdAt: Date; readonly publishedAt: Date | null; + readonly revision: number; readonly canonicalHash: string; } @@ -88,6 +89,7 @@ function domainScope(row: GovernedDatasetDatabaseRowV1): TenantScopeV1 { } function rowToDomain(row: GovernedDatasetDatabaseRowV1): GovernedDatasetDefinitionV1 { + if (row.revision !== 1) throw new Error('DSM_PERSISTED_REVISION_INVALID'); const created = createGovernedDatasetDefinitionV1({ datasetId: row.datasetId, versionId: row.id, diff --git a/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts index 3b0b7bb3..3c7b880c 100644 --- a/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts +++ b/services/api/src/features/dsm/adapter/prisma-mapping-repository.adapter.ts @@ -24,6 +24,7 @@ export interface MappingDatabaseRowV1 { readonly status: string; readonly createdAt: Date; readonly publishedAt: Date | null; + readonly revision: number; readonly canonicalHash: string; } @@ -72,6 +73,7 @@ function databaseScope(scope: TenantScopeV1) { } function rowToDomain(row: MappingDatabaseRowV1): MappingDefinitionV1 { + if (row.revision !== 1) throw new Error('DSM_PERSISTED_REVISION_INVALID'); const parsed = createMappingDefinitionV1({ datasetId: row.datasetId, versionId: row.id, diff --git a/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts index 8602dd37..f5666131 100644 --- a/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-authorization-repository.adapter.ts @@ -62,6 +62,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceAuthorizationDatabaseClientV1 { @@ -292,16 +296,17 @@ class PrismaDeviceAuthorizationTransactionAdapter implements DeviceAuthorization if (!tenantScopeContainsV1(context.tenantScope, current.tenantScope)) throw new Error('DSO_SCOPE_NARROWING_REQUIRED'); if (current.revision !== expectedRevision) throw new Error('DSO_REVISION_CONFLICT'); - if (!this.client.deviceGrantRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); const next = Object.freeze({ ...current, status: current.status === 'ACTIVE' ? ('REVOKED' as const) : current.status, revision: current.status === 'ACTIVE' ? current.revision + 1 : current.revision, }); - await this.client.deviceGrantRecord.update({ - where: { id: grantId }, + if (!this.client.deviceGrantRecord.updateMany) throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceGrantRecord.updateMany({ + where: { id: grantId, revision: expectedRevision }, data: { status: next.status, revision: next.revision }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); return next; } } diff --git a/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts index 9bb87906..13bbfdd3 100644 --- a/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-capability-repository.adapter.ts @@ -50,6 +50,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceCapabilityDatabaseCreateDataV1 { @@ -302,15 +306,16 @@ class PrismaDeviceCapabilityTransactionAdapter implements DeviceCapabilityTransa current.opaqueLocalHandle !== capability.opaqueLocalHandle ) throw new Error('DSO_IMMUTABLE_CAPABILITY'); - if (!this.client.deviceCapabilityRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceCapabilityRecord.update({ - where: { id: capability.capabilityId }, + if (!this.client.deviceCapabilityRecord.updateMany) throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceCapabilityRecord.updateMany({ + where: { id: capability.capabilityId, revision: expectedRevision }, data: { status: capability.status, reportedAt: new Date(capability.reportedAt), revision: capability.revision, }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } public async replaceGrant( @@ -329,11 +334,13 @@ class PrismaDeviceCapabilityTransactionAdapter implements DeviceCapabilityTransa current.authorizationEpoch !== grant.authorizationEpoch ) throw new Error('DSO_IMMUTABLE_GRANT'); - if (!this.client.deviceOperationalGrantRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceOperationalGrantRecord.update({ - where: { id: grant.grantId }, + if (!this.client.deviceOperationalGrantRecord.updateMany) + throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceOperationalGrantRecord.updateMany({ + where: { id: grant.grantId, revision: expectedRevision }, data: { status: grant.status, revision: grant.revision }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } } diff --git a/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts b/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts index 2e5c655d..d0eb8219 100644 --- a/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts +++ b/services/api/src/features/dso/adapter/prisma-device-sync-repository.adapter.ts @@ -104,6 +104,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceSyncDatabaseClientV1 { @@ -487,15 +491,17 @@ class PrismaDeviceSyncTransactionAdapter implements DeviceSyncTransactionPortV1 throw new Error('DSO_IMMUTABLE_RECORD'); if (JSON.stringify(current) === JSON.stringify(operation)) return; if (operation.revision !== current.revision + 1) throw new Error('DSO_REVISION_CONFLICT'); - if (!this.client.deviceSyncOperationRecord.update) throw new Error('DSO_UPDATE_UNAVAILABLE'); - await this.client.deviceSyncOperationRecord.update({ - where: { id: operation.operationId }, + if (!this.client.deviceSyncOperationRecord.updateMany) + throw new Error('DSO_UPDATE_UNAVAILABLE'); + const result = await this.client.deviceSyncOperationRecord.updateMany({ + where: { id: operation.operationId, revision: current.revision }, data: { status: operation.status, revision: operation.revision, acknowledgedAt: operation.acknowledgedAt ? new Date(operation.acknowledgedAt) : null, }, }); + if (result.count !== 1) throw new Error('DSO_REVISION_CONFLICT'); } public async findOperation( diff --git a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts index 1315c91a..84f41586 100644 --- a/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/in-memory-artifact-repository.adapter.ts @@ -1,5 +1,6 @@ import { tenantScopeContainsV1, + type ArtifactScanStateV1, type ArtifactVersionV1, type ContentPlacementV1, type EvidenceReferenceV1, @@ -68,6 +69,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise { await Promise.resolve(); const current = this.versions.get(versionId); @@ -78,7 +80,7 @@ export class InMemoryArtifactRepositoryAdapter implements ArtifactRepositoryPort throw new Error('IAE_INVALID_STATUS'); if (current.status === 'DELETED' && status !== 'DELETED') throw new Error('IAE_TERMINAL_STATUS'); - const next = cloneVersion({ ...current, status }); + const next = cloneVersion({ ...current, status, scanState: scanState ?? current.scanState }); this.versions.set(versionId, next); return next; } diff --git a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts index 0bd74f25..1338e4d7 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-lineage-repository.adapter.ts @@ -33,10 +33,7 @@ export interface ArtifactLineageDatabaseClientV1 { readonly data: ArtifactLineageDatabaseRowV1; }): Promise; findUnique(input: { - readonly where: { readonly id: string }; - }): Promise; - findFirst(input: { - readonly where: { readonly derivedArtifactVersionId: string }; + readonly where: { readonly id: string } | { readonly derivedArtifactVersionId: string }; }): Promise; findMany(input: { readonly where: { readonly sourceVersionIds: { readonly array_contains: string } }; @@ -120,7 +117,7 @@ class PrismaArtifactLineageTransactionAdapter implements ArtifactLineageTransact context: IamTenantContextV1, derivedArtifactVersionId: ArtifactLineageV1['derivedArtifactVersionId'], ): Promise { - const row = await this.client.artifactLineageRecord.findFirst({ + const row = await this.client.artifactLineageRecord.findUnique({ where: { derivedArtifactVersionId }, }); return row !== null && visible(context.tenantScope, row) ? rowToDomain(row) : undefined; diff --git a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts index b0ff651e..fa87c2c5 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-repository.adapter.ts @@ -2,6 +2,7 @@ import { createArtifactVersionV1, createContentPlacementV1, createEvidenceReferenceV1, + type ArtifactScanStateV1, type ArtifactVersionV1, type ContentPlacementV1, type EvidenceReferenceV1, @@ -33,6 +34,7 @@ export interface ArtifactVersionDatabaseRowV1 { readonly displayName: string; readonly createdAt: Date; readonly status: string; + readonly scanState?: string; } export interface ContentPlacementDatabaseRowV1 { @@ -84,8 +86,12 @@ export interface ArtifactDatabaseClientV1 { }): Promise; update(input: { readonly where: { readonly id: string }; - readonly data: { readonly status: string }; + readonly data: { readonly status: string; readonly scanState: ArtifactScanStateV1 }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly status: string; readonly scanState: string }; + readonly data: { readonly status: string; readonly scanState: ArtifactScanStateV1 }; + }): Promise<{ readonly count: number }>; }; readonly contentPlacement: { create(input: { @@ -101,9 +107,16 @@ export interface ArtifactDatabaseClientV1 { readonly where: { readonly id: string }; readonly data: { readonly available: boolean; readonly revision: number }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: { readonly available: boolean; readonly revision: number }; + }): Promise<{ readonly count: number }>; }; readonly evidenceReference: { create(input: { readonly data: EvidenceCreateDataV1 }): Promise; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; findMany(input: { readonly where: Readonly>; }): Promise; @@ -149,6 +162,7 @@ function rowToVersion(row: ArtifactVersionDatabaseRowV1): ArtifactVersionV1 { displayName: row.displayName, createdAt: row.createdAt.toISOString(), status: row.status, + scanState: row.scanState ?? 'PENDING', }); if (!parsed.accepted) throw new Error('IAE_PERSISTED_ARTIFACT_INVALID'); return parsed.value; @@ -223,6 +237,7 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { displayName: version.displayName, createdAt: new Date(version.createdAt), status: version.status, + scanState: version.scanState, }, }); } @@ -243,6 +258,7 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise { const row = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); if (row === null || !visible(context.tenantScope, row)) return undefined; @@ -253,10 +269,13 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { throw new Error('IAE_INVALID_STATUS'); if (current.status === 'DELETED' && status !== 'DELETED') throw new Error('IAE_TERMINAL_STATUS'); - const updated = await this.client.artifactVersion.update({ - where: { id: versionId }, - data: { status }, + const result = await this.client.artifactVersion.updateMany({ + where: { id: versionId, status: current.status, scanState: current.scanState }, + data: { status, scanState: scanState ?? current.scanState }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); + const updated = await this.client.artifactVersion.findUnique({ where: { id: versionId } }); + if (updated === null) throw new Error('IAE_VERSION_NOT_FOUND'); return rowToVersion(updated); } @@ -333,10 +352,11 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { current.contentSha256 !== placement.contentSha256 ) throw new Error('IAE_IMMUTABLE_PLACEMENT'); - await this.client.contentPlacement.update({ - where: { id: placement.placementId }, + const result = await this.client.contentPlacement.updateMany({ + where: { id: placement.placementId, revision: current.revision }, data: { available: placement.available, revision: placement.revision }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); } public async saveEvidence( @@ -349,6 +369,14 @@ class PrismaArtifactTransactionAdapter implements ArtifactTransactionPortV1 { if (versionRow === null) throw new Error('IAE_VERSION_NOT_FOUND'); if (!tenantScopeContainsV1(context.tenantScope, evidence.tenantScope)) throw new Error('IAE_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.evidenceReference.findUnique({ + where: { id: evidence.evidenceId }, + }); + if (existing !== null) { + const persisted = rowToEvidence(existing, rowToVersion(versionRow)); + if (JSON.stringify(persisted) === JSON.stringify(evidence)) return; + throw new Error('IAE_IMMUTABLE_EVIDENCE'); + } await this.client.evidenceReference.create({ data: { ...databaseScope(evidence.tenantScope), diff --git a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts index 7948a67b..f3c1ac99 100644 --- a/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts +++ b/services/api/src/features/iae/adapter/prisma-artifact-retention-repository.adapter.ts @@ -52,6 +52,15 @@ export interface ArtifactRetentionDatabaseClientV1 { readonly revision: number; }; }): Promise; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: { + readonly state: string; + readonly blockers: unknown; + readonly authorizedAt: Date | null; + readonly revision: number; + }; + }): Promise<{ readonly count: number }>; }; $transaction( work: (transaction: ArtifactRetentionDatabaseClientV1) => Promise, @@ -150,8 +159,8 @@ class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTran current.requestedAt !== request.requestedAt ) throw new Error('IAE_IMMUTABLE_DELETION_REQUEST'); - await this.client.artifactDeletionRequestRecord.update({ - where: { id: request.requestId }, + const result = await this.client.artifactDeletionRequestRecord.updateMany({ + where: { id: request.requestId, revision: current.revision }, data: { state: request.state, blockers: request.blockers, @@ -159,6 +168,7 @@ class PrismaArtifactRetentionTransactionAdapter implements ArtifactRetentionTran revision: request.revision, }, }); + if (result.count !== 1) throw new Error('IAE_REVISION_CONFLICT'); } public async find( diff --git a/services/api/src/features/iae/api/artifact-read.controller.ts b/services/api/src/features/iae/api/artifact-read.controller.ts index d2df83b2..41696279 100644 --- a/services/api/src/features/iae/api/artifact-read.controller.ts +++ b/services/api/src/features/iae/api/artifact-read.controller.ts @@ -50,4 +50,21 @@ export class ArtifactReadController { if (!result.version) return { accepted: false, code: 'NOT_FOUND' as const }; return Object.freeze({ accepted: true, value: result.evidence }); } + + @Get(':versionId/evidence/:evidenceId/resolve') + @ApiOperation({ summary: 'Resolve one exact evidence reference to a safe opaque action' }) + async resolveEvidence( + @Req() request: unknown, + @Param('versionId') versionIdInput: string, + @Param('evidenceId') evidenceIdInput: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const versionId = parseStableIdentifierV1(versionIdInput); + const evidenceId = parseStableIdentifierV1(evidenceIdInput); + if (!versionId.accepted || !evidenceId.accepted) + return { accepted: false, code: 'INVALID_IDENTIFIER' as const }; + const result = await this.artifacts.resolveEvidence(context, versionId.value, evidenceId.value); + if (!result) return { accepted: false, code: 'NOT_FOUND' as const }; + return Object.freeze({ accepted: true, value: result }); + } } diff --git a/services/api/src/features/iae/api/artifact-retention.controller.ts b/services/api/src/features/iae/api/artifact-retention.controller.ts index 806e8e52..32edc6c3 100644 --- a/services/api/src/features/iae/api/artifact-retention.controller.ts +++ b/services/api/src/features/iae/api/artifact-retention.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Param, Post, Req } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -33,6 +33,13 @@ export class ArtifactRetentionController { this.retention = new ArtifactRetentionService(requests, artifacts); } + @Get('artifact-deletion-requests/:requestId') + @ApiOperation({ summary: 'Read one governed artifact deletion request' }) + async find(@Req() request: unknown, @Param('requestId') requestId: string): Promise { + const context = await this.requestContext.resolve(request); + return this.retention.find(context, requestId); + } + @Post('artifact-versions/:versionId/deletion-requests') @ApiOperation({ summary: 'Request governed deletion of an exact artifact version' }) @ApiBody({ type: CreateArtifactDeletionRequestDto }) @@ -46,7 +53,7 @@ export class ArtifactRetentionController { requestId: input.requestId, artifactVersionId: versionId, tenantScope: context.tenantScope, - requestedBy: input.requestedBy, + requestedBy: context.actorId, requestedAt: input.requestedAt, retention: input, }); diff --git a/services/api/src/features/iae/application/artifact-admission.service.ts b/services/api/src/features/iae/application/artifact-admission.service.ts index 34219e8a..b36451d8 100644 --- a/services/api/src/features/iae/application/artifact-admission.service.ts +++ b/services/api/src/features/iae/application/artifact-admission.service.ts @@ -37,6 +37,7 @@ export class ArtifactAdmissionService { context, versionId, admission.value.status, + admission.value.scanState, ); if (!updated) return Object.freeze({ accepted: false, code: 'ADMISSION_UPDATE_FAILED' as const }); diff --git a/services/api/src/features/iae/application/artifact-repository.port.ts b/services/api/src/features/iae/application/artifact-repository.port.ts index 4a2e768c..ae649069 100644 --- a/services/api/src/features/iae/application/artifact-repository.port.ts +++ b/services/api/src/features/iae/application/artifact-repository.port.ts @@ -1,4 +1,5 @@ import type { + ArtifactScanStateV1, ArtifactVersionV1, ContentPlacementV1, EvidenceReferenceV1, @@ -18,6 +19,7 @@ export interface ArtifactTransactionPortV1 { context: IamTenantContextV1, versionId: ArtifactVersionV1['versionId'], status: ArtifactVersionV1['status'], + scanState?: ArtifactScanStateV1, ): Promise; savePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; updatePlacement(context: IamTenantContextV1, placement: ContentPlacementV1): Promise; diff --git a/services/api/src/features/iae/application/artifact-retention.service.ts b/services/api/src/features/iae/application/artifact-retention.service.ts index 1958e7b8..766fb2f6 100644 --- a/services/api/src/features/iae/application/artifact-retention.service.ts +++ b/services/api/src/features/iae/application/artifact-retention.service.ts @@ -12,7 +12,10 @@ import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js import type { ArtifactRepositoryPortV1 } from './artifact-repository.port.js'; import type { ArtifactRetentionRepositoryPortV1 } from './artifact-retention-repository.port.js'; -export type ArtifactRetentionServiceErrorV1 = 'ARTIFACT_NOT_FOUND' | 'REQUEST_NOT_FOUND'; +export type ArtifactRetentionServiceErrorV1 = + | 'ACTOR_MISMATCH' + | 'ARTIFACT_NOT_FOUND' + | 'REQUEST_NOT_FOUND'; export type ArtifactRetentionServiceResultV1 = | ArtifactRetentionResultV1 | { readonly accepted: false; readonly code: ArtifactRetentionServiceErrorV1 }; @@ -30,7 +33,13 @@ export class ArtifactRetentionService { readonly retention: Parameters[0]; }, ): Promise> { - const created = createArtifactDeletionRequestV1(input); + const requestedBy = parseStableIdentifierV1(input.requestedBy); + if (!requestedBy.accepted || requestedBy.value !== context.actorId) + return Object.freeze({ accepted: false, code: 'ACTOR_MISMATCH' as const }); + const created = createArtifactDeletionRequestV1({ + ...input, + requestedBy: context.actorId, + }); if (!created.accepted) return created; const artifactVersionId = parseStableIdentifierV1(input.artifactVersionId); if (!artifactVersionId.accepted) @@ -62,6 +71,19 @@ export class ArtifactRetentionService { }); } + public async find( + context: IamTenantContextV1, + requestIdInput: unknown, + ): Promise> { + const requestId = parseStableIdentifierV1(requestIdInput); + if (!requestId.accepted) + return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' as const }); + const request = await this.requests.find(context, requestId.value); + return request + ? Object.freeze({ accepted: true, value: request }) + : Object.freeze({ accepted: false, code: 'REQUEST_NOT_FOUND' as const }); + } + public async authorize( context: IamTenantContextV1, input: { diff --git a/services/api/src/features/iae/application/artifact.service.ts b/services/api/src/features/iae/application/artifact.service.ts index ed95a5a6..c5d5f6cc 100644 --- a/services/api/src/features/iae/application/artifact.service.ts +++ b/services/api/src/features/iae/application/artifact.service.ts @@ -94,6 +94,8 @@ export class ArtifactService { if (!evidence) return undefined; const version = await transaction.findVersion(context, versionId); if (!version) return undefined; + if (version.status === 'DELETED' || evidence.sourceState !== 'AVAILABLE') + return Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); const placements = await transaction.listPlacements(context, version.versionId); const cloud = placements.find( (placement) => placement.kind === 'CLOUD' && placement.available, @@ -107,12 +109,14 @@ export class ArtifactService { }); const local = placements.find((placement) => placement.kind === 'LOCAL'); if (local) - return Object.freeze({ - evidence, - version, - action: local.available ? ('OPEN_ON_SOURCE_DEVICE' as const) : ('UNAVAILABLE' as const), - placementReference: local.opaqueReference, - }); + return local.available + ? Object.freeze({ + evidence, + version, + action: 'OPEN_ON_SOURCE_DEVICE' as const, + placementReference: local.opaqueReference, + }) + : Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); return Object.freeze({ evidence, version, action: 'UNAVAILABLE' as const }); }); } 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 e96ff924..7ec25f9b 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 @@ -23,13 +23,21 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) ) return false; - if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(factor) && + factor.revision !== prior.revision + 1 + ) return false; } for (const code of next.recoveryCodes) { const prior = existingCodes.get(code.id); if (prior && (prior.userId !== code.userId || prior.digest !== code.digest)) return false; - if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(code) && + code.revision !== prior.revision + 1 + ) return false; } return true; diff --git a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts index 76f60ffc..3e8025e2 100644 --- a/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-device-identity-repository.adapter.ts @@ -56,6 +56,10 @@ interface DelegateV1 { readonly where: { readonly id: string }; readonly data: TUpdate; }): Promise; + updateMany?(input: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: TUpdate; + }): Promise<{ readonly count: number }>; } export interface DeviceIdentityDatabaseClientV1 { @@ -245,11 +249,12 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio challenge.revision !== current.revision + 1 ) throw new Error('IMMUTABLE_CHALLENGE'); - if (!this.client.deviceEnrollmentChallenge.update) throw new Error('UPDATE_UNAVAILABLE'); - await this.client.deviceEnrollmentChallenge.update({ - where: { id: challenge.id }, + if (!this.client.deviceEnrollmentChallenge.updateMany) throw new Error('UPDATE_UNAVAILABLE'); + const result = await this.client.deviceEnrollmentChallenge.updateMany({ + where: { id: challenge.id, revision: current.revision }, data: { status: challenge.status, revision: challenge.revision }, }); + if (result.count !== 1) throw new Error('REVISION_CONFLICT'); } public async findChallenge( @@ -301,9 +306,9 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio if (!current) throw new Error('DEVICE_NOT_FOUND'); if (current.revision !== expectedRevision) throw new Error('REVISION_CONFLICT'); if (device.revision !== expectedRevision + 1) throw new Error('INVALID_REVISION'); - if (!this.client.deviceIdentity.update) throw new Error('UPDATE_UNAVAILABLE'); - await this.client.deviceIdentity.update({ - where: { id: device.id }, + if (!this.client.deviceIdentity.updateMany) throw new Error('UPDATE_UNAVAILABLE'); + const result = await this.client.deviceIdentity.updateMany({ + where: { id: device.id, revision: expectedRevision }, data: { publicKey: device.publicKey, status: device.status, @@ -313,6 +318,7 @@ class PrismaDeviceIdentityTransactionAdapter implements DeviceIdentityTransactio revokedAt: device.revokedAt ? new Date(device.revokedAt) : null, }, }); + if (result.count !== 1) throw new Error('REVISION_CONFLICT'); } } diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index ccf18823..995fe68c 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -37,10 +37,10 @@ interface IamMembershipDelegateV1 { readonly where: Readonly>; }): Promise; create(input: { readonly data: IamMembershipDatabaseRowV1 }): Promise; - update(input: { - readonly where: { readonly id: string }; + updateMany(input: { + readonly where: { readonly id: string; readonly revision: number }; readonly data: Partial; - }): Promise; + }): Promise<{ readonly count: number }>; } export interface IamDatabaseClientV1 { @@ -167,14 +167,15 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { !tenantScopesEqualV1(existing.scope, membership.scope) ) throw new Error('IAM_MEMBERSHIP_SCOPE_IMMUTABLE'); - await this.client.membershipIdentity.update({ - where: { id: membership.id }, + const updated = await this.client.membershipIdentity.updateMany({ + where: { id: membership.id, revision: existing.revision }, data: { roleId: membership.roleId, status: membership.status, revision: membership.revision, }, }); + if (updated.count !== 1) throw new Error('IAM_REVISION_CONFLICT'); } } 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 f30ecced..03c7d76f 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 @@ -305,7 +305,9 @@ export class PrismaIdentityBootstrapRepositoryAdapter implements IdentityBootstr } public save(bootstrap: PersonalOrganizationBootstrapV1) { - return new PrismaIdentityBootstrapTransactionAdapter(this.client).save(bootstrap); + return this.client.$transaction((transaction) => + new PrismaIdentityBootstrapTransactionAdapter(transaction).save(bootstrap), + ); } public withTransaction( 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 199897cc..748723d9 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 @@ -192,14 +192,22 @@ function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { (prior.userId !== factor.userId || prior.secretReference !== factor.secretReference) ) return false; - if (prior && factor.revision !== prior.revision && factor.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(factor) && + factor.revision !== prior.revision + 1 + ) return false; if (!prior && factor.revision !== 1) return false; } for (const code of next.recoveryCodes) { const prior = existingCodes.get(code.id); if (prior && (prior.userId !== code.userId || prior.digest !== code.digest)) return false; - if (prior && code.revision !== prior.revision && code.revision !== prior.revision + 1) + if ( + prior && + JSON.stringify(prior) !== JSON.stringify(code) && + code.revision !== prior.revision + 1 + ) return false; if (!prior && code.revision !== 1) return false; } diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index de4527ec..c13d7eb1 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -72,7 +72,10 @@ function snapshot(): EntitlementSnapshotV1 { }; } -function delegate>(rows: TRow[]) { +function delegate>( + rows: TRow[], + forceRevisionConflict = false, +) { return { create({ data }: { readonly data: TRow }) { const persisted = { ...data }; @@ -121,10 +124,27 @@ function delegate>(rows: TRow[]) { rows[index] = { ...rows[index], ...data } as TRow; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data } as TRow; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): EntitlementDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): EntitlementDatabaseClientV1 { const planRows: Record[] = []; const snapshotRows: Record[] = []; const entryRows: Record[] = []; @@ -133,7 +153,7 @@ function client(): EntitlementDatabaseClientV1 { entitlementPlanRecord: delegate(planRows), entitlementSnapshotRecord: delegate(snapshotRows), usageLedgerEntryRecord: delegate(entryRows), - usageReservationRecord: delegate(reservationRows), + usageReservationRecord: delegate(reservationRows, options.forceRevisionConflict), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, ): Promise { @@ -224,3 +244,28 @@ void test('[BUA-012] Prisma entitlement adapter applies reservation status revis finalized, ); }); + +void test('[BUA-012] Prisma entitlement adapter rejects a reservation settlement race', async () => { + const repository = new PrismaEntitlementRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveSnapshot(context(workspaceId, 'seed-race'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'admit-race'), + admissionInput('admit-race', '1'), + ); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + await assert.rejects( + service.finalize(context(workspaceId, 'finish-race'), { + reservationId: admitted.value.reservation.reservationId, + releaseEntryId: stable('00000000-0000-4000-8000-000000000241'), + commitEntryId: stable('00000000-0000-4000-8000-000000000242'), + committedUnits: 1, + now: '2026-01-01T00:02:00.000Z', + idempotencyKey: 'finish-race', + }), + /BUA_RESERVATION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts index 89b14f93..7264362d 100644 --- a/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts +++ b/services/api/test/features/dsm/prisma-governed-dataset-repository.test.ts @@ -66,6 +66,7 @@ function row(id: string, candidateWorkspaceId: string): GovernedDatasetDatabaseR status: 'DRAFT', createdAt: new Date('2026-01-01T00:00:00.000Z'), publishedAt: null, + revision: 1, canonicalHash: 'a'.repeat(64), }; } @@ -153,3 +154,13 @@ void test('[DSM-001] Prisma dataset adapter persists a replay exactly once', asy await repository.save(context(workspaceId, 'replay'), definition); assert.equal(rows.length, 1); }); + +void test('[DSM-001] Prisma dataset adapter rejects a non-initial immutable revision', async () => { + const repository = new PrismaGovernedDatasetRepositoryAdapter( + client([{ ...row(versionId, workspaceId), revision: 2 }]), + ); + await assert.rejects( + repository.find(context(workspaceId, 'invalid-revision'), versionId), + /DSM_PERSISTED_REVISION_INVALID/u, + ); +}); diff --git a/services/api/test/features/dsm/prisma-mapping-repository.test.ts b/services/api/test/features/dsm/prisma-mapping-repository.test.ts index 5002b8f1..6382c82d 100644 --- a/services/api/test/features/dsm/prisma-mapping-repository.test.ts +++ b/services/api/test/features/dsm/prisma-mapping-repository.test.ts @@ -91,4 +91,11 @@ void test('[DSM-007, IAM-009] Prisma mapping adapter persists and lists typed ma (await repository.list(context('list'), datasetId)).map((item) => item.versionId), [versionId], ); + const persisted = rows[0]; + if (!persisted) throw new Error('fixture mapping was not persisted'); + rows[0] = { ...persisted, revision: 2 }; + await assert.rejects( + repository.find(context('invalid-revision'), versionId), + /DSM_PERSISTED_REVISION_INVALID/u, + ); }); diff --git a/services/api/test/features/dso/prisma-device-authorization-repository.test.ts b/services/api/test/features/dso/prisma-device-authorization-repository.test.ts index 32673e54..6604c68d 100644 --- a/services/api/test/features/dso/prisma-device-authorization-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-authorization-repository.test.ts @@ -83,7 +83,7 @@ function grant(): OpaqueDeviceGrantV1 { return created.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -123,15 +123,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceAuthorizationDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceAuthorizationDatabaseClientV1 { const snapshotRows: Record[] = []; const grantRows: Record[] = []; const database = { authorizationSnapshot: delegate(snapshotRows), - deviceGrantRecord: delegate(grantRows), + deviceGrantRecord: delegate(grantRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceAuthorizationDatabaseClientV1) => Promise, ) { @@ -165,3 +182,14 @@ void test('[IAM-020, DSO-005, IAM-009] Prisma device authorization adapter persi 2, ); }); + +void test('[IAM-020, DSO-005] Prisma device grant revocation rejects a database race', async () => { + const repository = new PrismaDeviceAuthorizationRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveGrant(context(workspaceId, 'grant-race-save'), grant()); + await assert.rejects( + repository.revokeGrant(context(workspaceId, 'grant-race-revoke'), id(grantId), 1), + /DSO_REVISION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/dso/prisma-device-capability-repository.test.ts b/services/api/test/features/dso/prisma-device-capability-repository.test.ts index dafa52ff..6b1f0967 100644 --- a/services/api/test/features/dso/prisma-device-capability-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-capability-repository.test.ts @@ -77,7 +77,7 @@ function grant(): DeviceGrantV1 { return result.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -116,15 +116,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceCapabilityDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceCapabilityDatabaseClientV1 { const capabilityRows: Record[] = []; const grantRows: Record[] = []; const database = { - deviceCapabilityRecord: delegate(capabilityRows), - deviceOperationalGrantRecord: delegate(grantRows), + deviceCapabilityRecord: delegate(capabilityRows, options.forceRevisionConflict), + deviceOperationalGrantRecord: delegate(grantRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceCapabilityDatabaseClientV1) => Promise, ) { @@ -174,3 +191,27 @@ void test('[DSO-005, DSO-016] Prisma grant adapter hides sibling workspaces and /DSO_REVISION_CONFLICT/, ); }); + +void test('[DSO-005, DSO-016] Prisma capability and grant replacements reject database races', async () => { + const repository = new PrismaDeviceCapabilityRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveCapability(context(workspaceId, 'cap-race-save'), capability()); + await assert.rejects( + repository.replaceCapability( + context(workspaceId, 'cap-race-replace'), + { ...capability(), status: 'PAUSED', revision: 2 }, + 1, + ), + /DSO_REVISION_CONFLICT/u, + ); + await repository.saveGrant(context(workspaceId, 'grant-race-save'), grant()); + await assert.rejects( + repository.replaceGrant( + context(workspaceId, 'grant-race-replace'), + { ...grant(), status: 'REVOKED', revision: 2 }, + 1, + ), + /DSO_REVISION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/dso/prisma-device-sync-repository.test.ts b/services/api/test/features/dso/prisma-device-sync-repository.test.ts index d4a731e8..db3fbd02 100644 --- a/services/api/test/features/dso/prisma-device-sync-repository.test.ts +++ b/services/api/test/features/dso/prisma-device-sync-repository.test.ts @@ -132,7 +132,7 @@ function matches(row: Record, where: Readonly row[key] === value); } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -180,16 +180,33 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceSyncDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceSyncDatabaseClientV1 { const operationRows: Record[] = []; const conflictRows: Record[] = []; const packageRows: Record[] = []; const receiptRows: Record[] = []; const database = { - deviceSyncOperationRecord: delegate(operationRows), + deviceSyncOperationRecord: delegate(operationRows, options.forceRevisionConflict), deviceSyncConflictRecord: delegate(conflictRows), strictLocalPackageManifestRecord: delegate(packageRows), deviceTransferReceiptRecord: delegate(receiptRows), @@ -241,3 +258,18 @@ void test('[DSO-018, DSO-019, DSO-021] Prisma DSO adapter keeps conflict and tra /DSO_SCOPE_NARROWING_REQUIRED/u, ); }); + +void test('[DSO-011, DSO-016] Prisma sync-operation transitions reject a database revision race', async () => { + const repository = new PrismaDeviceSyncRepositoryAdapter(client({ forceRevisionConflict: true })); + const first = operation(); + await repository.saveOperation(context(workspaceId, 'race-save'), first); + const accepted = transitionDeviceSyncOperationV1(first, 'ACCEPT', '2026-01-01T00:00:01.000Z'); + assert.equal(accepted.accepted, true); + if (!accepted.accepted) return; + await assert.rejects( + repository.saveOperation(context(workspaceId, 'race-accept', 1), accepted.value, { + expectedRevision: 1, + }), + /DSO_REVISION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 760bfe2a..6ea3f0bc 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { AppModule } from '../../src/app.module.js'; +import type { ApiApplicationOptions } from '../../src/bootstrap.js'; import { IamModule } from '../../src/features/iam/iam.module.js'; import { AUTHENTICATION_USE_CASE, @@ -38,6 +39,15 @@ function moduleTypes(): readonly unknown[] { ); } +void test('[AUD-001, BUA-001] API application options expose durable module adapters', () => { + const options = { + auditRepository: {} as never, + entitlementRepository: {} as never, + } satisfies ApiApplicationOptions; + const registered = AppModule.register(options); + assert.equal(registered.module, AppModule); +}); + void test('[IAM-001, AUD-001, BUA-001] application composition includes identity, audit, and entitlements modules', () => { const types = moduleTypes(); assert.ok(types.includes(AudModule)); diff --git a/services/api/test/features/iae/artifact-admission.service.test.ts b/services/api/test/features/iae/artifact-admission.service.test.ts index 70d96704..5c494f34 100644 --- a/services/api/test/features/iae/artifact-admission.service.test.ts +++ b/services/api/test/features/iae/artifact-admission.service.test.ts @@ -49,6 +49,7 @@ void test('IAE-009/010 admission updates only the status projection after scanne assert.equal(admitted.accepted, true); if (!admitted.accepted) return; assert.equal(admitted.value.version.status, 'ACTIVE'); + assert.equal(admitted.value.version.scanState, 'CLEAN'); const rejected = await service.admit(context, artifact.value.versionId, { actualSha256: 'b'.repeat(64), actualByteSize: 4, diff --git a/services/api/test/features/iae/artifact-read.controller.test.ts b/services/api/test/features/iae/artifact-read.controller.test.ts index 5850915b..41c9bed0 100644 --- a/services/api/test/features/iae/artifact-read.controller.test.ts +++ b/services/api/test/features/iae/artifact-read.controller.test.ts @@ -95,6 +95,42 @@ void test('[IAE-006, IAE-008, IAE-019, IAE-020] artifact reads return exact cont row: 1, field: 'amount', }); + + const resolutionResponse = await app.inject({ + method: 'GET', + url: `/v1/artifact-versions/${versionId}/evidence/${evidenceId}/resolve`, + }); + assert.equal(resolutionResponse.statusCode, 200); + assert.deepEqual(JSON.parse(resolutionResponse.body), { + accepted: true, + value: { + evidence: { + schemaVersion: 1, + evidenceId, + artifactVersionId: versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + sourceState: 'AVAILABLE', + }, + version: { + schemaVersion: 1, + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'a'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'ACTIVE', + scanState: 'PENDING', + }, + action: 'OPEN_ON_SOURCE_DEVICE', + placementReference: 'local-placement-000001', + }, + }); } finally { await app.close(); } @@ -144,3 +180,53 @@ void test('[IAE-008, IAM-009] artifact reads do not enumerate a sibling workspac const result = await service.find(sibling.value, parsedVersionId.value); assert.equal(result.version, undefined); }); + +void test('[IAE-006, IAE-019] unavailable or deleted evidence never resolves to an open handle', async () => { + const repository = new InMemoryArtifactRepositoryAdapter(); + const tenantContext = context(); + const service = new ArtifactService(repository); + const registered = await service.register(tenantContext, { + version: { + artifactId, + versionId, + tenantScope: tenantContext.tenantScope, + sourceKind: 'FILE', + dataMode: 'Cloud', + contentSha256: 'c'.repeat(64), + byteSize: 10, + mediaType: 'text/csv', + displayName: 'unavailable.csv', + createdAt: '2026-01-01T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: tenantContext.tenantScope, + kind: 'CLOUD', + opaqueReference: 'cloud-placement-000001', + contentSha256: 'c'.repeat(64), + }, + evidence: { + evidenceId, + tenantScope: tenantContext.tenantScope, + coordinate: { kind: 'ROW', row: 1, field: 'amount' }, + sourceState: 'SOURCE_OFFLINE', + }, + }); + assert.equal(registered.accepted, true); + if (!registered.accepted) return; + const registeredEvidence = registered.value.evidence; + if (!registeredEvidence) return; + + const resolved = await service.resolveEvidence( + tenantContext, + registered.value.version.versionId, + registeredEvidence.evidenceId, + ); + assert.deepEqual( + resolved && { action: resolved.action, placementReference: resolved.placementReference }, + { + action: 'UNAVAILABLE', + placementReference: undefined, + }, + ); +}); diff --git a/services/api/test/features/iae/artifact-retention.controller.test.ts b/services/api/test/features/iae/artifact-retention.controller.test.ts new file mode 100644 index 00000000..7492de79 --- /dev/null +++ b/services/api/test/features/iae/artifact-retention.controller.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { InMemoryArtifactRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-repository.adapter.js'; +import { InMemoryArtifactRetentionRepositoryAdapter } from '../../../src/features/iae/adapter/in-memory-artifact-retention-repository.adapter.js'; +import { ArtifactService } from '../../../src/features/iae/application/artifact.service.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const organizationId = '00000000-0000-4000-8000-000000000731'; +const workspaceId = '00000000-0000-4000-8000-000000000732'; +const actorId = '00000000-0000-4000-8000-000000000733'; +const artifactId = '00000000-0000-4000-8000-000000000734'; +const versionId = '00000000-0000-4000-8000-000000000735'; +const placementId = '00000000-0000-4000-8000-000000000736'; +const requestId = '00000000-0000-4000-8000-000000000737'; + +void test('[IAE-016, IAM-009] retention HTTP binds requester to the authenticated actor and supports reads', async () => { + const contextResult = createIamTenantContextV1({ + actorId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + authorizationEpoch: 1, + correlationId: '00000000-0000-4000-8000-000000000738', + idempotencyKey: 'retention-http', + }); + assert.equal(contextResult.accepted, true); + if (!contextResult.accepted) throw new Error('fixture context rejected'); + const context = contextResult.value; + const artifacts = new InMemoryArtifactRepositoryAdapter(); + await new ArtifactService(artifacts).register(context, { + version: { + artifactId, + versionId, + tenantScope: context.tenantScope, + sourceKind: 'FILE', + dataMode: 'Local', + contentSha256: 'b'.repeat(64), + byteSize: 1, + mediaType: 'text/plain', + displayName: 'retention.txt', + createdAt: '2026-08-02T00:00:00.000Z', + }, + placement: { + placementId, + tenantScope: context.tenantScope, + kind: 'LOCAL', + opaqueReference: 'local-retention-placement', + contentSha256: 'b'.repeat(64), + }, + }); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(context), + }; + const retention = new InMemoryArtifactRetentionRepositoryAdapter(); + const { app } = await createApiApplication({ + artifactRepository: artifacts, + artifactRetentionRepository: retention, + requestTenantContext, + }); + try { + const response = await app.inject({ + method: 'POST', + url: `/v1/artifact-versions/${versionId}/deletion-requests`, + payload: { + requestId, + requestedBy: '00000000-0000-4000-8000-000000000739', + requestedAt: '2026-08-02T01:00:00.000Z', + evaluatedAt: '2026-08-02T01:00:00.000Z', + workspaceRetentionUntil: '2026-07-01T00:00:00.000Z', + resourceRetentionUntil: '2026-07-01T00:00:00.000Z', + auditRetentionUntil: '2026-07-01T00:00:00.000Z', + recoveryWindowUntil: '2026-07-01T00:00:00.000Z', + activeApproval: false, + legalHold: false, + }, + }); + assert.equal(response.statusCode, 201); + const created = response.json<{ + readonly accepted: boolean; + readonly value?: { readonly requestedBy?: string; readonly requestId?: string }; + }>(); + assert.equal(created.accepted, true); + assert.equal(created.value?.requestId, requestId); + assert.equal(created.value?.requestedBy, actorId); + + const read = await app.inject({ + method: 'GET', + url: `/v1/artifact-deletion-requests/${requestId}`, + }); + assert.equal(read.statusCode, 200); + assert.deepEqual(read.json(), created); + assert.doesNotMatch(read.body, /path|bytes|excerpt|opaqueReference/iu); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/features/iae/artifact-retention.service.test.ts b/services/api/test/features/iae/artifact-retention.service.test.ts index a5ad0348..621f40f4 100644 --- a/services/api/test/features/iae/artifact-retention.service.test.ts +++ b/services/api/test/features/iae/artifact-retention.service.test.ts @@ -77,6 +77,15 @@ void test('[IAE-016, IAE-021] retention service preserves blocked requests and a assert.equal(request.accepted, true); if (!request.accepted) return; assert.equal(request.value.state, 'BLOCKED'); + const forged = await service.request(tenantContext, { + requestId: '00000000-0000-4000-8000-000000000719', + artifactVersionId: versionId, + tenantScope: tenantContext.tenantScope, + requestedBy: '00000000-0000-4000-8000-000000000720', + requestedAt: '2026-01-03T00:00:00.000Z', + retention: retention(false), + }); + assert.deepEqual(forged, { accepted: false, code: 'ACTOR_MISMATCH' }); const stale = await service.authorize(tenantContext, { requestId: request.value.requestId, retention: retention(true), @@ -93,5 +102,9 @@ void test('[IAE-016, IAE-021] retention service preserves blocked requests and a expectedRevision: request.value.revision, }); assert.equal(authorized.accepted, true); - if (authorized.accepted) assert.equal(authorized.value.state, 'AUTHORIZED'); + if (authorized.accepted) { + assert.equal(authorized.value.state, 'AUTHORIZED'); + const found = await service.find(tenantContext, authorized.value.requestId); + assert.deepEqual(found, authorized); + } }); diff --git a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts index 3605a398..37622836 100644 --- a/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-lineage-repository.test.ts @@ -41,13 +41,14 @@ function client(rows: ArtifactLineageDatabaseRowV1[]): ArtifactLineageDatabaseCl return Promise.resolve({ ...data }); }, findUnique({ where }) { - return Promise.resolve(rows.find((row) => row.id === where.id) ?? null); - }, - findFirst({ where }) { - return Promise.resolve( - rows.find((row) => row.derivedArtifactVersionId === where.derivedArtifactVersionId) ?? - null, - ); + const row = + 'id' in where + ? rows.find((candidate) => candidate.id === where.id) + : rows.find( + (candidate) => + candidate.derivedArtifactVersionId === where.derivedArtifactVersionId, + ); + return Promise.resolve(row ?? null); }, findMany({ where }) { return Promise.resolve( diff --git a/services/api/test/features/iae/prisma-artifact-repository.test.ts b/services/api/test/features/iae/prisma-artifact-repository.test.ts index 0f4ec5f6..05fbd6a7 100644 --- a/services/api/test/features/iae/prisma-artifact-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-repository.test.ts @@ -50,6 +50,7 @@ function client( versions: ArtifactVersionDatabaseRowV1[], placements: ContentPlacementDatabaseRowV1[], evidence: EvidenceDatabaseRowV1[], + options: { readonly forceVersionConflict?: boolean } = {}, ): ArtifactDatabaseClientV1 { return { artifactVersion: { @@ -70,6 +71,19 @@ function client( versions[versions.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany(input) { + const current = versions.find((candidate) => candidate.id === input.where.id); + if ( + options.forceVersionConflict || + !current || + current.status !== input.where.status || + current.scanState !== input.where.scanState + ) + return Promise.resolve({ count: 0 }); + const next = { ...current, ...input.data }; + versions[versions.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, contentPlacement: { create(input) { @@ -96,6 +110,14 @@ function client( placements[placements.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany(input) { + const current = placements.find((candidate) => candidate.id === input.where.id); + if (!current || current.revision !== input.where.revision) + return Promise.resolve({ count: 0 }); + const next = { ...current, ...input.data }; + placements[placements.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, evidenceReference: { create(input) { @@ -103,6 +125,11 @@ function client( evidence.push(persisted); return Promise.resolve(persisted); }, + findUnique(input) { + return Promise.resolve( + evidence.find((candidate) => candidate.id === input.where.id) ?? null, + ); + }, findMany(input) { return Promise.resolve( evidence.filter( @@ -165,6 +192,101 @@ void test('[IAE-003, IAE-004, IAE-005, IAM-009] Prisma artifact adapter keeps pl await repository.savePlacement(context('placement'), placement.value); await repository.savePlacement(context('placement-repeat'), placement.value); await repository.saveEvidence(context('evidence'), evidenceRef.value); + await repository.saveEvidence(context('evidence-repeat'), evidenceRef.value); + const conflictingEvidence = createEvidenceReferenceV1({ + evidenceId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + coordinate: { kind: 'ROW', row: 2 }, + }); + assert.equal(conflictingEvidence.accepted, true); + if (!conflictingEvidence.accepted) return; + await assert.rejects( + repository.saveEvidence(context('evidence-conflict'), conflictingEvidence.value), + /IAE_IMMUTABLE_EVIDENCE/, + ); assert.equal((await repository.listPlacements(context('list-placement'), versionId)).length, 1); assert.equal((await repository.listEvidence(context('list-evidence'), versionId)).length, 1); }); + +void test('[IAE-009, IAE-010] Prisma artifact status transitions reject a scan-state race', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const artifact = createArtifactVersionV1({ + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'a'.repeat(64), + byteSize: 8, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: createdAt.value, + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) throw new Error('fixture artifact rejected'); + const versions: ArtifactVersionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRepositoryAdapter( + client(versions, [], [], { forceVersionConflict: true }), + ); + await repository.saveVersion(context('status-race-version'), artifact.value); + await assert.rejects( + repository.updateVersionStatus(context('status-race-update'), versionId, 'QUARANTINED'), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(versions[0]?.status, 'ACTIVE'); +}); + +void test('[IAE-020, DSO-006] Prisma placement adapter rejects a stale revision after a concurrent update', async () => { + const createdAt = parseStrictUtcTimestampV1('2026-01-01T00:00:00.000Z'); + assert.equal(createdAt.accepted, true); + if (!createdAt.accepted) throw new Error('fixture timestamp rejected'); + const artifact = createArtifactVersionV1({ + artifactId, + versionId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + sourceKind: 'FILE', + dataMode: 'Hybrid', + contentSha256: 'f'.repeat(64), + byteSize: 8, + mediaType: 'text/csv', + displayName: 'orders.csv', + createdAt: createdAt.value, + }); + assert.equal(artifact.accepted, true); + if (!artifact.accepted) throw new Error('fixture artifact rejected'); + const placement = createContentPlacementV1({ + placementId, + artifactVersion: artifact.value, + tenantScope: artifact.value.tenantScope, + kind: 'CLOUD', + opaqueReference: 'opaque-reference-1234', + contentSha256: artifact.value.contentSha256, + }); + assert.equal(placement.accepted, true); + if (!placement.accepted) throw new Error('fixture placement rejected'); + const placements: ContentPlacementDatabaseRowV1[] = []; + const repository = new PrismaArtifactRepositoryAdapter(client([], placements, [])); + await repository.saveVersion(context('stale-version'), artifact.value); + await repository.savePlacement(context('stale-placement'), placement.value); + + const updated = { + ...placement.value, + available: false, + revision: placement.value.revision + 1, + }; + await repository.updatePlacement(context('first-update'), updated); + + await assert.rejects( + repository.updatePlacement(context('stale-update'), { + ...placement.value, + available: true, + revision: placement.value.revision + 1, + }), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(placements[0]?.available, false); + assert.equal(placements[0]?.revision, 2); +}); diff --git a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts index 01cfae2c..f76c929f 100644 --- a/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts +++ b/services/api/test/features/iae/prisma-artifact-retention-repository.test.ts @@ -37,7 +37,10 @@ function context() { return result.value; } -function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDatabaseClientV1 { +function client( + rows: ArtifactRetentionDatabaseRowV1[], + options: { readonly forceRevisionConflict?: boolean } = {}, +): ArtifactRetentionDatabaseClientV1 { return { artifactDeletionRequestRecord: { create({ data }) { @@ -55,6 +58,14 @@ function client(rows: ArtifactRetentionDatabaseRowV1[]): ArtifactRetentionDataba rows[rows.indexOf(current)] = next; return Promise.resolve(next); }, + updateMany({ where, data }) { + const current = rows.find((row) => row.id === where.id); + if (options.forceRevisionConflict || !current || current.revision !== where.revision) + return Promise.resolve({ count: 0 }); + const next = { ...current, ...data }; + rows[rows.indexOf(current)] = next; + return Promise.resolve({ count: 1 }); + }, }, $transaction(work) { return work(this); @@ -80,3 +91,31 @@ void test('[IAE-016, IAE-021, IAM-009] Prisma retention adapter preserves immuta assert.deepEqual(await repository.find(tenantContext, requestId), created.value); assert.equal(rows.length, 1); }); + +void test('[IAE-016] Prisma retention adapter rejects a database revision race', async () => { + const rows: ArtifactRetentionDatabaseRowV1[] = []; + const repository = new PrismaArtifactRetentionRepositoryAdapter( + client(rows, { forceRevisionConflict: true }), + ); + const tenantContext = context(); + const created = createArtifactDeletionRequestV1({ + requestId, + artifactVersionId: '00000000-0000-4000-8000-000000000826', + tenantScope: tenantContext.tenantScope, + requestedBy: tenantContext.actorId, + requestedAt: '2026-01-03T00:00:00.000Z', + }); + assert.equal(created.accepted, true); + if (!created.accepted) return; + await repository.save(tenantContext, created.value); + await assert.rejects( + repository.save(tenantContext, { + ...created.value, + state: 'BLOCKED', + blockers: ['LEGAL_HOLD'], + revision: 2, + }), + /IAE_REVISION_CONFLICT/u, + ); + assert.equal(rows[0]?.revision, 1); +}); diff --git a/services/api/test/features/iam/prisma-device-identity-repository.test.ts b/services/api/test/features/iam/prisma-device-identity-repository.test.ts index 0d876d1d..475a3901 100644 --- a/services/api/test/features/iam/prisma-device-identity-repository.test.ts +++ b/services/api/test/features/iam/prisma-device-identity-repository.test.ts @@ -82,7 +82,7 @@ function device(): DeviceIdentityV1 { return result.value; } -function delegate(rows: Record[]) { +function delegate(rows: Record[], forceRevisionConflict = false) { return { create({ data }: { readonly data: Record }) { const persisted = { ...data }; @@ -109,15 +109,32 @@ function delegate(rows: Record[]) { rows[index] = { ...rows[index], ...data }; return Promise.resolve(rows[index]); }, + updateMany({ + where, + data, + }: { + readonly where: { readonly id: string; readonly revision: number }; + readonly data: Record; + }) { + if (forceRevisionConflict) return Promise.resolve({ count: 0 }); + const index = rows.findIndex( + (row) => row['id'] === where.id && row['revision'] === where.revision, + ); + if (index < 0) return Promise.resolve({ count: 0 }); + rows[index] = { ...rows[index], ...data }; + return Promise.resolve({ count: 1 }); + }, }; } -function client(): DeviceIdentityDatabaseClientV1 { +function client( + options: { readonly forceRevisionConflict?: boolean } = {}, +): DeviceIdentityDatabaseClientV1 { const challengeRows: Record[] = []; const deviceRows: Record[] = []; const database = { - deviceEnrollmentChallenge: delegate(challengeRows), - deviceIdentity: delegate(deviceRows), + deviceEnrollmentChallenge: delegate(challengeRows, options.forceRevisionConflict), + deviceIdentity: delegate(deviceRows, options.forceRevisionConflict), async $transaction( work: (transaction: DeviceIdentityDatabaseClientV1) => Promise, ) { @@ -157,3 +174,26 @@ void test('[IAM-007, IAM-009, IAM-021] Prisma device identity adapter persists c 'ACTIVE', ); }); + +void test('[IAM-007, IAM-021] Prisma device identity transitions reject database revision races', async () => { + const repository = new PrismaDeviceIdentityRepositoryAdapter( + client({ forceRevisionConflict: true }), + ); + await repository.saveChallenge(context(), challenge()); + const used = { + ...challenge(), + status: 'USED' as const, + revision: 2, + }; + await assert.rejects(repository.saveChallenge(context(), used), /REVISION_CONFLICT/u); + + await repository.saveDevice(context(), device()); + const active = { + ...device(), + status: 'ACTIVE' as const, + securityEpoch: 2, + revision: 2, + activatedAt: timestamp('2026-01-01T00:01:00.000Z'), + }; + await assert.rejects(repository.replaceDevice(context(), active, 1), /REVISION_CONFLICT/u); +}); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 935ed118..78edb94a 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -66,8 +66,10 @@ function row( function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly client: IamDatabaseClientV1; readonly memberships: Map; + readonly forceUpdateConflict: { value: boolean }; } { const memberships = new Map(rows.map((value) => [value.id, value])); + const forceUpdateConflict = { value: false }; const client = { membershipIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -82,18 +84,19 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { memberships.set(data.id, data); return data; }, - update: async ({ + updateMany: async ({ where, data, }: { - readonly where: { readonly id: string }; + readonly where: { readonly id: string; readonly revision: number }; readonly data: Partial; }) => { + if (forceUpdateConflict.value) return { count: 0 }; const current = memberships.get(where.id); - if (!current) throw new Error('MEMBERSHIP_NOT_FOUND'); + if (!current || current.revision !== where.revision) return { count: 0 }; const updated = { ...current, ...data }; memberships.set(where.id, updated); - return updated; + return { count: 1 }; }, }, $transaction: async (work: (transaction: IamDatabaseClientV1) => Promise) => { @@ -107,7 +110,7 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships }; + return { client, memberships, forceUpdateConflict }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { @@ -131,7 +134,7 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and }); void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce optimistic revisions', async () => { - const { client, memberships } = createDatabase(); + const { client, memberships, forceUpdateConflict } = createDatabase(); const repository = new PrismaIamRepositoryAdapter(client); const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; await assert.rejects( @@ -165,6 +168,19 @@ void test('[IAM-009, IAM-019] Prisma IAM writes require narrowing and enforce op /IAM_REVISION_CONFLICT/u, ); assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); + forceUpdateConflict.value = true; + await assert.rejects( + repository.saveMembership(context(workspaceScope, 1), { + id: stable('21'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 2, + }), + /IAM_REVISION_CONFLICT/u, + ); + assert.equal(memberships.get(id('21'))?.roleId, 'viewer'); }); void test('[IAM-009] Prisma IAM transaction rollback leaves no staged membership', async () => { diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts index 4a187435..ccdbe89b 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -40,6 +40,7 @@ function createDatabase(): { readonly workspaces: Map; readonly projects: Map; readonly memberships: Map; + readonly transactionCalls: { value: number }; } { const users = new Map([ [ @@ -59,6 +60,7 @@ function createDatabase(): { const workspaces = new Map(); const projects = new Map(); const memberships = new Map(); + const transactionCalls = { value: 0 }; const client = { userIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -117,6 +119,7 @@ function createDatabase(): { $transaction: async ( work: (transaction: IdentityBootstrapDatabaseClientV1) => Promise, ) => { + transactionCalls.value += 1; const before = { organizations: new Map(organizations), workspaces: new Map(workspaces), @@ -138,17 +141,19 @@ function createDatabase(): { } }, } as unknown as IdentityBootstrapDatabaseClientV1; - return { client, users, organizations, workspaces, projects, memberships }; + return { client, users, organizations, workspaces, projects, memberships, transactionCalls }; } void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstructs a personal owner hierarchy', async () => { - const { client, organizations, workspaces, projects, memberships } = createDatabase(); + const { client, organizations, workspaces, projects, memberships, transactionCalls } = + createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); const validated = bootstrapPersonalOrganizationV1(input); assert.equal(validated.accepted, true); if (!validated.accepted) return; await adapter.save(validated.value); + assert.equal(transactionCalls.value, 1); assert.equal(organizations.size, 1); assert.equal(workspaces.size, 1); assert.equal(projects.size, 1); 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 05c5a0c6..9d9994f0 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -173,3 +173,30 @@ void test('[IAM-009, IAM-012] MFA state cannot cross users and failed transactio ); assert.equal(factors.size, 0); }); + +void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale revision', async () => { + const { client, factors } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + if (!factor) throw new Error('fixture missing factor'); + await adapter.saveState(factor.userId, input); + const persisted = factors.get(factor.id); + if (!persisted) throw new Error('fixture factor was not persisted'); + factors.set(factor.id, { + ...persisted, + status: 'ACTIVE', + verifiedAt: new Date('2026-01-01T00:02:00.000Z'), + revision: 2, + }); + const stale = transitionMfaFactorV1(factor, 'VERIFY', '2026-01-01T00:01:00.000Z'); + assert.equal(stale.accepted, true); + if (!stale.accepted) return; + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [stale.value], + recoveryCodes: input.recoveryCodes, + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); +}); diff --git a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts index d3740010..5f0dca75 100644 --- a/services/api/test/features/sa/spreadsheet-audit.controller.test.ts +++ b/services/api/test/features/sa/spreadsheet-audit.controller.test.ts @@ -63,7 +63,10 @@ void test('SA-001/SA-004 HTTP stores value-free audit results and rejects source payload: { ...payload, formula: '=SUM(A1:A3)', sourceValue: '42' }, }); assert.equal(rejected.statusCode, 400); - assert.doesNotMatch(rejected.body, /SUM|42|sourceValue/iu); + const rejectedBody = JSON.parse(rejected.body) as Record; + const rejectedWithoutCorrelation = { ...rejectedBody }; + delete rejectedWithoutCorrelation['correlationId']; + assert.doesNotMatch(JSON.stringify(rejectedWithoutCorrelation), /SUM|42|sourceValue/iu); const created = await app.inject({ method: 'POST', diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 143a562b..b2040d55 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -63,6 +63,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, assert.deepEqual(paths, [ '/health/live', '/health/ready', + '/v1/artifact-deletion-requests/{requestId}', '/v1/artifact-deletion-requests/{requestId}/authorize', '/v1/artifact-upload-sessions', '/v1/artifact-upload-sessions/{sessionId}', @@ -75,6 +76,7 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/artifact-versions/{versionId}/deletion-requests', '/v1/artifact-versions/{versionId}/derived-lineage', '/v1/artifact-versions/{versionId}/evidence', + '/v1/artifact-versions/{versionId}/evidence/{evidenceId}/resolve', '/v1/artifact-versions/{versionId}/lineage', '/v1/artifact-versions/{versionId}/placements/{placementId}', '/v1/artifacts/evidence-grants/{grantId}', diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 40063985..6d13ca9f 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -121,6 +121,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260802280000_iae_protected_document_unlocks', '20260802290000_dsm_export_manifests', '20260802300000_sa_spreadsheet_audits', + '20260803000000_iae_lineage_uniqueness', 'migration_lock.toml', ]); const migration = await readFile( @@ -493,4 +494,12 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const lineageUniquenessMigration = await readFile( + path.join(migrationsDirectory, inventory[32], 'migration.sql'), + 'utf8', + ); + assert.match( + lineageUniquenessMigration, + /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"/, + ); }); diff --git a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py index fb864479..afaa4270 100644 --- a/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py +++ b/services/engine/src/databreeze_engine/processors/spreadsheet_auditor.py @@ -1,9 +1,10 @@ -"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-004).""" +"""Safe deterministic workbook inventory and formula-family auditing (SA-001..SA-007).""" from __future__ import annotations import hashlib import io +import itertools import posixpath import re import zipfile @@ -86,6 +87,14 @@ def _column_number(column: str) -> int: return value +def _column_name(column: int) -> str: + value = "" + while column > 0: + column, remainder = divmod(column - 1, 26) + value = chr(65 + remainder) + value + return value + + def _cell_address(reference: str) -> tuple[int, int] | None: match = _CELL_REFERENCE.fullmatch(reference) if match is None: @@ -98,11 +107,19 @@ def _normalized_formula(value: str) -> str: normalized = _FORMULA_SPACE.sub(" ", value.strip().upper()) def reference(match: re.Match[str]) -> str: - token = match.group(0).replace("$", "") - column = re.match(r"[A-Z]{1,3}", token) - if column is None: + token = re.fullmatch( + r"(?P\$?)(?P[A-Z]{1,3})" + r"(?P\$?)(?P[1-9][0-9]*)", + match.group(0), + re.IGNORECASE, + ) + if token is None: return "#CELL" - return f"{column.group(0)}#ROW" + column = token.group("column").upper() + row = token.group("row") + column_prefix = "$" if token.group("column_absolute") else "" + row_value = f"${row}" if token.group("row_absolute") else "#ROW" + return f"{column_prefix}{column}{row_value}" return _FORMULA_REFERENCE.sub(reference, normalized) @@ -199,7 +216,7 @@ def audit_workbook( root = _xml(archive.read(target)) max_row = 0 max_column = 0 - formulas: list[tuple[str, str]] = [] + cells: list[tuple[str, str | None]] = [] for address, formula in _iter_cells(root): total_cells += 1 if total_cells > max_cells: @@ -211,8 +228,8 @@ def audit_workbook( column, row = coordinates max_column = max(max_column, column) max_row = max(max_row, row) - if formula is not None: - formulas.append((address.upper(), formula)) + cells.append((address.upper(), formula)) + formulas = [(address, formula) for address, formula in cells if formula is not None] families = Counter(_normalized_formula(formula) for _, formula in formulas) for address, formula in formulas: family = _normalized_formula(formula) @@ -225,6 +242,41 @@ def audit_workbook( formulaFingerprint=_fingerprint(family), ) ) + cells_by_column: dict[int, dict[int, str | None]] = {} + for address, formula in cells: + coordinates = _cell_address(address) + if coordinates is None: + continue + column, row = coordinates + cells_by_column.setdefault(column, {})[row] = formula + gap_keys: set[tuple[str, str]] = set() + for column, rows in cells_by_column.items(): + formula_rows = sorted(row for row, formula in rows.items() if formula is not None) + for previous_row, next_row in itertools.pairwise(formula_rows): + if next_row - previous_row <= 1: + continue + previous_formula = rows[previous_row] + next_formula = rows[next_row] + if previous_formula is None or next_formula is None: + continue + previous_family = _normalized_formula(previous_formula) + if previous_family != _normalized_formula(next_formula): + continue + populated_rows = sorted(row for row in rows if previous_row < row < next_row) + for row in populated_rows: + address = f"{_column_name(column)}{row}" + key = (address, previous_family) + if key in gap_keys: + continue + gap_keys.add(key) + findings.append( + SpreadsheetFinding( + sheet=sheet_name, + address=address, + kind="FORMULA_GAP", + formulaFingerprint=_fingerprint(previous_family), + ) + ) summaries.append( SpreadsheetSheetSummary( name=sheet_name, diff --git a/services/engine/tests/test_spreadsheet_auditor.py b/services/engine/tests/test_spreadsheet_auditor.py index 7e1c175f..ffc8e7c9 100644 --- a/services/engine/tests/test_spreadsheet_auditor.py +++ b/services/engine/tests/test_spreadsheet_auditor.py @@ -12,7 +12,13 @@ ) -def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: +def _workbook( + *, + macro: bool = False, + external_link: bool = False, + formula_gap: bool = False, + absolute_reference: bool = False, +) -> bytes: workbook = ( b'' @@ -22,11 +28,27 @@ def _workbook(*, macro: bool = False, external_link: bool = False) -> bytes: b'' b'' ) + if absolute_reference: + sheet_rows = ( + b'SUM($B$1:C1)3' + b'SUM(B2:C2)3' + b'SUM(B3:C3)3' + ) + elif formula_gap: + sheet_rows = ( + b'SUM(B1:C1)3' + b'9' + b'SUM(B3:C3)3' + ) + else: + sheet_rows = ( + b'SUM(B1:C1)3' + b'SUM(B1:C1)3' + b'SUM(B1:D1)4' + ) sheet = ( b'' - b'SUM(B1:C1)3' - b'SUM(B1:C1)3' - b'SUM(B1:D1)4' + b"" + sheet_rows + b"" ) output = io.BytesIO() with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: @@ -50,6 +72,21 @@ def test_audit_is_value_free_and_reports_formula_family_outlier() -> None: assert result.blockedReasons == () +def test_audit_reports_a_formula_gap_without_returning_the_intervening_value() -> None: + result = audit_workbook(_workbook(formula_gap=True)) + assert [(finding.address, finding.kind) for finding in result.findings] == [ + ("A2", "FORMULA_GAP"), + ] + assert all("value" not in finding.model_dump() for finding in result.findings) + + +def test_formula_family_normalization_preserves_absolute_references() -> None: + result = audit_workbook(_workbook(absolute_reference=True)) + assert [(finding.address, finding.kind) for finding in result.findings] == [ + ("A1", "FORMULA_FAMILY_OUTLIER"), + ] + + @pytest.mark.parametrize("flag", ["macro", "external_link"]) def test_audit_discloses_blocked_execution_features_without_running_them(flag: str) -> None: result = audit_workbook(_workbook(**{flag: True}))