diff --git a/apps/identity-service/src/data-rights-request-ledger.test.ts b/apps/identity-service/src/data-rights-request-ledger.test.ts index c06ef9cf..7abacd08 100644 --- a/apps/identity-service/src/data-rights-request-ledger.test.ts +++ b/apps/identity-service/src/data-rights-request-ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DataRightsRequestConflictError, + DataRightsRequestPersistenceError, DataRightsRequestValidationError, PostgresDataRightsRequestLedger, type DataRightsRequestSqlClient, @@ -208,6 +209,70 @@ describe('PostgresDataRightsRequestLedger', () => { ).rejects.toBeInstanceOf(DataRightsRequestConflictError); }); + it('returns one request only through tenant-and-actor scoped status lookup', async () => { + const client = new RecordingSqlClient([[storedRow()]]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect( + ledger.getRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + }), + ).resolves.toMatchObject({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + status: 'pending', + }); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain('request_id = $1::uuid'); + expect(client.calls[0]?.text).toContain('workspace_id = $2::uuid'); + expect(client.calls[0]?.text).toContain('requested_by_user_id = $3::uuid'); + expect(client.calls[0]?.values).toEqual([ + REQUEST_ID, + WORKSPACE_ID, + ACTOR_USER_ID, + ]); + }); + + it('returns undefined for an inaccessible request without widening the lookup', async () => { + const client = new RecordingSqlClient([[]]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect( + ledger.getRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + }), + ).resolves.toBeUndefined(); + expect(client.calls).toHaveLength(1); + }); + + it('fails closed on malformed or duplicate persisted status lookup evidence', async () => { + const invalidClient = new RecordingSqlClient([]); + const invalidLedger = new PostgresDataRightsRequestLedger(invalidClient); + await expect( + invalidLedger.getRequest({ + requestId: 'not-a-uuid', + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + }), + ).rejects.toBeInstanceOf(DataRightsRequestValidationError); + expect(invalidClient.calls).toHaveLength(0); + + const duplicateClient = new RecordingSqlClient([[storedRow(), storedRow()]]); + const duplicateLedger = new PostgresDataRightsRequestLedger(duplicateClient); + await expect( + duplicateLedger.getRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + }), + ).rejects.toBeInstanceOf(DataRightsRequestPersistenceError); + }); + it('rejects malformed ownership, digest, kind, and time before querying PostgreSQL', async () => { for (const invalidInput of [ beginInput({ workspaceId: 'not-a-uuid' }), @@ -217,9 +282,9 @@ describe('PostgresDataRightsRequestLedger', () => { ]) { const client = new RecordingSqlClient([]); const ledger = new PostgresDataRightsRequestLedger(client); - await expect(ledger.beginRequest(invalidInput as never)).rejects.toBeInstanceOf( - DataRightsRequestValidationError, - ); + await expect( + ledger.beginRequest(invalidInput as never), + ).rejects.toBeInstanceOf(DataRightsRequestValidationError); expect(client.calls).toHaveLength(0); } }); diff --git a/apps/identity-service/src/data-rights-request-ledger.ts b/apps/identity-service/src/data-rights-request-ledger.ts index 431d042b..ffea625a 100644 --- a/apps/identity-service/src/data-rights-request-ledger.ts +++ b/apps/identity-service/src/data-rights-request-ledger.ts @@ -49,6 +49,13 @@ export interface BeginDataRightsRequest { readonly requestedAt: string; } +/** Validated input for tenant-and-actor scoped request-status lookup. */ +export interface GetDataRightsRequest { + readonly requestId: string; + readonly workspaceId: string; + readonly requestedByUserId: string; +} + /** Validated input for binding an immutable terminal receipt to one request. */ export interface CompleteDataRightsRequest { readonly requestId: string; @@ -135,7 +142,11 @@ function requireStoredDigest(value: unknown): string { function parseInstant(value: unknown, invalid: () => never): string { const candidate = - value instanceof Date ? value.toISOString() : typeof value === 'string' ? value : ''; + value instanceof Date + ? value.toISOString() + : typeof value === 'string' + ? value + : ''; if (!ISO_INSTANT_PATTERN.test(candidate)) { return invalid(); } @@ -188,7 +199,10 @@ function parseRequestRow(row: DataRightsRequestRow): DataRightsRequestRecord { return invalidPersistence(); } const requestedAt = requireStoredInstant(row.requested_at); - if (completedAt !== null && new Date(completedAt).getTime() < new Date(requestedAt).getTime()) { + if ( + completedAt !== null && + new Date(completedAt).getTime() < new Date(requestedAt).getTime() + ) { return invalidPersistence(); } return Object.freeze({ @@ -224,6 +238,14 @@ function validateBeginInput(input: BeginDataRightsRequest): BeginDataRightsReque }); } +function validateGetInput(input: GetDataRightsRequest): GetDataRightsRequest { + return Object.freeze({ + requestId: requireInputUuid(input.requestId), + workspaceId: requireInputUuid(input.workspaceId), + requestedByUserId: requireInputUuid(input.requestedByUserId), + }); +} + function validateCompleteInput( input: CompleteDataRightsRequest, ): CompleteDataRightsRequest { @@ -258,6 +280,26 @@ export class PostgresDataRightsRequestLedger { /** Creates the ledger over a least-authority fixed-query SQL client. */ constructor(private readonly client: DataRightsRequestSqlClient) {} + /** Returns one request only when request, workspace, and requesting actor match. */ + async getRequest( + input: GetDataRightsRequest, + ): Promise { + const safe = validateGetInput(input); + const result = await this.client.query( + `SELECT request_id, workspace_id, requested_by_user_id, request_kind, + idempotency_key, request_digest, request_status, receipt_digest, + requested_at, completed_at + FROM identity.data_rights_requests + WHERE request_id = $1::uuid + AND workspace_id = $2::uuid + AND requested_by_user_id = $3::uuid + LIMIT 2`, + [safe.requestId, safe.workspaceId, safe.requestedByUserId], + ); + const row = oneOrUndefined(result.rows); + return row ? parseRequestRow(row) : undefined; + } + /** Creates one tenant-bound request or returns its exact durable replay. */ async beginRequest(input: BeginDataRightsRequest): Promise<{ readonly kind: 'created' | 'replayed'; @@ -365,7 +407,10 @@ export class PostgresDataRightsRequestLedger { throw new DataRightsRequestConflictError(); } const request = parseRequestRow(existingRow); - if (request.status !== 'completed' || request.receiptDigest !== safe.receiptDigest) { + if ( + request.status !== 'completed' || + request.receiptDigest !== safe.receiptDigest + ) { throw new DataRightsRequestConflictError(); } return Object.freeze({ kind: 'replayed', request });