Skip to content

Commit 7d7adeb

Browse files
committed
test(webapp): pin every tool's API against the scope its route authorizes on
1 parent b5c3788 commit 7d7adeb

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

apps/webapp/app/services/dashboardAgent.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const TASK_ID = "dashboard-agent";
1313
// what lets it exchange the token for an env JWT (the gate on the exchange
1414
// route); the rest scope the actual reads. No write/admin scopes, so even a
1515
// leaked token can't mutate anything.
16-
const DASHBOARD_AGENT_UAT_CAP = [
16+
export const DASHBOARD_AGENT_UAT_CAP = [
1717
"read:apiKeys",
1818
"read:runs",
1919
"read:deployments",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { buildJwtAbility } from "@trigger.dev/rbac";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "secret" } }));
5+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
6+
7+
import { DASHBOARD_AGENT_UAT_CAP } from "~/services/dashboardAgent.server";
8+
9+
/**
10+
* Every API the agent's tools call, against the resource that route authorizes on. A tool
11+
* whose route needs a resource the cap doesn't carry fails with a 403 the model cannot
12+
* see as a permission problem — it reads as missing data, which is how `get_queue` came to
13+
* report a queue of 4800 runs as non-existent.
14+
*/
15+
const TOOL_READS: { tool: string; path: string; resource: { type: string; id?: string } }[] = [
16+
{ tool: "list_runs", path: "/api/v1/runs", resource: { type: "runs" } },
17+
{ tool: "get_run_trace", path: "/api/v1/runs/:id/trace", resource: { type: "runs" } },
18+
{ tool: "list_errors", path: "/api/v1/errors", resource: { type: "errors" } },
19+
{ tool: "get_error", path: "/api/v1/errors/:id", resource: { type: "errors" } },
20+
{ tool: "list_deploys", path: "/api/v1/deployments", resource: { type: "deployments" } },
21+
{ tool: "get_deploy", path: "/api/v1/deployments/current", resource: { type: "deployments" } },
22+
{
23+
tool: "list_environments",
24+
path: "/api/v1/projects/:ref/environments",
25+
resource: { type: "environments" },
26+
},
27+
{
28+
tool: "get_query_schema",
29+
path: "/api/v1/query/schema",
30+
resource: { type: "query", id: "schema" },
31+
},
32+
{ tool: "run_query", path: "/api/v1/query", resource: { type: "query", id: "runs" } },
33+
{
34+
tool: "get_queue (metrics)",
35+
path: "/api/v1/queues/:name/metrics",
36+
resource: { type: "query", id: "queue_metrics" },
37+
},
38+
{ tool: "get_queue (live row)", path: "/api/v1/queues/:name", resource: { type: "queues" } },
39+
{
40+
tool: "get_report",
41+
path: "/api/v1/reports/:key",
42+
resource: { type: "query", id: "env_metrics" },
43+
},
44+
{
45+
tool: "repo snapshot",
46+
path: "/api/v1/projects/:ref/:env/repo/snapshot",
47+
resource: { type: "apiKeys" },
48+
},
49+
];
50+
51+
describe("the agent's token can do what its tools ask", () => {
52+
const ability = buildJwtAbility(DASHBOARD_AGENT_UAT_CAP);
53+
54+
it.each(TOOL_READS)("$tool reads $path", ({ resource }) => {
55+
expect(ability.can("read", resource)).toBe(true);
56+
});
57+
58+
it("stays read-only", () => {
59+
expect(DASHBOARD_AGENT_UAT_CAP.every((scope) => scope.startsWith("read:"))).toBe(true);
60+
expect(ability.can("write", { type: "runs" })).toBe(false);
61+
expect(ability.can("trigger", { type: "tasks" })).toBe(false);
62+
expect(ability.canSuper()).toBe(false);
63+
});
64+
});

0 commit comments

Comments
 (0)