Skip to content

Commit 399e4b1

Browse files
committed
RBAC: pre-migration JWT behaviour tests for TRI-8719 risks (TRI-8716)
Lock in the legacy checkAuthorization behaviours that TRI-8719 must preserve once it swaps in rbac.authenticateBearer + ability.can. Three tests in a new describe block 'JWT bearer auth — behaviours to preserve through TRI-8719': - Custom action route: type-level write:tasks JWT on POST /api/v1/tasks/:taskId/trigger (action: trigger) → auth passes today via exact superScope match. Must keep passing after TRI-8719 via the ACTION_ALIASES map (trigger ← write). - Multi-key resource: read:tags:<tag> JWT on /api/v1/runs/:runId/trace where the seeded run has that tag → auth passes today because legacy checks each resource key. Must keep passing after TRI-8719 via ability.can's array-resource form. - Multi-key resource: read:batch:<friendlyId> JWT on /api/v1/runs/:runId/trace where the seeded run is in that batch → same rationale as the tags case. Dropped the planned empty-resource test: researching it surfaced that legacy checkAuthorization denies empty-resource requests BEFORE the super-scope check runs, so api.v1.batches.ts and idempotencyKeys reset currently reject all JWTs despite allowJWT: true. TRI-8719's plan (adding explicit { type: 'runs' }) is an intentional improvement, not a preservation — documented in the TRI-8719 description comment. New helper test/helpers/seedTestRun.ts seeds a minimal TaskRun (and, optionally, an associated BatchTaskRun) that ApiRetrieveRunPresenter's findRun can resolve for multi-key resource tests. The tests only assert 'auth passes' (!== 401, !== 403) — the handler's downstream behaviour (which may fail in a worker-less test env) isn't relevant to the auth-layer contract.
1 parent 385fe3c commit 399e4b1

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

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

Lines changed: 65 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 { seedTestRun } from "./helpers/seedTestRun";
1516
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
1617

1718
vi.setConfig({ testTimeout: 180_000 });
@@ -344,6 +345,70 @@ describe("JWT bearer auth — resource-scoped scopes", () => {
344345
});
345346
});
346347

348+
// Pre-migration coverage for the three behavioural constraints captured in TRI-8719.
349+
// Each test locks in an observable current behaviour that the migration must preserve:
350+
// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes
351+
// - multi-key resource callbacks (runs/tags/batch/tasks) — any key match grants access
352+
// - empty resource callbacks relying on superScopes
353+
describe("JWT bearer auth — behaviours to preserve through TRI-8719", () => {
354+
it("custom action: type-level write:tasks scope satisfies action=\"trigger\" (auth passes)", async () => {
355+
const { environment } = await seedTestEnvironment(server.prisma);
356+
// Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks.
357+
// Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"].
358+
// After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is
359+
// satisfied by a scope whose action is write.
360+
const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] });
361+
const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", {
362+
method: "POST",
363+
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
364+
body: JSON.stringify({}),
365+
});
366+
expect(res.status).not.toBe(401);
367+
expect(res.status).not.toBe(403);
368+
});
369+
370+
it("multi-key resource: read:tags:<tag> scope grants access to a run carrying that tag (auth passes)", async () => {
371+
const { environment, project } = await seedTestEnvironment(server.prisma);
372+
const { runFriendlyId } = await seedTestRun(server.prisma, {
373+
environmentId: environment.id,
374+
projectId: project.id,
375+
runTags: ["my-resource-scoped-tag"],
376+
});
377+
const jwt = await generateTestJWT(environment, {
378+
scopes: ["read:tags:my-resource-scoped-tag"],
379+
});
380+
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
381+
headers: { Authorization: `Bearer ${jwt}` },
382+
});
383+
expect(res.status).not.toBe(401);
384+
expect(res.status).not.toBe(403);
385+
});
386+
387+
it("multi-key resource: read:batch:<friendlyId> scope grants access to a run in that batch (auth passes)", async () => {
388+
const { environment, project } = await seedTestEnvironment(server.prisma);
389+
const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, {
390+
environmentId: environment.id,
391+
projectId: project.id,
392+
withBatch: true,
393+
});
394+
const jwt = await generateTestJWT(environment, {
395+
scopes: [`read:batch:${batchFriendlyId}`],
396+
});
397+
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
398+
headers: { Authorization: `Bearer ${jwt}` },
399+
});
400+
expect(res.status).not.toBe(401);
401+
expect(res.status).not.toBe(403);
402+
});
403+
404+
// Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts)
405+
// currently DENY all JWTs because legacy checkAuthorization's empty-resource check
406+
// fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" }
407+
// changes this to "JWTs with read:runs or write:runs now work on these routes" — an
408+
// intentional improvement, not a preserved behaviour. See TRI-8719 description for
409+
// the note; there's nothing to lock in with a test here.
410+
});
411+
347412
// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
348413
describe("API bearer auth — environment/project edge cases", () => {
349414
it("valid API key whose project is soft-deleted: 401", async () => {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { PrismaClient, TaskRun } from "@trigger.dev/database";
2+
import { customAlphabet, nanoid } from "nanoid";
3+
4+
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
5+
6+
export interface SeededRun {
7+
run: TaskRun;
8+
runFriendlyId: string; // `run_...`
9+
batchFriendlyId?: string; // `batch_...` when { withBatch: true }
10+
}
11+
12+
// Minimum-viable TaskRun for auth-layer e2e tests — enough fields for
13+
// ApiRetrieveRunPresenter.findRun to return it and for the authorization.resource
14+
// callback to populate `runs`, `tags`, `batch`, `tasks` keys.
15+
export async function seedTestRun(
16+
prisma: PrismaClient,
17+
opts: {
18+
environmentId: string;
19+
projectId: string;
20+
runTags?: string[];
21+
withBatch?: boolean;
22+
}
23+
): Promise<SeededRun> {
24+
const runInternalId = idGenerator();
25+
const runFriendlyId = `run_${runInternalId}`;
26+
27+
let batchInternalId: string | undefined;
28+
if (opts.withBatch) {
29+
batchInternalId = idGenerator();
30+
await prisma.batchTaskRun.create({
31+
data: {
32+
id: batchInternalId,
33+
friendlyId: `batch_${batchInternalId}`,
34+
runtimeEnvironmentId: opts.environmentId,
35+
},
36+
});
37+
}
38+
39+
const run = await prisma.taskRun.create({
40+
data: {
41+
id: runInternalId,
42+
friendlyId: runFriendlyId,
43+
taskIdentifier: "test-task",
44+
payload: "{}",
45+
payloadType: "application/json",
46+
traceId: nanoid(32),
47+
spanId: nanoid(16),
48+
queue: "task/test-task",
49+
runtimeEnvironmentId: opts.environmentId,
50+
projectId: opts.projectId,
51+
runTags: opts.runTags ?? [],
52+
batchId: batchInternalId,
53+
},
54+
});
55+
56+
return {
57+
run,
58+
runFriendlyId,
59+
batchFriendlyId: batchInternalId ? `batch_${batchInternalId}` : undefined,
60+
};
61+
}

0 commit comments

Comments
 (0)