Skip to content

Commit b158bc9

Browse files
tofikwestclaude
andauthored
fix(isms): serialize approve/decline to prevent concurrent double-publish (CS-701) (#3366)
Flagged on the production deploy PR (#3364): assertPendingApprovalBy runs before the transaction, so two racing approve() calls could both pass the guard. Root cause (verified): under READ COMMITTED a plain in-transaction status re-read does NOT serialize — the loser reads the still-committed `needs_review`. The @@unique([documentId, version]) constraint only turns the perfectly-simultaneous case into an error; the staggered case silently creates a second published version and overwrites currentVersionId. Fix: - approve() now takes a per-document advisory lock and then claims the approval atomically via updateMany({ where: status='needs_review' + approverId }) inside the transaction, aborting when count !== 1. Exactly one caller freezes a version; losers abort before any derivation/version creation. The pre-transaction guard is kept for the normal-case specific error messages. - decline() gets the same atomic claim, so an approve/decline race can no longer leave a "declined" document that still owns a live currentVersionId. - Renamed the shared advisory-lock helper lockDocumentForPositions -> lockDocument (it now serializes approval too, not just register-row position allocation); updated the four register-create callers. Adversarially audited the whole approve/publish/version/export flow for related concurrency + IDOR issues; getVersionExport org-scoping, S3 keys, snapshot parsing, currentVersionId integrity and the migration paths verified safe. The narrow edit-during-approve window for register update/delete is low-severity/self-healing and left as a follow-up. Tests: 321 api ISMS tests passing (incl. new approve + decline race tests); typecheck clean. Claude-Session: https://claude.ai/code/session_012CweXoSSEP89mX93u3Bdcp Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d756d4a commit b158bc9

8 files changed

Lines changed: 125 additions & 37 deletions

‎apps/api/src/isms/isms-context-issue.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
22
import { db } from '@db';
33
import type { Prisma } from '@db';
44
import { invalidateApprovalIfNeeded } from './utils/approval';
5-
import { lockDocumentForPositions } from './utils/document-lock';
5+
import { lockDocument } from './utils/document-lock';
66
import type {
77
CreateContextIssueInput,
88
UpdateContextIssueInput,
@@ -28,7 +28,7 @@ export class IsmsContextIssueService {
2828
await this.requireDocument({ documentId, organizationId });
2929

3030
return db.$transaction(async (tx) => {
31-
await lockDocumentForPositions(tx, documentId);
31+
await lockDocument(tx, documentId);
3232
const position =
3333
dto.position ?? (await this.nextPosition({ tx, documentId }));
3434
await invalidateApprovalIfNeeded({ tx, documentId });
@@ -92,7 +92,7 @@ export class IsmsContextIssueService {
9292
/**
9393
* Next position uses max(position)+1 so it survives deletes. Runs on the
9494
* transaction client; the create first takes a per-document advisory lock
95-
* (lockDocumentForPositions) so concurrent creates can't read the same max.
95+
* (lockDocument) so concurrent creates can't read the same max.
9696
*/
9797
private async nextPosition({
9898
tx,

‎apps/api/src/isms/isms-interested-party.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
22
import { db } from '@db';
33
import type { Prisma } from '@db';
44
import { invalidateApprovalIfNeeded } from './utils/approval';
5-
import { lockDocumentForPositions } from './utils/document-lock';
5+
import { lockDocument } from './utils/document-lock';
66
import type {
77
CreateInterestedPartyInput,
88
UpdateInterestedPartyInput,
@@ -28,7 +28,7 @@ export class IsmsInterestedPartyService {
2828
await this.requireDocument({ documentId, organizationId });
2929

3030
return db.$transaction(async (tx) => {
31-
await lockDocumentForPositions(tx, documentId);
31+
await lockDocument(tx, documentId);
3232
const position =
3333
dto.position ?? (await this.nextPosition({ tx, documentId }));
3434
await invalidateApprovalIfNeeded({ tx, documentId });
@@ -89,7 +89,7 @@ export class IsmsInterestedPartyService {
8989
/**
9090
* Next position uses max(position)+1 so it survives deletes. Runs on the
9191
* transaction client; the create first takes a per-document advisory lock
92-
* (lockDocumentForPositions) so concurrent creates can't read the same max.
92+
* (lockDocument) so concurrent creates can't read the same max.
9393
*/
9494
private async nextPosition({
9595
tx,

‎apps/api/src/isms/isms-objective.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
22
import { db } from '@db';
33
import type { Prisma } from '@db';
44
import { invalidateApprovalIfNeeded } from './utils/approval';
5-
import { lockDocumentForPositions } from './utils/document-lock';
5+
import { lockDocument } from './utils/document-lock';
66
import type {
77
CreateObjectiveInput,
88
UpdateObjectiveInput,
@@ -31,7 +31,7 @@ export class IsmsObjectiveService {
3131
});
3232

3333
return db.$transaction(async (tx) => {
34-
await lockDocumentForPositions(tx, documentId);
34+
await lockDocument(tx, documentId);
3535
const position =
3636
dto.position ?? (await this.nextPosition({ tx, documentId }));
3737
await invalidateApprovalIfNeeded({ tx, documentId });
@@ -118,7 +118,7 @@ export class IsmsObjectiveService {
118118
/**
119119
* Next position uses max(position)+1 so it survives deletes. Runs on the
120120
* transaction client; the create first takes a per-document advisory lock
121-
* (lockDocumentForPositions) so concurrent creates can't read the same max.
121+
* (lockDocument) so concurrent creates can't read the same max.
122122
*/
123123
private async nextPosition({
124124
tx,

‎apps/api/src/isms/isms-requirement.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { db } from '@db';
77
import type { Prisma } from '@db';
88
import { invalidateApprovalIfNeeded } from './utils/approval';
9-
import { lockDocumentForPositions } from './utils/document-lock';
9+
import { lockDocument } from './utils/document-lock';
1010
import type {
1111
CreateRequirementInput,
1212
UpdateRequirementInput,
@@ -37,7 +37,7 @@ export class IsmsRequirementService {
3737
}
3838

3939
return db.$transaction(async (tx) => {
40-
await lockDocumentForPositions(tx, documentId);
40+
await lockDocument(tx, documentId);
4141
const position =
4242
dto.position ?? (await this.nextPosition({ tx, documentId }));
4343
await invalidateApprovalIfNeeded({ tx, documentId });
@@ -123,7 +123,7 @@ export class IsmsRequirementService {
123123
/**
124124
* Next position uses max(position)+1 so it survives deletes. Runs on the
125125
* transaction client; the create first takes a per-document advisory lock
126-
* (lockDocumentForPositions) so concurrent creates can't read the same max.
126+
* (lockDocument) so concurrent creates can't read the same max.
127127
*/
128128
private async nextPosition({
129129
tx,

‎apps/api/src/isms/isms.service.approve.spec.ts‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ describe('IsmsService.approve (CS-701 versioning)', () => {
122122
.mockResolvedValueOnce({ id: 'doc_1', status: 'approved' });
123123
mockCollect.mockResolvedValue(platformData);
124124
const tx = {
125+
$executeRaw: jest.fn().mockResolvedValue(0),
125126
ismsDocument: {
127+
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
126128
findUniqueOrThrow: jest.fn().mockResolvedValue(reloaded),
127129
update: jest.fn().mockResolvedValue({}),
128130
},
@@ -155,18 +157,26 @@ describe('IsmsService.approve (CS-701 versioning)', () => {
155157
expect(mockUpdateDraft).toHaveBeenCalledWith(
156158
expect.objectContaining({ tx, documentId: 'doc_1' }),
157159
);
160+
// The approval is claimed atomically (serializes concurrent approvals): only
161+
// matches while still awaiting this member's review.
162+
expect(tx.ismsDocument.updateMany).toHaveBeenCalledWith({
163+
where: {
164+
id: 'doc_1',
165+
organizationId: 'org_1',
166+
status: 'needs_review',
167+
approverId: 'mem_1',
168+
},
169+
data: expect.objectContaining({ status: 'approved', declinedAt: null }),
170+
});
158171
// A published version is created from the reloaded (re-derived) document.
159172
expect(versionService.createPublishedVersion).toHaveBeenCalledWith(
160173
expect.objectContaining({ tx, document: reloaded, memberId: 'mem_1' }),
161174
);
162-
// The document is promoted to the freshly-created version.
175+
// The document is promoted to the freshly-created version (status was already
176+
// set by the atomic claim above).
163177
expect(tx.ismsDocument.update).toHaveBeenCalledWith({
164178
where: { id: 'doc_1' },
165-
data: expect.objectContaining({
166-
status: 'approved',
167-
declinedAt: null,
168-
currentVersionId: 'isms_ver_1',
169-
}),
179+
data: { currentVersionId: 'isms_ver_1' },
170180
});
171181
// Renders + uploads happen AFTER the transaction (Policies pattern).
172182
expect(versionService.publishRenders).toHaveBeenCalledWith(
@@ -178,4 +188,32 @@ describe('IsmsService.approve (CS-701 versioning)', () => {
178188
}),
179189
);
180190
});
191+
192+
it('aborts without creating a version when a concurrent approval already won the claim', async () => {
193+
(mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
194+
(mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
195+
id: 'doc_1',
196+
status: 'needs_review',
197+
approverId: 'mem_1',
198+
frameworkId: 'fw_1',
199+
type: 'context_of_organization',
200+
});
201+
mockCollect.mockResolvedValue(platformData);
202+
const tx = {
203+
$executeRaw: jest.fn().mockResolvedValue(0),
204+
ismsDocument: {
205+
// The racing approval already flipped status, so the claim matches 0 rows.
206+
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
207+
findUniqueOrThrow: jest.fn(),
208+
update: jest.fn(),
209+
},
210+
};
211+
(mockDb.$transaction as jest.Mock).mockImplementation((cb) => cb(tx));
212+
213+
await expect(service.approve(args)).rejects.toThrow(BadRequestException);
214+
// No derivation, no version creation once the claim fails.
215+
expect(mockRunDerivation).not.toHaveBeenCalled();
216+
expect(versionService.createPublishedVersion).not.toHaveBeenCalled();
217+
expect(tx.ismsDocument.findUniqueOrThrow).not.toHaveBeenCalled();
218+
});
181219
});

‎apps/api/src/isms/isms.service.lifecycle.spec.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jest.mock('@db', () => ({
1414
ismsDocument: {
1515
findFirst: jest.fn(),
1616
update: jest.fn(),
17+
updateMany: jest.fn(),
1718
},
1819
member: { findFirst: jest.fn() },
1920
$transaction: jest.fn(),
@@ -151,23 +152,39 @@ describe('IsmsService document lifecycle', () => {
151152
await expect(service.decline(args)).rejects.toThrow(ForbiddenException);
152153
});
153154

154-
it('sets declined status and declinedAt', async () => {
155+
it('sets declined status and declinedAt via an atomic claim', async () => {
155156
(mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
156157
(mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
157158
id: 'doc_1',
158159
status: 'needs_review',
159160
approverId: 'mem_1',
160161
});
161-
(mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({
162-
id: 'doc_1',
163-
status: 'declined',
164-
});
162+
(mockDb.ismsDocument.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
165163

166164
await service.decline(args);
167165

168-
const call = (mockDb.ismsDocument.update as jest.Mock).mock.calls[0][0];
166+
const call = (mockDb.ismsDocument.updateMany as jest.Mock).mock.calls[0][0];
167+
// Guarded transition: only matches while awaiting this member's review.
168+
expect(call.where).toEqual({
169+
id: 'doc_1',
170+
organizationId: 'org_1',
171+
status: 'needs_review',
172+
approverId: 'mem_1',
173+
});
169174
expect(call.data.status).toBe('declined');
170175
expect(call.data.declinedAt).toBeInstanceOf(Date);
171176
});
177+
178+
it('aborts when a concurrent approve already won (claim matches 0 rows)', async () => {
179+
(mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' });
180+
(mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
181+
id: 'doc_1',
182+
status: 'needs_review',
183+
approverId: 'mem_1',
184+
});
185+
(mockDb.ismsDocument.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
186+
187+
await expect(service.decline(args)).rejects.toThrow(BadRequestException);
188+
});
172189
});
173190
});

‎apps/api/src/isms/isms.service.ts‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { collectPlatformData } from './documents/data-source';
1111
import { runDerivation } from './documents/generate';
1212
import { updateDraftSnapshot } from './utils/draft-snapshot';
1313
import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload';
14+
import { lockDocument } from './utils/document-lock';
1415
import { IsmsVersionService } from './isms-version.service';
1516

1617
/**
@@ -224,6 +225,29 @@ export class IsmsService {
224225
// currentVersion. Editing afterwards reverts status to draft but leaves this
225226
// published version live and exportable (CS-701).
226227
const published = await db.$transaction(async (tx) => {
228+
// Serialize concurrent approvals (and register-row creates, which take the
229+
// same lock) on this document so they can't interleave and double-publish.
230+
await lockDocument(tx, documentId);
231+
232+
// Atomically claim the approval: the check-then-act guard above runs before
233+
// the transaction, so under READ COMMITTED a racing approve/decline could
234+
// read the same stale `needs_review`. This conditional update only matches
235+
// while the document is still awaiting THIS member's review, so exactly one
236+
// caller proceeds; a loser matches zero rows and aborts before creating a
237+
// version. (A plain in-transaction re-read would not serialize.)
238+
const claim = await tx.ismsDocument.updateMany({
239+
where: {
240+
id: documentId,
241+
organizationId,
242+
status: 'needs_review',
243+
approverId: member.id,
244+
},
245+
data: { status: 'approved', approvedAt: now, declinedAt: null },
246+
});
247+
if (claim.count !== 1) {
248+
throw new BadRequestException('Document is not pending your approval');
249+
}
250+
227251
// Re-derive in the same transaction so the persisted rows and the frozen
228252
// snapshot come from one pass (otherwise the approved content can drift).
229253
await runDerivation({
@@ -250,12 +274,7 @@ export class IsmsService {
250274

251275
await tx.ismsDocument.update({
252276
where: { id: documentId },
253-
data: {
254-
status: 'approved',
255-
approvedAt: now,
256-
declinedAt: null,
257-
currentVersionId: result.versionId,
258-
},
277+
data: { currentVersionId: result.versionId },
259278
});
260279
return result;
261280
});
@@ -286,10 +305,23 @@ export class IsmsService {
286305
const document = await this.requireDocument({ documentId, organizationId });
287306
this.assertPendingApprovalBy({ document, member });
288307

289-
return db.ismsDocument.update({
290-
where: { id: documentId },
308+
// Atomically claim the decline so it can't race an approve (both pass the
309+
// pre-transaction guard). Only matches while still awaiting this member's
310+
// review, so a concurrent approve that already won leaves zero rows here.
311+
const claim = await db.ismsDocument.updateMany({
312+
where: {
313+
id: documentId,
314+
organizationId,
315+
status: 'needs_review',
316+
approverId: member.id,
317+
},
291318
data: { status: 'declined', declinedAt: new Date() },
292319
});
320+
if (claim.count !== 1) {
321+
throw new BadRequestException('Document is not pending your approval');
322+
}
323+
324+
return this.getDocument({ documentId, organizationId });
293325
}
294326

295327
/**

‎apps/api/src/isms/utils/document-lock.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { Prisma } from '@db';
22

33
/**
4-
* Serialize register-row position allocation for a single document. The Postgres
4+
* Serialize concurrent writes for a single ISMS document. The Postgres
55
* transaction-scoped advisory lock (keyed on the document id) is held until the
6-
* surrounding transaction commits, so two concurrent creates can't both read the
7-
* same max(position) and persist duplicate ordering keys. Call this inside the
8-
* create transaction, before computing the next position.
6+
* surrounding transaction commits, so two transactions touching the same document
7+
* run one at a time. Used by register-row creates (so concurrent creates can't
8+
* read the same max(position)) and by approve (so concurrent approvals can't both
9+
* freeze a published version). Call this first, inside the transaction.
910
*/
10-
export async function lockDocumentForPositions(
11+
export async function lockDocument(
1112
tx: Prisma.TransactionClient,
1213
documentId: string,
1314
): Promise<void> {

0 commit comments

Comments
 (0)