Skip to content

Commit d408d99

Browse files
Merge pull request #490 from corbitsdev/cl-7232-lost-update-access-policy
Fix lost-update race in access-policy upsertPolicy
2 parents 8d13588 + 1b61def commit d408d99

2 files changed

Lines changed: 151 additions & 31 deletions

File tree

‎packages/access-policy/src/store.ts‎

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -110,41 +110,63 @@ export function createDrizzleAccessPolicyStore<
110110
return row !== undefined;
111111
},
112112

113+
// Read-modify-write under a transaction with a row lock rather than
114+
// an optimistic version-stamp check: two transactions starting in
115+
// the same wall-clock tick could still both pass a version check,
116+
// which is exactly the lost-update bug this closes.
117+
//
118+
// Because this method's row may not exist yet (it is an upsert),
119+
// `SELECT ... FOR UPDATE` alone has nothing to lock for a brand-new
120+
// tenant. The `INSERT ... ON CONFLICT DO NOTHING` below guarantees
121+
// a row first: two concurrent first-writes for the same tenant
122+
// serialize on that insert's unique-index conflict — the loser
123+
// blocks until the winner's transaction commits, then no-ops and
124+
// its own subsequent `SELECT ... FOR UPDATE` sees the winner's
125+
// committed row rather than racing it.
113126
async upsertPolicy(tenantId, patch) {
114-
const current = await db
115-
.select()
116-
.from(policy)
117-
.where(eq(policy.tenantId, tenantId));
118-
const existing =
119-
current[0] === undefined
120-
? DEFAULT_ACCESS_POLICY
121-
: resolveAccessPolicy(current[0]);
122-
const next: AccessPolicy = {
123-
selfSignup: patch.selfSignup ?? existing.selfSignup,
124-
allowedDomains: patch.allowedDomains ?? existing.allowedDomains,
125-
tenancyCreation: patch.tenancyCreation ?? existing.tenancyCreation,
126-
};
127-
const now = new Date();
128-
await db
129-
.insert(policy)
130-
.values({
131-
tenantId,
132-
selfSignup: next.selfSignup,
133-
allowedDomains: serializeAllowedDomains(next.allowedDomains),
134-
tenancyCreation: next.tenancyCreation,
135-
createdAt: now,
136-
updatedAt: now,
137-
})
138-
.onConflictDoUpdate({
139-
target: policy.tenantId,
140-
set: {
127+
return db.transaction(async (tx) => {
128+
const now = new Date();
129+
await tx
130+
.insert(policy)
131+
.values({
132+
tenantId,
133+
selfSignup: DEFAULT_ACCESS_POLICY.selfSignup,
134+
allowedDomains: serializeAllowedDomains(
135+
DEFAULT_ACCESS_POLICY.allowedDomains,
136+
),
137+
tenancyCreation: DEFAULT_ACCESS_POLICY.tenancyCreation,
138+
createdAt: now,
139+
updatedAt: now,
140+
})
141+
.onConflictDoNothing({ target: policy.tenantId });
142+
143+
const [current] = await tx
144+
.select()
145+
.from(policy)
146+
.where(eq(policy.tenantId, tenantId))
147+
.for("update");
148+
if (current === undefined) {
149+
throw new Error(
150+
`upsertPolicy: no access_policy.policy row for tenant ${tenantId} after ensuring one exists`,
151+
);
152+
}
153+
const existing = resolveAccessPolicy(current);
154+
const next: AccessPolicy = {
155+
selfSignup: patch.selfSignup ?? existing.selfSignup,
156+
allowedDomains: patch.allowedDomains ?? existing.allowedDomains,
157+
tenancyCreation: patch.tenancyCreation ?? existing.tenancyCreation,
158+
};
159+
await tx
160+
.update(policy)
161+
.set({
141162
selfSignup: next.selfSignup,
142163
allowedDomains: serializeAllowedDomains(next.allowedDomains),
143164
tenancyCreation: next.tenancyCreation,
144-
updatedAt: now,
145-
},
146-
});
147-
return next;
165+
updatedAt: new Date(),
166+
})
167+
.where(eq(policy.tenantId, tenantId));
168+
return next;
169+
});
148170
},
149171

150172
async createPendingInvite(tenantId, input) {

‎packages/access-policy/test/store.drizzle.test.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,104 @@ describeIfDb("createDrizzleAccessPolicyStore", () => {
180180
}
181181
});
182182

183+
test("upsertPolicy is atomic: two concurrent patches to different fields on an existing row both land, neither reverts the other", async () => {
184+
const setupSql = postgres(scratchUrl, { max: 1 });
185+
try {
186+
const store = createDrizzleAccessPolicyStore(drizzle(setupSql));
187+
await store.upsertPolicy("tnt_race_existing", {
188+
selfSignup: "off",
189+
allowedDomains: [],
190+
tenancyCreation: "owners",
191+
});
192+
} finally {
193+
await setupSql.end();
194+
}
195+
196+
// A plain `Promise.all` of two real calls does not reliably force
197+
// the worst-case interleaving on a fast local connection: one
198+
// call's whole read-modify-write often finishes before the other's
199+
// read even starts, so the two never actually overlap. Instead, a
200+
// third connection takes the row's lock first and holds it open
201+
// while both real `upsertPolicy` calls start and queue up behind
202+
// it — releasing it then guarantees both calls' reads had to
203+
// happen without seeing the other's write yet, exactly the
204+
// interleaving that silently reverted one admin's change.
205+
const holderSql = postgres(scratchUrl, { max: 1 });
206+
const sqlA = postgres(scratchUrl, { max: 1 });
207+
const sqlB = postgres(scratchUrl, { max: 1 });
208+
try {
209+
const storeA = createDrizzleAccessPolicyStore(drizzle(sqlA));
210+
const storeB = createDrizzleAccessPolicyStore(drizzle(sqlB));
211+
212+
let holderReady: () => void = () => undefined;
213+
const holderHasLock = new Promise<void>((resolve) => {
214+
holderReady = resolve;
215+
});
216+
let releaseHolder: () => void = () => undefined;
217+
const releaseSignal = new Promise<void>((resolve) => {
218+
releaseHolder = resolve;
219+
});
220+
const holderTx = holderSql.begin(async (tx) => {
221+
await tx`select * from access_policy.policy where tenant_id = 'tnt_race_existing' for update`;
222+
holderReady();
223+
await releaseSignal;
224+
});
225+
226+
await holderHasLock;
227+
228+
const racers = Promise.all([
229+
storeA.upsertPolicy("tnt_race_existing", { selfSignup: "open" }),
230+
storeB.upsertPolicy("tnt_race_existing", {
231+
allowedDomains: ["acme.example"],
232+
}),
233+
]);
234+
// Give both calls time to actually issue their row-locking read
235+
// and start queuing behind the holder before it releases.
236+
await new Promise((resolve) => setTimeout(resolve, 100));
237+
238+
releaseHolder();
239+
await holderTx;
240+
await racers;
241+
242+
const policy = await storeA.getPolicy("tnt_race_existing");
243+
expect(policy.selfSignup).toBe("open");
244+
expect(policy.allowedDomains).toEqual(["acme.example"]);
245+
expect(policy.tenancyCreation).toBe("owners");
246+
} finally {
247+
await holderSql.end();
248+
await sqlA.end();
249+
await sqlB.end();
250+
}
251+
});
252+
253+
test("upsertPolicy is atomic: two concurrent first-writes for a brand-new tenant both land, neither reverts the other", async () => {
254+
const sql = postgres(scratchUrl, { max: 5 });
255+
try {
256+
const store = createDrizzleAccessPolicyStore(drizzle(sql));
257+
258+
// No row exists yet for this tenant, so both calls race the
259+
// create path too — the ensure-row-then-lock step inside
260+
// `upsertPolicy` has to serialize this case as well, not only
261+
// the existing-row case above.
262+
await Promise.all([
263+
store.upsertPolicy("tnt_race_new", { selfSignup: "open" }),
264+
store.upsertPolicy("tnt_race_new", {
265+
allowedDomains: ["acme.example"],
266+
}),
267+
]);
268+
269+
const policy = await store.getPolicy("tnt_race_new");
270+
expect(policy.selfSignup).toBe("open");
271+
expect(policy.allowedDomains).toEqual(["acme.example"]);
272+
273+
const rows =
274+
await sql`select count(*)::int as count from access_policy.policy where tenant_id = 'tnt_race_new'`;
275+
expect(rows[0]?.["count"]).toBe(1);
276+
} finally {
277+
await sql.end();
278+
}
279+
});
280+
183281
test("pending invites: a domain match is found for any email on that domain", async () => {
184282
const sql = postgres(scratchUrl, { max: 1 });
185283
try {

0 commit comments

Comments
 (0)