Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 68 additions & 3 deletions apps/identity-service/src/data-rights-request-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
DataRightsRequestConflictError,
DataRightsRequestPersistenceError,
DataRightsRequestValidationError,
PostgresDataRightsRequestLedger,
type DataRightsRequestSqlClient,
Expand Down Expand Up @@ -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' }),
Expand All @@ -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);
}
});
Expand Down
51 changes: 48 additions & 3 deletions apps/identity-service/src/data-rights-request-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DataRightsRequestRecord | undefined> {
const safe = validateGetInput(input);
const result = await this.client.query<DataRightsRequestRow>(
`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';
Expand Down Expand Up @@ -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 });
Expand Down
Loading