Skip to content

Commit 85b98ab

Browse files
committed
fix(webapp): mint the agent's token for the caller's own environment, or not at all
1 parent 62b598d commit 85b98ab

4 files changed

Lines changed: 51 additions & 21 deletions

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "~/components/dashboard-agent/message-limits";
1010
import { $replica } from "~/db.server";
1111
import { findProjectBySlug } from "~/models/project.server";
12+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1213
import {
1314
dashboardAgentApiOrigin,
1415
dashboardAgentEnvironmentName,
@@ -69,12 +70,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
6970
const url = new URL(request.url);
7071
const upstreamUrl = `${apiOrigin.replace(/\/$/, "")}/${upstreamPath}${url.search}`;
7172

72-
// Resolve the dashboard env slug to the canonical API env name its tools use.
73-
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
74-
where: { projectId: project.id, slug: envParam },
75-
select: { id: true, type: true },
76-
});
77-
const environmentName = dashboardAgentEnvironmentName(runtimeEnv?.type);
73+
// Membership-scoped: `(projectId, slug)` is not unique because every developer has their own
74+
// dev row, and a token must never be minted for someone else's environment — or for none.
75+
const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, user.id);
76+
if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 });
77+
const environmentName = dashboardAgentEnvironmentName(runtimeEnv.type);
7878

7979
// Null without a connected GitHub repo, and the agent stays in assistant mode.
8080
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
@@ -106,13 +106,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
106106
parsed.payload.metadata = {
107107
...(parsed.payload.metadata ?? {}),
108108
userActorToken: await mintDashboardAgentUserActorToken(user.id, {
109-
environmentId: runtimeEnv?.id,
109+
environmentId: runtimeEnv.id,
110110
}),
111111
apiOrigin,
112112
projectRef: project.externalRef,
113+
// Server-owned: the browser sends these too, and the eval opt-out and every tenancy
114+
// check key on them, so the client's copy must never win.
115+
organizationId: project.organizationId,
116+
userId: user.id,
113117
// `(projectId, slug)` isn't unique (dev is per-member), so anything addressing
114118
// one environment row uses this id. `environmentName` is for name-addressed tools.
115-
environmentId: runtimeEnv?.id,
119+
environmentId: runtimeEnv.id,
116120
environmentName,
117121
...(repoSnapshot ? { repoSnapshot } : {}),
118122
};

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
294294
...(clientData ? { metadata: { context: clientData } } : {}),
295295
});
296296

297-
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
298-
where: { projectId: project.id, slug: envParam },
299-
select: { id: true, type: true },
300-
});
301-
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
297+
// Membership-scoped: dev rows are per-developer, so a token must never be minted for
298+
// someone else's environment — or, when nothing resolves, for no environment at all.
299+
const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, userId);
300+
if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 });
301+
const environmentName = ENV_NAME_BY_TYPE[runtimeEnv.type];
302302
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
303303

304304
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
@@ -313,12 +313,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
313313
// per-turn clientData must accompany the injected auth and context fields.
314314
...(clientData ?? {}),
315315
userActorToken: await mintDashboardAgentUserActorToken(userId, {
316-
environmentId: runtimeEnv?.id,
316+
environmentId: runtimeEnv.id,
317317
}),
318318
apiOrigin: dashboardAgentApiOrigin(),
319319
projectRef: project.externalRef,
320+
// Server-owned, like the `in` proxy: the eval opt-out and every tenancy check
321+
// key on these, so a client-sent copy must not win.
322+
organizationId: project.organizationId,
323+
userId,
320324
// Same environment identity the `in` proxy injects.
321-
environmentId: runtimeEnv?.id,
325+
environmentId: runtimeEnv.id,
322326
environmentName,
323327
...(repoSnapshot ? { repoSnapshot } : {}),
324328
},

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,30 @@ import { type UserActorClaims } from "@trigger.dev/rbac";
44
/** The code returned when a token's environment scope doesn't cover the requested environment. */
55
export const FORBIDDEN_ENVIRONMENT_CODE = "forbidden_environment";
66

7+
/** The agent always mints per-environment, so a claimless token of its own is a bug, not a flow. */
8+
const DASHBOARD_AGENT_CLIENT = "dashboard-agent";
9+
710
/**
811
* A user-actor token is signed for one environment. Honouring it against another environment
912
* would break that signed scope, widening a leaked token to every environment its user can reach.
1013
*
11-
* Throws a 403 Response on mismatch. A caller with no `environmentId` claim (a PAT, an org token,
12-
* or an environment-agnostic UAT flow) is unaffected.
14+
* Throws a 403 Response on mismatch. A PAT, an org token or an environment-agnostic UAT flow
15+
* (the public PAT exchange, used by MCP and the CLI) carries no claim and is unaffected — but a
16+
* dashboard-agent token without one is refused, so a mint that failed to resolve an environment
17+
* cannot produce a token that passes every gate.
1318
*/
1419
export function assertUserActorEnvironment(
1520
userActor: UserActorClaims | undefined,
1621
environmentId: string
1722
): void {
18-
if (!userActor?.environmentId) return;
23+
if (!userActor) return;
24+
if (!userActor.environmentId) {
25+
if (userActor.client !== DASHBOARD_AGENT_CLIENT) return;
26+
throw json(
27+
{ error: "This token isn't scoped to an environment.", code: FORBIDDEN_ENVIRONMENT_CODE },
28+
{ status: 403 }
29+
);
30+
}
1931
if (userActor.environmentId === environmentId) return;
2032

2133
throw json(

apps/webapp/test/uatEnvironmentClaim.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,10 @@ const ENV_A = environment("env_aaaa", "prod", "PRODUCTION");
116116
const ENV_B = environment("env_bbbb", "stg", "STAGING");
117117
const ENVIRONMENTS = [ENV_A, ENV_B];
118118

119-
function mintToken(opts: { environmentId?: string } = {}) {
119+
function mintToken(opts: { environmentId?: string; client?: string } = {}) {
120120
return signUserActorToken(SESSION_SECRET, {
121121
userId: USER_ID,
122-
client: "dashboard-agent",
122+
client: opts.client ?? "dashboard-agent",
123123
...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
124124
cap: ["read:apiKeys", "read:runs", "read:deployments"],
125125
});
@@ -251,11 +251,21 @@ describe("user-actor token environment scope", () => {
251251
expect(response.status).toBe(200);
252252
});
253253

254-
it("allows an environment-agnostic token (no claim)", async () => {
254+
it("refuses an agent token that carries no environment claim", async () => {
255+
// A mint that couldn't resolve an environment must not produce a token that passes
256+
// every gate, so a claimless agent token is a bug rather than a flow.
255257
const token = await mintToken();
256258

257259
const response = await call(token, "staging");
258260

261+
expect(response.status).toBe(403);
262+
});
263+
264+
it("allows an environment-agnostic token from another client", async () => {
265+
const token = await mintToken({ client: "personal-access-token" });
266+
267+
const response = await call(token, "staging");
268+
259269
expect(response.status).toBe(200);
260270
});
261271
});

0 commit comments

Comments
 (0)