Skip to content

Commit 385fe3c

Browse files
committed
RBAC: resource-scoped JWT e2e coverage (TRI-8716 follow-up)
Close the resource-scoped JWT coverage gap before TRI-8719 swaps apiBuilder to rbac.authenticateBearer. Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT, resource: { waitpoints: params.waitpointFriendlyId }, superScopes: [write:waitpoints, admin]. New helper test/helpers/seedTestWaitpoint.ts seeds a Waitpoint in COMPLETED status so the handler short-circuits once auth passes, keeping the 200 assertion independent of run-engine workers. 7 new tests exercise the legacy checkAuthorization scope algebra that the migration must preserve: - scope matches exact resource id → 200 - scope targets a different id of the same type → 403 - type-level scope (no id) grants all resources of that type → 200 - read-only scope on a write route → 403 - scope targets a different resource type → 403 - admin super-scope → 200 (legacy super-scope listing) - unrelated type scope with no super-scope match → 403 Without these, the only JWT coverage was coarse type-level allow/deny against routes whose resource callbacks returned () => 1 or () => ({}), leaving resource-id matching entirely untested end-to-end.
1 parent 8a3c5b6 commit 385fe3c

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

apps/webapp/test/api-auth.e2e.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { startTestServer } from "@internal/testcontainers/webapp";
1212
import { generateJWT } from "@trigger.dev/core/v3/jwt";
1313
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
1414
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
15+
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
1516

1617
vi.setConfig({ testTimeout: 180_000 });
1718

@@ -257,6 +258,92 @@ describe("Personal access token auth", () => {
257258
});
258259
});
259260

261+
// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource.
262+
// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT: true,
263+
// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
264+
// superScopes: ["write:waitpoints", "admin"] }.
265+
//
266+
// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with
267+
// { success: true } once auth passes — no run-engine worker needed. "Auth passes" is
268+
// observable as a 200 response; "auth fails" is observable as a 403.
269+
describe("JWT bearer auth — resource-scoped scopes", () => {
270+
const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
271+
272+
async function seedEnvAndWaitpoint() {
273+
const seed = await seedTestEnvironment(server.prisma);
274+
const waitpoint = await seedTestWaitpoint(server.prisma, {
275+
environmentId: seed.environment.id,
276+
projectId: seed.project.id,
277+
});
278+
return { ...seed, waitpoint };
279+
}
280+
281+
async function completeRequest(friendlyId: string, jwt: string) {
282+
return server.webapp.fetch(pathFor(friendlyId), {
283+
method: "POST",
284+
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
285+
body: JSON.stringify({}),
286+
});
287+
}
288+
289+
it("scope matches exact resource id: 200", async () => {
290+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
291+
const jwt = await generateTestJWT(environment, {
292+
scopes: [`write:waitpoints:${waitpoint.friendlyId}`],
293+
});
294+
const res = await completeRequest(waitpoint.friendlyId, jwt);
295+
expect(res.status).toBe(200);
296+
});
297+
298+
it("scope targets a different resource id: 403", async () => {
299+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
300+
const jwt = await generateTestJWT(environment, {
301+
scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"],
302+
});
303+
const res = await completeRequest(waitpoint.friendlyId, jwt);
304+
expect(res.status).toBe(403);
305+
});
306+
307+
it("type-level scope (no id) grants all resources of that type: 200", async () => {
308+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
309+
const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] });
310+
const res = await completeRequest(waitpoint.friendlyId, jwt);
311+
expect(res.status).toBe(200);
312+
});
313+
314+
it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => {
315+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
316+
const jwt = await generateTestJWT(environment, {
317+
scopes: [`read:waitpoints:${waitpoint.friendlyId}`],
318+
});
319+
const res = await completeRequest(waitpoint.friendlyId, jwt);
320+
expect(res.status).toBe(403);
321+
});
322+
323+
it("scope targets a different resource type: 403", async () => {
324+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
325+
const jwt = await generateTestJWT(environment, {
326+
scopes: ["write:runs:run_abc000000000000000000000"],
327+
});
328+
const res = await completeRequest(waitpoint.friendlyId, jwt);
329+
expect(res.status).toBe(403);
330+
});
331+
332+
it("admin super-scope grants access (legacy behaviour): 200", async () => {
333+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
334+
const jwt = await generateTestJWT(environment, { scopes: ["admin"] });
335+
const res = await completeRequest(waitpoint.friendlyId, jwt);
336+
expect(res.status).toBe(200);
337+
});
338+
339+
it("unrelated type scope with no super-scope match: 403", async () => {
340+
const { environment, waitpoint } = await seedEnvAndWaitpoint();
341+
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
342+
const res = await completeRequest(waitpoint.friendlyId, jwt);
343+
expect(res.status).toBe(403);
344+
});
345+
});
346+
260347
// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
261348
describe("API bearer auth — environment/project edge cases", () => {
262349
it("valid API key whose project is soft-deleted: 401", async () => {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { customAlphabet } from "nanoid";
3+
4+
// Must match friendlyId.ts IdUtil alphabet so generated IDs are valid.
5+
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
6+
7+
// Seeds a Waitpoint already in COMPLETED status so the waitpoints/:id/complete
8+
// handler short-circuits with { success: true }. That keeps the "auth passes"
9+
// assertion independent of run-engine workers (which are disabled in e2e).
10+
export async function seedTestWaitpoint(
11+
prisma: PrismaClient,
12+
opts: { environmentId: string; projectId: string }
13+
): Promise<{ id: string; friendlyId: string }> {
14+
const internalId = idGenerator();
15+
const friendlyId = `waitpoint_${internalId}`;
16+
await prisma.waitpoint.create({
17+
data: {
18+
id: internalId,
19+
friendlyId,
20+
type: "MANUAL",
21+
status: "COMPLETED",
22+
idempotencyKey: internalId,
23+
userProvidedIdempotencyKey: false,
24+
environmentId: opts.environmentId,
25+
projectId: opts.projectId,
26+
},
27+
});
28+
return { id: internalId, friendlyId };
29+
}

0 commit comments

Comments
 (0)