Skip to content

Commit f700174

Browse files
committed
fix(webapp): ceiling an exchanged environment JWT by what the delegated token can do
A user-actor token declaring no scope cap could mint an environment JWT with any scopes it asked for. The exchange now clamps the minted scopes to the actor's own ability, so a capless token is read-only, and only mints for its claimed environment.
1 parent a7fc406 commit f700174

2 files changed

Lines changed: 283 additions & 16 deletions

File tree

apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
import { logger } from "~/services/logger.server";
99
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
1010
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
11+
import { rbac } from "~/services/rbac.server";
12+
import {
13+
assertUserActorEnvironment,
14+
clampUserActorScopes,
15+
} from "~/services/userActorEnvironment.server";
1116

1217
const ParamsSchema = z.object({
1318
projectRef: z.string(),
@@ -34,8 +39,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
3439
}
3540

3641
const { authenticationResult, userActor } = authentication;
37-
const isUat = Boolean(userActor);
38-
const uatCap = userActor?.cap;
3942
const userActorId = userActor?.userId;
4043

4144
const parsedParams = ParamsSchema.safeParse(params);
@@ -54,6 +57,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
5457
triggerBranch
5558
);
5659

60+
// The exchange only ever mints for the environment the token was signed for.
61+
assertUserActorEnvironment(userActor, runtimeEnv.id);
62+
5763
// This mints a JWT signed with the environment's secret key. For a PAT
5864
// (a user), gate it on env-tier read:apiKeys so a restricted role can't
5965
// obtain deployed-environment credentials (and therefore can't deploy).
@@ -77,21 +83,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
7783
);
7884
}
7985

80-
// The env JWT carries scopes only — downstream auth builds its ability
81-
// from them with no role context. So for a user-actor token we ceiling
82-
// the scopes by the token's own cap here (a read-only agent token can't
83-
// widen its grant through the exchange) and stamp the user via `act` so
84-
// the minted env JWT stays attributable. The cap is a ceiling, not a
85-
// replacement: intersect what the caller asked for with the cap (or use
86-
// the full cap if they asked for nothing). No cap → the request passes
87-
// through, same as a PAT.
86+
// The env JWT carries scopes only — downstream auth builds its ability from them with no role
87+
// context. So for a user-actor token the ceiling is the actor's own ability (role floor ∩ the
88+
// token's cap), never the request: a delegated token can't mint a credential more capable than
89+
// itself, and a capless token is read-only rather than unbounded. A PAT passes through, gated
90+
// by the env-tier check above.
8891
const requestedScopes = parsedBody.data.claims?.scopes;
89-
const scopes =
90-
isUat && uatCap
91-
? requestedScopes && requestedScopes.length > 0
92-
? requestedScopes.filter((scope) => uatCap.includes(scope))
93-
: uatCap
94-
: requestedScopes;
92+
let scopes = requestedScopes;
93+
94+
if (userActor) {
95+
const actorAuth = await rbac.authenticateUserActor(request, {
96+
organizationId: runtimeEnv.organizationId,
97+
projectId: runtimeEnv.project.id,
98+
});
99+
if (!actorAuth.ok) {
100+
return json({ error: actorAuth.error }, { status: actorAuth.status });
101+
}
102+
103+
const clamped = clampUserActorScopes(requestedScopes, userActor, actorAuth.ability);
104+
if (clamped.scopes.length === 0) {
105+
return json(
106+
{ error: "This token isn't allowed the requested scopes", scopes: clamped.deniedScopes },
107+
{ status: 403 }
108+
);
109+
}
110+
scopes = clamped.scopes;
111+
}
95112

96113
// Attribution: stamp the acting user on the minted env JWT. A UAT carries
97114
// its user as `userActorId`; a PAT exchange resolves the user from the
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* Two seams a delegated user-actor token passes through outside the route builder:
3+
* the direct PAT authentication (which used to hand back identity only, dropping the
4+
* environment scope), and the environment JWT exchange (which used to mint whatever
5+
* scopes the caller asked for when the token declared no cap).
6+
*/
7+
8+
import { postgresTest } from "@internal/testcontainers";
9+
import type { PrismaClient } from "@trigger.dev/database";
10+
import { signUserActorToken } from "@trigger.dev/rbac";
11+
import { expect, it, vi } from "vitest";
12+
13+
const SESSION_SECRET = "test-session-secret-for-user-actor-claims";
14+
15+
const ctx = vi.hoisted(() => ({
16+
prisma: undefined as unknown as PrismaClient,
17+
/** Set when the RBAC controller should behave like a plugin on an older contract. */
18+
omitClaims: false,
19+
}));
20+
21+
vi.mock("~/db.server", () => {
22+
const proxy = new Proxy(
23+
{},
24+
{ get: (_target, prop) => (ctx.prisma as unknown as Record<string, unknown>)[prop as string] }
25+
);
26+
return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined };
27+
});
28+
vi.mock("~/env.server", () => ({
29+
env: { SESSION_SECRET: "test-session-secret-for-user-actor-claims" },
30+
}));
31+
vi.mock("~/services/logger.server", () => ({
32+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
33+
}));
34+
35+
// The RBAC controller is the OSS fallback's behaviour: verify the token, ability from its own cap.
36+
vi.mock("~/services/rbac.server", async () => {
37+
const { buildJwtAbility, verifyUserActorToken } = await import("@trigger.dev/rbac");
38+
const bearerOf = (request: Request) =>
39+
request.headers.get("Authorization")?.replace(/^Bearer /, "").trim() ?? "";
40+
41+
return {
42+
rbac: {
43+
authenticateUserActor: async (request: Request, context: any) => {
44+
const claims = await verifyUserActorToken(
45+
"test-session-secret-for-user-actor-claims",
46+
bearerOf(request)
47+
);
48+
if (!claims) return { ok: false, status: 401, error: "Invalid user-actor token" };
49+
return {
50+
ok: true,
51+
userId: claims.userId,
52+
...(ctx.omitClaims ? {} : { claims }),
53+
subject: {
54+
type: "userActor",
55+
userId: claims.userId,
56+
organizationId: context.organizationId ?? "",
57+
},
58+
ability: buildJwtAbility(claims.cap ?? ["read:all"]),
59+
};
60+
},
61+
},
62+
};
63+
});
64+
65+
const { authenticateApiRequestWithPersonalAccessToken } =
66+
await import("~/services/personalAccessToken.server");
67+
const { action: jwtAction } = await import("~/routes/api.v1.projects.$projectRef.$env.jwt");
68+
69+
const USER_ID = "usr_claims_1";
70+
71+
function token(opts: { userId?: string; environmentId?: string; cap?: string[] } = {}) {
72+
return signUserActorToken(SESSION_SECRET, {
73+
userId: opts.userId ?? USER_ID,
74+
client: "dashboard-agent",
75+
...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
76+
...(opts.cap ? { cap: opts.cap } : {}),
77+
});
78+
}
79+
80+
function bearer(value: string) {
81+
return new Request("https://api.trigger.dev/api/v1/whatever", {
82+
headers: { Authorization: `Bearer ${value}` },
83+
});
84+
}
85+
86+
it("keeps a user-actor token's environment claim on the authenticated identity", async () => {
87+
ctx.omitClaims = false;
88+
89+
const result = await authenticateApiRequestWithPersonalAccessToken(
90+
bearer(await token({ environmentId: "env_claimed" }))
91+
);
92+
93+
expect(result?.userId).toBe(USER_ID);
94+
expect(result?.userActor?.environmentId).toBe("env_claimed");
95+
});
96+
97+
it("recovers the claim when the RBAC controller doesn't return it", async () => {
98+
ctx.omitClaims = true;
99+
100+
const result = await authenticateApiRequestWithPersonalAccessToken(
101+
bearer(await token({ environmentId: "env_claimed" }))
102+
);
103+
104+
ctx.omitClaims = false;
105+
expect(result?.userActor?.environmentId).toBe("env_claimed");
106+
});
107+
108+
function suffix() {
109+
return Math.random().toString(36).slice(2, 10);
110+
}
111+
112+
/** An org with one project, a prod and a staging environment, and a member user. */
113+
async function seedProject(prisma: PrismaClient) {
114+
const slug = `jwt_${suffix()}`;
115+
const user = await prisma.user.create({
116+
data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
117+
});
118+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
119+
await prisma.orgMember.create({
120+
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
121+
});
122+
const project = await prisma.project.create({
123+
data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` },
124+
});
125+
126+
const environmentFor = (envSlug: "prod" | "stg") =>
127+
prisma.runtimeEnvironment.create({
128+
data: {
129+
slug: envSlug,
130+
type: envSlug === "prod" ? "PRODUCTION" : "STAGING",
131+
projectId: project.id,
132+
organizationId: organization.id,
133+
apiKey: `tr_${envSlug}_${slug}`,
134+
pkApiKey: `pk_${envSlug}_${slug}`,
135+
shortcode: `${envSlug}${suffix()}`,
136+
},
137+
});
138+
139+
return {
140+
user,
141+
organization,
142+
project,
143+
prod: await environmentFor("prod"),
144+
staging: await environmentFor("stg"),
145+
};
146+
}
147+
148+
async function exchange(opts: {
149+
projectRef: string;
150+
env: string;
151+
token: string;
152+
scopes?: string[];
153+
}) {
154+
const request = new Request(
155+
`https://api.trigger.dev/api/v1/projects/${opts.projectRef}/${opts.env}/jwt`,
156+
{
157+
method: "POST",
158+
headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/json" },
159+
body: JSON.stringify(opts.scopes ? { claims: { scopes: opts.scopes } } : {}),
160+
}
161+
);
162+
163+
try {
164+
const response = await jwtAction({
165+
request,
166+
params: { projectRef: opts.projectRef, env: opts.env },
167+
context: {},
168+
} as any);
169+
return { status: response.status, body: await response.json() };
170+
} catch (thrown) {
171+
if (thrown instanceof Response) {
172+
return { status: thrown.status, body: await thrown.json() };
173+
}
174+
throw thrown;
175+
}
176+
}
177+
178+
/** The minted env JWT's payload. Signature verification isn't what's under test here. */
179+
function payloadOf(jwt: string): any {
180+
return JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8"));
181+
}
182+
183+
postgresTest(
184+
"the exchange never mints scopes a capless delegated token doesn't have",
185+
async ({ prisma }) => {
186+
ctx.prisma = prisma;
187+
const seeded = await seedProject(prisma);
188+
189+
const denied = await exchange({
190+
projectRef: seeded.project.externalRef,
191+
env: "prod",
192+
token: await token({ userId: seeded.user.id, environmentId: seeded.prod.id }),
193+
scopes: ["write:runs"],
194+
});
195+
196+
expect(denied.status).toBe(403);
197+
expect(denied.body.token).toBeUndefined();
198+
}
199+
);
200+
201+
postgresTest("a capless delegated token exchanges for a read-only JWT", async ({ prisma }) => {
202+
ctx.prisma = prisma;
203+
const seeded = await seedProject(prisma);
204+
205+
const minted = await exchange({
206+
projectRef: seeded.project.externalRef,
207+
env: "prod",
208+
token: await token({ userId: seeded.user.id, environmentId: seeded.prod.id }),
209+
});
210+
211+
expect(minted.status).toBe(200);
212+
expect(payloadOf(minted.body.token).scopes).toEqual(["read:all"]);
213+
});
214+
215+
postgresTest("the exchange clamps requested scopes to the token's cap", async ({ prisma }) => {
216+
ctx.prisma = prisma;
217+
const seeded = await seedProject(prisma);
218+
219+
const minted = await exchange({
220+
projectRef: seeded.project.externalRef,
221+
env: "prod",
222+
token: await token({
223+
userId: seeded.user.id,
224+
environmentId: seeded.prod.id,
225+
cap: ["read:runs", "read:apiKeys"],
226+
}),
227+
scopes: ["read:runs", "write:runs"],
228+
});
229+
230+
expect(minted.status).toBe(200);
231+
expect(payloadOf(minted.body.token).scopes).toEqual(["read:runs"]);
232+
});
233+
234+
postgresTest("the exchange only mints for the claimed environment", async ({ prisma }) => {
235+
ctx.prisma = prisma;
236+
const seeded = await seedProject(prisma);
237+
238+
const other = await exchange({
239+
projectRef: seeded.project.externalRef,
240+
env: "staging",
241+
token: await token({
242+
userId: seeded.user.id,
243+
environmentId: seeded.prod.id,
244+
cap: ["read:runs", "read:apiKeys"],
245+
}),
246+
});
247+
248+
expect(other.status).toBe(403);
249+
expect(other.body.token).toBeUndefined();
250+
});

0 commit comments

Comments
 (0)