Skip to content

Commit 27f0533

Browse files
committed
RBAC tests: run resource routes — multi-key (TRI-8734)
Every read-side $runId route computes its authorization resource from the loaded TaskRun: [ { type: "runs", id: run.friendlyId }, { type: "tasks", id: run.taskIdentifier }, ...run.runTags.map(tag => ({ type: "tags", id: tag })), run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId }, ] A JWT scope matching ANY array element grants access. Tests target GET /api/v3/runs/:runId as the canonical route with the full matrix (13 cases), plus a sanity check on /api/v1/runs/:runId/events to confirm the wiring isn't route-local. api.v3.runs.$runId — 13 cases: - missing auth → 401 - invalid API key → 401 - private API key → auth passes - JWT read:runs (type-level) → passes - JWT read:runs:<exact friendlyId> → passes - JWT read:runs:<other> → 403 - JWT read:tags:<tag in run.runTags> → passes (array element match) - JWT read:tags:<tag NOT in runTags> → 403 - JWT read:batch:<run.batch.friendlyId> → passes - JWT read:batch:<other> → 403 - JWT read:tasks:<run.taskIdentifier> → passes - JWT read:all → passes - JWT admin → passes - JWT write:runs:<friendlyId> → 403 (action mismatch — read route) - cross-env: env A's JWT cannot read env B's run → not 200 api.v1.runs.$runId.events — 2-case sanity (missing auth, read:runs). If a route in this family ever diverges from the canonical pattern, add a dedicated describe. Reuses seedTestRun({ withBatch, runTags }) — already in the helper shipped with TRI-8716. No new fixtures. Verification: typecheck clean. Test execution still blocked by the e2e.full webapp-boot issue noted on TRI-8731.
1 parent 47d25c1 commit 27f0533

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

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

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,4 +1284,259 @@ describe("API", () => {
12841284
expect(res.status).not.toBe(403);
12851285
});
12861286
});
1287+
1288+
// Run resource routes (TRI-8734). Every read-side `$runId` route
1289+
// computes its authorization resource from the loaded TaskRun:
1290+
// [
1291+
// { type: "runs", id: run.friendlyId },
1292+
// { type: "tasks", id: run.taskIdentifier },
1293+
// ...run.runTags.map(tag => ({ type: "tags", id: tag })),
1294+
// run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId },
1295+
// ]
1296+
//
1297+
// A JWT scope matching ANY array element grants access. We test the
1298+
// full matrix against the canonical route (api.v3.runs.$runId), and
1299+
// a sanity check on one of the others to confirm the wiring isn't
1300+
// route-local. If a future route's resource shape diverges, add a
1301+
// targeted describe.
1302+
describe("Run resource — GET /api/v3/runs/:runId (multi-key array)", () => {
1303+
const pathFor = (runId: string) => `/api/v3/runs/${runId}`;
1304+
1305+
async function seedRunWithBatchAndTags() {
1306+
const server = getTestServer();
1307+
const seed = await seedTestEnvironment(server.prisma);
1308+
const seeded = await seedTestRun(server.prisma, {
1309+
environmentId: seed.environment.id,
1310+
projectId: seed.project.id,
1311+
runTags: ["alpha", "beta"],
1312+
withBatch: true,
1313+
});
1314+
return { ...seed, ...seeded };
1315+
}
1316+
1317+
const get = (path: string, headers: Record<string, string>) =>
1318+
getTestServer().webapp.fetch(path, { headers });
1319+
1320+
it("missing auth: 401", async () => {
1321+
const res = await get(pathFor("run_anything"), {});
1322+
expect(res.status).toBe(401);
1323+
});
1324+
1325+
it("invalid API key: 401", async () => {
1326+
const res = await get(pathFor("run_anything"), {
1327+
Authorization: "Bearer tr_dev_invalid",
1328+
});
1329+
expect(res.status).toBe(401);
1330+
});
1331+
1332+
it("private API key on real run: auth passes", async () => {
1333+
const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags();
1334+
const res = await get(pathFor(runFriendlyId), {
1335+
Authorization: `Bearer ${apiKey}`,
1336+
});
1337+
expect(res.status).not.toBe(401);
1338+
expect(res.status).not.toBe(403);
1339+
});
1340+
1341+
it("JWT read:runs (type-level): auth passes", async () => {
1342+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1343+
const jwt = await generateJWT({
1344+
secretKey: apiKey,
1345+
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
1346+
expirationTime: "15m",
1347+
});
1348+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1349+
expect(res.status).not.toBe(401);
1350+
expect(res.status).not.toBe(403);
1351+
});
1352+
1353+
it("JWT read:runs:<exact friendlyId>: auth passes (id match)", async () => {
1354+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1355+
const jwt = await generateJWT({
1356+
secretKey: apiKey,
1357+
payload: {
1358+
pub: true,
1359+
sub: environment.id,
1360+
scopes: [`read:runs:${runFriendlyId}`],
1361+
},
1362+
expirationTime: "15m",
1363+
});
1364+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1365+
expect(res.status).not.toBe(401);
1366+
expect(res.status).not.toBe(403);
1367+
});
1368+
1369+
it("JWT read:runs:<other>: 403", async () => {
1370+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1371+
const jwt = await generateJWT({
1372+
secretKey: apiKey,
1373+
payload: {
1374+
pub: true,
1375+
sub: environment.id,
1376+
scopes: ["read:runs:run_someoneelse00000000000"],
1377+
},
1378+
expirationTime: "15m",
1379+
});
1380+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1381+
expect(res.status).toBe(403);
1382+
});
1383+
1384+
it("JWT read:tags:<tag the run has>: auth passes (array element match)", async () => {
1385+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1386+
// run was seeded with runTags=["alpha","beta"]; scope matches "alpha".
1387+
const jwt = await generateJWT({
1388+
secretKey: apiKey,
1389+
payload: { pub: true, sub: environment.id, scopes: ["read:tags:alpha"] },
1390+
expirationTime: "15m",
1391+
});
1392+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1393+
expect(res.status).not.toBe(401);
1394+
expect(res.status).not.toBe(403);
1395+
});
1396+
1397+
it("JWT read:tags:<tag the run does not have>: 403", async () => {
1398+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1399+
const jwt = await generateJWT({
1400+
secretKey: apiKey,
1401+
payload: { pub: true, sub: environment.id, scopes: ["read:tags:gamma"] },
1402+
expirationTime: "15m",
1403+
});
1404+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1405+
expect(res.status).toBe(403);
1406+
});
1407+
1408+
it("JWT read:batch:<run's batchFriendlyId>: auth passes", async () => {
1409+
const { runFriendlyId, batchFriendlyId, apiKey, environment } =
1410+
await seedRunWithBatchAndTags();
1411+
const jwt = await generateJWT({
1412+
secretKey: apiKey,
1413+
payload: {
1414+
pub: true,
1415+
sub: environment.id,
1416+
scopes: [`read:batch:${batchFriendlyId}`],
1417+
},
1418+
expirationTime: "15m",
1419+
});
1420+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1421+
expect(res.status).not.toBe(401);
1422+
expect(res.status).not.toBe(403);
1423+
});
1424+
1425+
it("JWT read:batch:<other>: 403", async () => {
1426+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1427+
const jwt = await generateJWT({
1428+
secretKey: apiKey,
1429+
payload: {
1430+
pub: true,
1431+
sub: environment.id,
1432+
scopes: ["read:batch:batch_someoneelse00000000"],
1433+
},
1434+
expirationTime: "15m",
1435+
});
1436+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1437+
expect(res.status).toBe(403);
1438+
});
1439+
1440+
it("JWT read:tasks:<run's taskIdentifier>: auth passes", async () => {
1441+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1442+
// seedTestRun uses taskIdentifier "test-task" by default.
1443+
const jwt = await generateJWT({
1444+
secretKey: apiKey,
1445+
payload: { pub: true, sub: environment.id, scopes: ["read:tasks:test-task"] },
1446+
expirationTime: "15m",
1447+
});
1448+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1449+
expect(res.status).not.toBe(401);
1450+
expect(res.status).not.toBe(403);
1451+
});
1452+
1453+
it("JWT read:all: auth passes", async () => {
1454+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1455+
const jwt = await generateJWT({
1456+
secretKey: apiKey,
1457+
payload: { pub: true, sub: environment.id, scopes: ["read:all"] },
1458+
expirationTime: "15m",
1459+
});
1460+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1461+
expect(res.status).not.toBe(401);
1462+
expect(res.status).not.toBe(403);
1463+
});
1464+
1465+
it("JWT admin: auth passes", async () => {
1466+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1467+
const jwt = await generateJWT({
1468+
secretKey: apiKey,
1469+
payload: { pub: true, sub: environment.id, scopes: ["admin"] },
1470+
expirationTime: "15m",
1471+
});
1472+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1473+
expect(res.status).not.toBe(401);
1474+
expect(res.status).not.toBe(403);
1475+
});
1476+
1477+
it("JWT write:runs:<friendlyId>: 403 (action mismatch — read route)", async () => {
1478+
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
1479+
const jwt = await generateJWT({
1480+
secretKey: apiKey,
1481+
payload: {
1482+
pub: true,
1483+
sub: environment.id,
1484+
scopes: [`write:runs:${runFriendlyId}`],
1485+
},
1486+
expirationTime: "15m",
1487+
});
1488+
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
1489+
expect(res.status).toBe(403);
1490+
});
1491+
1492+
it("cross-env: env A's JWT cannot read env B's run: not 200", async () => {
1493+
const server = getTestServer();
1494+
const a = await seedTestEnvironment(server.prisma);
1495+
const b = await seedRunWithBatchAndTags();
1496+
const jwt = await generateJWT({
1497+
secretKey: a.apiKey,
1498+
payload: {
1499+
pub: true,
1500+
sub: a.environment.id,
1501+
scopes: [`read:runs:${b.runFriendlyId}`],
1502+
},
1503+
expirationTime: "15m",
1504+
});
1505+
const res = await get(pathFor(b.runFriendlyId), { Authorization: `Bearer ${jwt}` });
1506+
// Either auth fails or the run lookup misses (env A's view of
1507+
// the run doesn't include env B's data). Critical: NOT 200.
1508+
expect(res.status).not.toBe(200);
1509+
});
1510+
});
1511+
1512+
// Sanity check: same multi-key pattern wired the same way on the
1513+
// events sub-route. If this drifts in the future the divergence
1514+
// gets a dedicated describe.
1515+
describe("Run resource — GET /api/v1/runs/:runId/events (sanity)", () => {
1516+
const pathFor = (runId: string) => `/api/v1/runs/${runId}/events`;
1517+
1518+
it("missing auth: 401", async () => {
1519+
const res = await getTestServer().webapp.fetch(pathFor("run_anything"));
1520+
expect(res.status).toBe(401);
1521+
});
1522+
1523+
it("JWT read:runs (type-level): auth passes on a real run", async () => {
1524+
const server = getTestServer();
1525+
const seed = await seedTestEnvironment(server.prisma);
1526+
const { runFriendlyId } = await seedTestRun(server.prisma, {
1527+
environmentId: seed.environment.id,
1528+
projectId: seed.project.id,
1529+
});
1530+
const jwt = await generateJWT({
1531+
secretKey: seed.apiKey,
1532+
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
1533+
expirationTime: "15m",
1534+
});
1535+
const res = await getTestServer().webapp.fetch(pathFor(runFriendlyId), {
1536+
headers: { Authorization: `Bearer ${jwt}` },
1537+
});
1538+
expect(res.status).not.toBe(401);
1539+
expect(res.status).not.toBe(403);
1540+
});
1541+
});
12871542
});

0 commit comments

Comments
 (0)