Skip to content

Commit 35f380f

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): require session-write and task-trigger access
Session creation requires both session-write and task-trigger permissions. Cached sessions are checked against their stored task before refreshing their configuration or starting a run. The API key scope preview now displays session and tagged-run access. Mono-RevId: a138ef996f92a325c335c061f1b46e7f67bbca4a
1 parent cd51868 commit 35f380f

6 files changed

Lines changed: 160 additions & 37 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: breaking
4+
---
5+
6+
Creating or resuming a session with `POST /api/v1/sessions` now requires both session-write and task-trigger permissions. Scoped keys and public tokens must grant `write:sessions` (or access to the target session) and permission to trigger its task.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,16 @@ const API_KEY_EXPIRATIONS = [
889889
{ value: "never", label: "Never" },
890890
];
891891

892-
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";
892+
type CapId =
893+
| "tasks"
894+
| "runs"
895+
| "batches"
896+
| "queues"
897+
| "sessions"
898+
| "tags"
899+
| "deployments"
900+
| "branches"
901+
| "envvars";
893902

894903
// Capability rows shown in the scope pane, in a fixed order so two presets read
895904
// as a diff of the same list rather than a reshuffled one.
@@ -898,6 +907,8 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [
898907
["runs", "Runs"],
899908
["batches", "Batches"],
900909
["queues", "Queues"],
910+
["sessions", "Sessions (all tasks)"],
911+
["tags", "Tagged runs"],
901912
["deployments", "Deployments"],
902913
["branches", "Preview branches"],
903914
["envvars", "Environment variables"],
@@ -934,6 +945,9 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
934945
"write:batch": ["batches", 2],
935946
"read:queues": ["queues", 1],
936947
"write:queues": ["queues", 2],
948+
"read:sessions": ["sessions", 1],
949+
"write:sessions": ["sessions", 2],
950+
"read:tags": ["tags", 1],
937951
"read:deployments": ["deployments", 1],
938952
"write:deployments": ["deployments", 2],
939953
"write:branches": ["branches", 3],

‎apps/webapp/app/routes/api.v1.sessions.ts‎

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type SessionItem,
88
type SessionStatus,
99
} from "@trigger.dev/core/v3";
10-
import type { Session } from "@trigger.dev/database";
10+
import type { Prisma, Session } from "@trigger.dev/database";
1111
import { $replica, prisma, type PrismaClient } from "~/db.server";
1212
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
1313
import { logger } from "~/services/logger.server";
@@ -32,7 +32,10 @@ import {
3232
createLoaderApiRoute,
3333
everyResource,
3434
} from "~/services/routeBuilders/apiBuilder.server";
35-
import { recordSessionCreateAuthorization } from "~/services/sessionAuthorizationTelemetry.server";
35+
import {
36+
recordSessionCreateAuthorization,
37+
sessionCreateAuthorizationOutcome,
38+
} from "~/services/sessionAuthorizationTelemetry.server";
3639
import { ServiceValidationError } from "~/v3/services/common.server";
3740
import { runStore } from "~/v3/runStore.server";
3841

@@ -148,26 +151,15 @@ const { action } = createActionApiRoute(
148151
// browser uses thereafter against `.in/append`, `.out` SSE,
149152
// `end-and-continue`, etc.
150153
//
151-
// JWT is allowed when the caller holds an explicit `write:sessions` /
152-
// `admin` super-scope plus a `tasks:<taskIdentifier>` scope — gates
153-
// server-side surfaces like the cli-v3 MCP from creating sessions on
154-
// behalf of the developer without weakening the browser model.
154+
// Creating a session requires session-write AND task-trigger permissions.
155155
allowJWT: true,
156156
authorization: {
157-
// Per-task scoping via `body.taskIdentifier` (action-route resource
158-
// callbacks receive the parsed body as the 4th arg — see
159-
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
160-
// can only create sessions whose `taskIdentifier` is `"foo"`.
161-
//
162-
// Multi-key resource: pre-RBAC this route had a `superScopes:
163-
// ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
164-
// is the `{ type: "sessions" }` element below — a `write:sessions`
165-
// JWT (no id) matches it directly, deliberately bypassing the
166-
// per-task check exactly as before. `admin` / `write:all` bypass
167-
// via the JWT ability's wildcard branches.
168-
action: "write",
169-
resource: (_params, _searchParams, _headers, body) =>
170-
anyResource([{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }]),
157+
// Session-write is checked below after resolving alternate session IDs.
158+
action: "trigger",
159+
resource: (_params, _searchParams, _headers, body) => ({
160+
type: "tasks",
161+
id: body.taskIdentifier,
162+
}),
171163
},
172164
corsStrategy: "all",
173165
},
@@ -182,8 +174,25 @@ const { action } = createActionApiRoute(
182174
);
183175
}
184176

185-
// Idempotent on (env, externalId): two concurrent POSTs converge to the same row, and
186-
// `triggerConfig` is refreshed on the cached path so a redeployed config reaches the next run.
177+
const sessionIds = body.externalId ? [body.externalId] : [];
178+
if (body.externalId && !ability.can("write", { type: "sessions", id: body.externalId })) {
179+
const existing = await prisma.session.findFirst({
180+
where: {
181+
runtimeEnvironmentId: authentication.environment.id,
182+
externalId: body.externalId,
183+
},
184+
select: { friendlyId: true },
185+
});
186+
if (existing) sessionIds.push(existing.friendlyId);
187+
}
188+
if (
189+
sessionCreateAuthorizationOutcome(ability, body.taskIdentifier, sessionIds) !==
190+
"both_allowed"
191+
) {
192+
return json({ error: "Unauthorized" }, { status: 403 });
193+
}
194+
195+
// Defer cached config changes until the stored task has been authorized.
187196
const { session, isCached } = await findOrCreateSession({
188197
environment: authentication.environment,
189198
externalId: body.externalId,
@@ -193,6 +202,7 @@ const { action } = createActionApiRoute(
193202
tags: body.tags,
194203
metadata: body.metadata as Record<string, unknown> | undefined,
195204
expiresAt: body.expiresAt,
205+
refreshTriggerConfig: false,
196206
});
197207

198208
// Reject create on a closed session. The upsert path will return
@@ -220,6 +230,26 @@ const { action } = createActionApiRoute(
220230
}
221231

222232
recordSessionCreateAuthorization(ability, session, request, authentication.environment);
233+
if (
234+
sessionCreateAuthorizationOutcome(
235+
ability,
236+
session.taskIdentifier,
237+
[session.friendlyId, session.externalId].filter((id): id is string => !!id)
238+
) !== "both_allowed"
239+
) {
240+
return json({ error: "Unauthorized" }, { status: 403 });
241+
}
242+
243+
if (isCached) {
244+
Object.assign(
245+
session,
246+
await prisma.session.update({
247+
where: { id: session.id },
248+
data: { triggerConfig: body.triggerConfig as unknown as Prisma.InputJsonValue },
249+
select: { triggerConfig: true, updatedAt: true },
250+
})
251+
);
252+
}
223253

224254
// Session is task-bound — every session has a live run by
225255
// construction. `ensureRunForSession` is idempotent: on the

‎apps/webapp/app/services/sessionAuthorizationTelemetry.server.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ describe("session creation authorization observation", () => {
2929
).toBe(expected);
3030
});
3131

32+
it.each(["chat-1", "session_123"])(
33+
"requires a matching task even with write access to session %s",
34+
(sessionId) => {
35+
const ability = withActionAliases(
36+
buildJwtAbility([`write:sessions:${sessionId}`, "trigger:tasks:chat"])
37+
);
38+
expect(sessionCreateAuthorizationOutcome(ability, "chat", [sessionId])).toBe("both_allowed");
39+
expect(sessionCreateAuthorizationOutcome(ability, "other", [sessionId])).toBe(
40+
"missing_task_trigger"
41+
);
42+
expect(sessionCreateAuthorizationOutcome(ability, "chat", ["other-session"])).toBe(
43+
"missing_session_write"
44+
);
45+
}
46+
);
47+
3248
it("attributes only would-deny events without exporting the credential or task", async () => {
3349
const exporter = new InMemorySpanExporter();
3450
const provider = new BasicTracerProvider({

‎apps/webapp/app/services/sessionAuthorizationTelemetry.server.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ const checks = singleton("sessionCreateAuthorizationChecks", () =>
1313
})
1414
);
1515

16-
export function sessionCreateAuthorizationOutcome(ability: RbacAbility, taskIdentifier: string) {
17-
const sessionWrite = ability.can("write", { type: "sessions" });
16+
export function sessionCreateAuthorizationOutcome(
17+
ability: RbacAbility,
18+
taskIdentifier: string,
19+
sessionIds: string[] = []
20+
) {
21+
const sessionWrite =
22+
ability.can("write", { type: "sessions" }) ||
23+
sessionIds.some((id) => ability.can("write", { type: "sessions", id }));
1824
const taskTrigger = ability.can("trigger", { type: "tasks", id: taskIdentifier });
1925

2026
if (sessionWrite && taskTrigger) return "both_allowed";
@@ -24,7 +30,7 @@ export function sessionCreateAuthorizationOutcome(ability: RbacAbility, taskIden
2430

2531
export function recordSessionCreateAuthorization(
2632
ability: RbacAbility,
27-
session: { taskIdentifier: string },
33+
session: { taskIdentifier: string; friendlyId?: string; externalId?: string | null },
2834
request: Request,
2935
environment: Pick<AuthenticatedEnvironment, "id" | "organizationId" | "projectId" | "type">
3036
) {
@@ -42,7 +48,11 @@ export function recordSessionCreateAuthorization(
4248
: "unknown";
4349

4450
try {
45-
const outcome = sessionCreateAuthorizationOutcome(ability, session.taskIdentifier);
51+
const outcome = sessionCreateAuthorizationOutcome(
52+
ability,
53+
session.taskIdentifier,
54+
[session.friendlyId, session.externalId].filter((id): id is string => !!id)
55+
);
4656
checks.add(1, { credential_kind: credentialKind, outcome });
4757
if (outcome === "both_allowed") return;
4858

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

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,12 +2555,11 @@ describe("API", () => {
25552555

25562556
// ---- Create session: POST /api/v1/sessions
25572557
//
2558-
// Resource: [{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }]
2559-
// Old superScopes: ["write:sessions", "admin"]
2558+
// Session-write AND permission to trigger the requested and resolved tasks.
25602559
describe("Create session — POST /api/v1/sessions", () => {
25612560
const path = "/api/v1/sessions";
25622561

2563-
const post = async (jwt: string, taskIdentifier: string) =>
2562+
const post = async (jwt: string, taskIdentifier: string, externalId?: string) =>
25642563
getTestServer().webapp.fetch(path, {
25652564
method: "POST",
25662565
headers: {
@@ -2570,6 +2569,7 @@ describe("API", () => {
25702569
body: JSON.stringify({
25712570
type: "chat.agent",
25722571
taskIdentifier,
2572+
externalId,
25732573
triggerConfig: { basePayload: {} },
25742574
}),
25752575
});
@@ -2581,15 +2581,11 @@ describe("API", () => {
25812581
expirationTime: "15m",
25822582
});
25832583

2584-
it("write:tasks:foo matching body: auth passes", async () => {
2584+
it("write:tasks:foo without session-write: 403", async () => {
25852585
const seed = await seedTestEnvironment(getTestServer().prisma);
25862586
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:tasks:foo"]);
25872587
const res = await post(jwt, "foo");
2588-
// Body validation / handler can fail later (404 if task is
2589-
// missing, 400 for invalid body) — we only care that auth
2590-
// didn't reject.
2591-
expect(res.status).not.toBe(401);
2592-
expect(res.status).not.toBe(403);
2588+
expect(res.status).toBe(403);
25932589
});
25942590

25952591
it("write:tasks:bar mismatching body: 403", async () => {
@@ -2599,14 +2595,65 @@ describe("API", () => {
25992595
expect(res.status).toBe(403);
26002596
});
26012597

2602-
it("write:sessions: auth passes (was a superScope)", async () => {
2598+
it("write:sessions without task-trigger: 403", async () => {
26032599
const seed = await seedTestEnvironment(getTestServer().prisma);
26042600
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
26052601
const res = await post(jwt, "foo");
2602+
expect(res.status).toBe(403);
2603+
});
2604+
2605+
it("session-write and matching task-trigger: auth passes", async () => {
2606+
const seed = await seedTestEnvironment(getTestServer().prisma);
2607+
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
2608+
"write:sessions",
2609+
"trigger:tasks:foo",
2610+
]);
2611+
const res = await post(jwt, "foo");
26062612
expect(res.status).not.toBe(401);
26072613
expect(res.status).not.toBe(403);
26082614
});
26092615

2616+
it.each(["foo", "other-task"])(
2617+
"accepts a cached session's friendly-ID grant only with access to its task %s",
2618+
async (taskIdentifier) => {
2619+
const server = getTestServer();
2620+
const seed = await seedTestEnvironment(server.prisma);
2621+
const session = await seedTestApiSession(server.prisma, seed.environment, {
2622+
taskIdentifier,
2623+
});
2624+
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
2625+
`write:sessions:${session.friendlyId}`,
2626+
"trigger:tasks:foo",
2627+
]);
2628+
const res = await post(jwt, "foo", session.externalId!);
2629+
if (taskIdentifier === "foo") {
2630+
expect(res.status).not.toBe(401);
2631+
expect(res.status).not.toBe(403);
2632+
} else {
2633+
expect(res.status).toBe(403);
2634+
const unchanged = await server.prisma.session.findFirst({ where: { id: session.id } });
2635+
expect(unchanged?.triggerConfig).toEqual(session.triggerConfig);
2636+
}
2637+
}
2638+
);
2639+
2640+
it("checks the cached task before changing its config or triggering it", async () => {
2641+
const server = getTestServer();
2642+
const seed = await seedTestEnvironment(server.prisma);
2643+
const session = await seedTestApiSession(server.prisma, seed.environment, {
2644+
taskIdentifier: "other-task",
2645+
});
2646+
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
2647+
"write:sessions",
2648+
"trigger:tasks:foo",
2649+
]);
2650+
const res = await post(jwt, "foo", session.externalId!);
2651+
expect(res.status).toBe(403);
2652+
const unchanged = await server.prisma.session.findFirst({ where: { id: session.id } });
2653+
expect(unchanged?.triggerConfig).toEqual(session.triggerConfig);
2654+
expect(unchanged?.currentRunId).toBe(session.currentRunId);
2655+
});
2656+
26102657
it("write:all: auth passes", async () => {
26112658
const seed = await seedTestEnvironment(getTestServer().prisma);
26122659
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);

0 commit comments

Comments
 (0)