Skip to content

Commit b5f573a

Browse files
Brooooooklynclaude
andcommitted
Queue an admin suspension's token rotations in one statement
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3c5736f commit b5f573a

3 files changed

Lines changed: 115 additions & 20 deletions

File tree

‎platform/packages/api/src/db/admin.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
import { alias } from 'drizzle-orm/pg-core';
1717
import { lockAuthSettings } from '../auth/configuration.ts';
1818
import { generateId } from '../id.ts';
19-
import { requestProxyTokenRotation } from '../proxy-token-rotation-request.ts';
19+
import {
20+
requestProxyTokenRotation,
21+
requestProxyTokenRotationsForMember,
22+
} from '../proxy-token-rotation-request.ts';
2023
import {
2124
users,
2225
projects,
@@ -178,16 +181,7 @@ async function withAdminRemovalGuard<T>(
178181

179182
/** An admin suspension ends deploy access, like account deletion. */
180183
async function requestRotationsForAdminSuspension(tx: Db, id: string): Promise<void> {
181-
const deployMemberships = await tx
182-
.select({ projectId: projectMembers.project_id })
183-
.from(projectMembers)
184-
.where(
185-
and(eq(projectMembers.user_id, id), inArray(projectMembers.role, ['collaborator', 'admin'])),
186-
)
187-
.orderBy(asc(projectMembers.project_id));
188-
for (const { projectId } of deployMemberships) {
189-
await requestProxyTokenRotation(tx, projectId, 'user-suspended', 'member');
190-
}
184+
await requestProxyTokenRotationsForMember(tx, id, 'user-suspended');
191185
}
192186

193187
/**

‎platform/packages/api/src/proxy-token-rotation-request.ts‎

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { sql } from 'drizzle-orm';
1+
import { and, eq, inArray, sql } from 'drizzle-orm';
22
import type { DbOrTx } from './db/client.ts';
3-
import { projectCleanupJobs } from './schema.ts';
3+
import { projectCleanupJobs, projectMembers } from './schema.ts';
44

55
// Rotation requests, apart from the runner in `proxy-token-rotation.ts` so the
66
// `db/` modules can write one inside their transactions without importing
@@ -26,6 +26,13 @@ export function rotationLog(event: string, projectId: string, detail = ''): void
2626
console.log(`[proxy-token-rotation] ${event} ${projectId}${detail ? ` ${detail}` : ''}`);
2727
}
2828

29+
// What a request does to an existing job. Both writers below share these, so
30+
// one statement for many projects leaves the rows one request per project would.
31+
const jobStarted = sql`${projectCleanupJobs.payload}->>'phase' <> 'requested'`;
32+
const flagAgain = sql`CASE WHEN ${projectCleanupJobs.payload}->>'phase' <> 'requested'
33+
THEN ${projectCleanupJobs.payload} || '{"again":"1"}'::jsonb
34+
ELSE ${projectCleanupJobs.payload} END`;
35+
2936
/**
3037
* Asks for a new token generation. A job that has not started yet already
3138
* covers the request; one past START may have handed out tok(T), so it
@@ -38,9 +45,6 @@ export async function requestProxyTokenRotation(
3845
reason: ProxyTokenRotationReason,
3946
mode: 'member' | 'operator',
4047
): Promise<void> {
41-
const flagAgain = sql`CASE WHEN ${projectCleanupJobs.payload}->>'phase' <> 'requested'
42-
THEN ${projectCleanupJobs.payload} || '{"again":"1"}'::jsonb
43-
ELSE ${projectCleanupJobs.payload} END`;
4448
const rows = await tx
4549
.insert(projectCleanupJobs)
4650
.values({
@@ -56,12 +60,39 @@ export async function requestProxyTokenRotation(
5660
mode === 'operator'
5761
? { payload: flagAgain, next_attempt_at: sql`now()` }
5862
: { payload: flagAgain },
59-
...(mode === 'member'
60-
? { setWhere: sql`${projectCleanupJobs.payload}->>'phase' <> 'requested'` }
61-
: {}),
63+
...(mode === 'member' ? { setWhere: jobStarted } : {}),
6264
})
6365
.returning({ phase: sql<string>`${projectCleanupJobs.payload}->>'phase'` });
6466
if (rows.length > 0) {
6567
rotationLog('requested', projectId, `reason=${reason}`);
6668
}
6769
}
70+
71+
/**
72+
* `requestProxyTokenRotation` in `member` mode for every project where the
73+
* user is a collaborator or admin, in one statement whatever the number of
74+
* projects. Call inside the caller's transaction.
75+
*/
76+
export async function requestProxyTokenRotationsForMember(
77+
tx: DbOrTx,
78+
userId: string,
79+
reason: ProxyTokenRotationReason,
80+
): Promise<void> {
81+
const deployMember = and(
82+
eq(projectMembers.user_id, userId),
83+
inArray(projectMembers.role, ['collaborator', 'admin']),
84+
);
85+
const result = await tx.execute<{ project_id: string }>(sql`
86+
INSERT INTO ${projectCleanupJobs} (target, project_id, kind, payload, next_attempt_at)
87+
SELECT ${PROXY_TOKEN_ROTATION_JOB_KIND}::text || ':' || ${projectMembers.project_id},
88+
${projectMembers.project_id}, ${PROXY_TOKEN_ROTATION_JOB_KIND}::text,
89+
jsonb_build_object('phase', 'requested', 'reason', ${reason}::text), now()
90+
FROM ${projectMembers}
91+
WHERE ${deployMember}
92+
ORDER BY ${projectMembers.project_id}
93+
ON CONFLICT (target) DO UPDATE SET payload = ${flagAgain} WHERE ${jobStarted}
94+
RETURNING project_id`);
95+
for (const { project_id: projectId } of result.rows) {
96+
rotationLog('requested', projectId, `reason=${reason}`);
97+
}
98+
}

‎platform/packages/api/test/integration/project-team-lifecycle.test.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eq } from 'drizzle-orm';
1+
import { eq, sql } from 'drizzle-orm';
22
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test';
33
import {
44
createGitHubConnection,
@@ -14,6 +14,7 @@ import {
1414
respondToProjectInvitation,
1515
suspendUser,
1616
} from '../../src/db.ts';
17+
import { proxyTokenRotationTarget } from '../../src/proxy-token-rotation-request.ts';
1718
import {
1819
projectAccessEvents,
1920
projectCleanupJobs,
@@ -196,6 +197,75 @@ describe('project team lifecycle', () => {
196197
}
197198
});
198199

200+
it('queues the rotations of an admin suspension in one statement, keeping queued jobs', async () => {
201+
const owner = await createTestUser(env.db, 'many-owner');
202+
const member = await createTestUser(env.db, 'many-member');
203+
const collaborated = await createProject(env.db, owner.id, 'many-collaborator');
204+
const administered = await createProject(env.db, owner.id, 'many-admin');
205+
const read = await createProject(env.db, owner.id, 'many-reader');
206+
const queued = await createProject(env.db, owner.id, 'many-queued');
207+
const started = await createProject(env.db, owner.id, 'many-started');
208+
await env.db.insert(projectMembers).values([
209+
{ project_id: collaborated.id, user_id: member.id, role: 'collaborator' },
210+
{ project_id: administered.id, user_id: member.id, role: 'admin' },
211+
{ project_id: read.id, user_id: member.id, role: 'reader' },
212+
{ project_id: queued.id, user_id: member.id, role: 'collaborator' },
213+
{ project_id: started.id, user_id: member.id, role: 'admin' },
214+
]);
215+
const later = sql`now() + interval '1 hour'`;
216+
await env.db.insert(projectCleanupJobs).values([
217+
{
218+
target: proxyTokenRotationTarget(queued.id),
219+
project_id: queued.id,
220+
kind: 'proxy-token-rotation',
221+
payload: { phase: 'requested', reason: 'member-role-changed' },
222+
next_attempt_at: later,
223+
},
224+
{
225+
target: proxyTokenRotationTarget(started.id),
226+
project_id: started.id,
227+
kind: 'proxy-token-rotation',
228+
payload: { phase: 'patch', reason: 'operator', gen: '1', cursor: '' },
229+
attempts: 2,
230+
next_attempt_at: later,
231+
},
232+
]);
233+
const before = new Map(
234+
(await env.db.select().from(projectCleanupJobs)).map((job) => [job.project_id, job]),
235+
);
236+
237+
const { queries } = await captureQueries(env.db, () =>
238+
suspendUser(env.db, member.id, 'admin_suspended'),
239+
);
240+
241+
const jobs = new Map(
242+
(await env.db.select().from(projectCleanupJobs)).map((job) => [job.project_id, job]),
243+
);
244+
expect([...jobs.keys()].sort()).toEqual(
245+
[collaborated.id, administered.id, queued.id, started.id].sort(),
246+
);
247+
for (const projectId of [collaborated.id, administered.id]) {
248+
expect(jobs.get(projectId)).toMatchObject({
249+
target: proxyTokenRotationTarget(projectId),
250+
kind: 'proxy-token-rotation',
251+
payload: { phase: 'requested', reason: 'user-suspended' },
252+
attempts: 0,
253+
last_error: null,
254+
});
255+
}
256+
// A job that has not started covers the request as it is.
257+
expect(jobs.get(queued.id)).toEqual(before.get(queued.id));
258+
// A started job finishes and runs once more, on its own schedule.
259+
const startedBefore = before.get(started.id)!;
260+
expect(jobs.get(started.id)).toEqual({
261+
...startedBefore,
262+
payload: { ...startedBefore.payload, again: '1' },
263+
});
264+
expect(
265+
queries.filter((query) => /^insert into "project_cleanup_jobs"/i.test(query.trim())),
266+
).toHaveLength(1);
267+
});
268+
199269
it('atomically discovers and locks pending-invitation projects before user deletion', async () => {
200270
const owner = await createTestUser(env.db, 'owner');
201271
const invitee = await createTestUser(env.db, 'invitee');

0 commit comments

Comments
 (0)