Skip to content

Commit bd357fc

Browse files
committed
revert(webapp): put the JWT exchange back to how main had it
The scope ceiling rewrite landed here and was reverted two PRs up, leaving the stack asserting both directions. The exchange intersects requested scopes with the token's cap again, a capless token passes through like a PAT, and the route keeps only the environment claim check and the acting client.
1 parent df2227e commit bd357fc

5 files changed

Lines changed: 114 additions & 190 deletions

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

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/node";
22
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
3+
import { isUserActorToken, verifyUserActorToken, type UserActorClaims } from "@trigger.dev/rbac";
34
import { z } from "zod";
45
import {
56
authenticatedEnvironmentForAuthentication,
7+
authenticateRequest,
68
branchNameFromRequest,
9+
type AuthenticationResult,
710
} from "~/services/apiAuth.server";
11+
import { env as appEnv } from "~/env.server";
12+
import { assertUserActorEnvironment } from "~/services/userActorEnvironment.server";
813
import { logger } from "~/services/logger.server";
914
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
10-
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
11-
import { rbac } from "~/services/rbac.server";
12-
import {
13-
assertUserActorEnvironment,
14-
clampUserActorScopes,
15-
} from "~/services/userActorEnvironment.server";
1615

1716
const ParamsSchema = z.object({
1817
projectRef: z.string(),
@@ -30,17 +29,46 @@ const RequestBodySchema = z.object({
3029

3130
export async function action({ request, params }: ActionFunctionArgs) {
3231
try {
33-
// A user-actor token authenticates as its user, like a PAT. Its scope cap ceilings the
32+
const bearer = request.headers
33+
.get("Authorization")
34+
?.replace(/^Bearer /, "")
35+
.trim();
36+
const isUat = !!bearer && isUserActorToken(bearer);
37+
38+
// A delegated user-actor token authenticates as its user, like a PAT. We
39+
// resolve it here (not through authenticateRequest) so the exchange stays
40+
// scoped to this route — UATs deliberately aren't accepted on every
41+
// PAT route. `uatCap` (the token's optional scope cap) ceilings the
3442
// minted env JWT below.
35-
const authentication = await authenticateUatOrApiRequest(request);
43+
let uatCap: string[] | undefined;
44+
let userActorId: string | undefined;
45+
let userActor: UserActorClaims | undefined;
46+
let authenticationResult: AuthenticationResult | undefined;
47+
if (isUat) {
48+
const claims = await verifyUserActorToken(appEnv.SESSION_SECRET, bearer!);
49+
if (!claims) {
50+
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
51+
}
52+
uatCap = claims.cap;
53+
userActorId = claims.userId;
54+
userActor = claims;
55+
// The env lookup keys purely on the user, identical to a PAT.
56+
authenticationResult = {
57+
type: "personalAccessToken",
58+
result: { userId: claims.userId },
59+
};
60+
} else {
61+
authenticationResult = await authenticateRequest(request, {
62+
personalAccessToken: true,
63+
organizationAccessToken: true,
64+
apiKey: false,
65+
});
66+
}
3667

37-
if (!authentication) {
68+
if (!authenticationResult) {
3869
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
3970
}
4071

41-
const { authenticationResult, userActor } = authentication;
42-
const userActorId = userActor?.userId;
43-
4472
const parsedParams = ParamsSchema.safeParse(params);
4573

4674
if (!parsedParams.success) {
@@ -57,7 +85,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
5785
triggerBranch
5886
);
5987

60-
// The exchange only ever mints for the environment the token was signed for.
88+
// A user-actor token signed for one environment mints only for that one.
6189
assertUserActorEnvironment(userActor, runtimeEnv.id);
6290

6391
// This mints a JWT signed with the environment's secret key. For a PAT
@@ -83,32 +111,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
83111
);
84112
}
85113

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.
114+
// The env JWT carries scopes only — downstream auth builds its ability
115+
// from them with no role context. So for a user-actor token we ceiling
116+
// the scopes by the token's own cap here (a read-only agent token can't
117+
// widen its grant through the exchange) and stamp the user via `act` so
118+
// the minted env JWT stays attributable. The cap is a ceiling, not a
119+
// replacement: intersect what the caller asked for with the cap (or use
120+
// the full cap if they asked for nothing). No cap → the request passes
121+
// through, same as a PAT.
91122
const requestedScopes = parsedBody.data.claims?.scopes;
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-
}
123+
const scopes =
124+
isUat && uatCap
125+
? requestedScopes && requestedScopes.length > 0
126+
? requestedScopes.filter((scope) => uatCap.includes(scope))
127+
: uatCap
128+
: requestedScopes;
112129

113130
// Attribution: stamp the acting user on the minted env JWT. A UAT carries
114131
// its user as `userActorId`; a PAT exchange resolves the user from the
@@ -120,13 +137,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
120137
(authenticationResult.type === "personalAccessToken"
121138
? authenticationResult.result.userId
122139
: undefined);
123-
const actorClient = userActor?.client ?? "personal-access-token";
124140

125141
const claims = {
126142
sub: runtimeEnv.id,
127143
pub: true,
128144
...(scopes ? { scopes } : {}),
129-
...(actorUserId ? { act: { sub: actorUserId, client: actorClient } } : {}),
145+
...(actorUserId
146+
? { act: { sub: actorUserId, client: userActor?.client ?? "personal-access-token" } }
147+
: {}),
130148
};
131149

132150
const jwt = await internal_generateJWT({

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

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@
99
*/
1010

1111
import { json } from "@remix-run/server-runtime";
12-
import {
13-
buildJwtAbility,
14-
type RbacAbility,
15-
scopesWithinAbility,
16-
type UserActorClaims,
17-
} from "@trigger.dev/rbac";
12+
import { type UserActorClaims } from "@trigger.dev/rbac";
1813
import { $replica } from "~/db.server";
1914

2015
export const FORBIDDEN_ENVIRONMENT_CODE = "forbidden_environment";
@@ -114,33 +109,6 @@ export async function resolveUserActorEnvironmentScope(
114109
};
115110
}
116111

117-
/** Mirrors the RBAC fallback's own default. */
118-
const CAPLESS_USER_ACTOR_SCOPES = ["read:all"];
119-
120-
/**
121-
* A delegated token must never mint something more capable than itself. Two ceilings apply:
122-
* the actor's own ability (their role) and the token's `cap`. The role alone is not enough —
123-
* a read-only agent token belongs to a user who may well be allowed to write.
124-
*/
125-
export function clampUserActorScopes(
126-
requestedScopes: string[] | undefined,
127-
userActor: UserActorClaims,
128-
ability: RbacAbility
129-
): { scopes: string[]; deniedScopes: string[] } {
130-
const cap = userActor.cap ?? CAPLESS_USER_ACTOR_SCOPES;
131-
const requested = requestedScopes && requestedScopes.length > 0 ? requestedScopes : cap;
132-
133-
const denied = new Set([
134-
...scopesWithinAbility(requested, ability).deniedScopes,
135-
...scopesWithinAbility(requested, buildJwtAbility(cap)).deniedScopes,
136-
]);
137-
138-
return {
139-
scopes: requested.filter((scope) => !denied.has(scope)),
140-
deniedScopes: [...denied],
141-
};
142-
}
143-
144112
function assertClaimIsOptional(userActor: UserActorClaims): void {
145113
if (userActor.client !== DASHBOARD_AGENT_CLIENT) return;
146114
throw forbiddenEnvironment("This token isn't scoped to an environment.");

apps/webapp/test/dashboardAgentDelegatedScopeCeiling.test.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

apps/webapp/test/uatEnvironmentClaim.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,59 @@ describe("user-actor token environment scope", () => {
292292
});
293293
});
294294

295+
/**
296+
* The exchange's own ceiling: a delegated token names the scopes it wants, and its `cap` is
297+
* what it may have. Without the intersection a read-only agent token mints a write JWT
298+
* whenever its user's role allows writes — the token travels in a task payload, so that is
299+
* a real widening rather than a theoretical one.
300+
*/
301+
describe("env JWT exchange — the cap is a ceiling", () => {
302+
beforeEach(() => {
303+
mocks.can.mockReset();
304+
mocks.can.mockReturnValue(true);
305+
});
306+
307+
async function exchange(token: string, scopes?: string[]) {
308+
const response = await respond(
309+
() =>
310+
jwtAction({
311+
request: requestFor(token, `/api/v1/projects/${PROJECT.externalRef}/prod/jwt`, {
312+
method: "POST",
313+
body: JSON.stringify(scopes ? { claims: { scopes } } : {}),
314+
}),
315+
params: { projectRef: PROJECT.externalRef, env: "prod" },
316+
context: {} as any,
317+
}) as Promise<Response>
318+
);
319+
return response;
320+
}
321+
322+
it("drops a scope the cap doesn't carry", async () => {
323+
const token = await mintToken({ environmentId: ENV_A.id });
324+
325+
const response = await exchange(token, ["read:runs", "write:runs"]);
326+
327+
expect(response.status).toBe(200);
328+
const { token: jwt } = (await response.json()) as { token: string };
329+
const payload = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()) as {
330+
scopes?: string[];
331+
};
332+
expect(payload.scopes).toEqual(["read:runs"]);
333+
});
334+
335+
it("falls back to the whole cap when the caller asks for nothing", async () => {
336+
const token = await mintToken({ environmentId: ENV_A.id });
337+
338+
const response = await exchange(token);
339+
340+
const { token: jwt } = (await response.json()) as { token: string };
341+
const payload = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()) as {
342+
scopes?: string[];
343+
};
344+
expect(payload.scopes).toEqual(["read:apiKeys", "read:runs", "read:deployments"]);
345+
});
346+
});
347+
295348
describe("repo snapshot authorization", () => {
296349
beforeEach(() => {
297350
mocks.can.mockReset();

apps/webapp/test/userActorTokenClaimsAndScopes.test.ts

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Two seams a delegated user-actor token passes through outside the route builder:
33
* 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).
4+
* environment scope), and the environment JWT exchange (which ceilings the minted
5+
* scopes by the token's cap and only mints for the environment it was signed for).
66
*/
77

88
import { postgresTest } from "@internal/testcontainers";
@@ -183,43 +183,6 @@ function payloadOf(jwt: string): any {
183183
return JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8"));
184184
}
185185

186-
postgresTest(
187-
"the exchange never mints scopes a capless delegated token doesn't have",
188-
async ({ prisma }) => {
189-
ctx.prisma = prisma;
190-
const seeded = await seedProject(prisma);
191-
192-
const denied = await exchange({
193-
projectRef: seeded.project.externalRef,
194-
env: "prod",
195-
token: await token({ userId: seeded.user.id, environmentId: seeded.prod.id }),
196-
scopes: ["write:runs"],
197-
});
198-
199-
expect(denied.status).toBe(403);
200-
expect(denied.body.token).toBeUndefined();
201-
},
202-
60_000
203-
);
204-
205-
postgresTest(
206-
"a capless delegated token exchanges for a read-only JWT",
207-
async ({ prisma }) => {
208-
ctx.prisma = prisma;
209-
const seeded = await seedProject(prisma);
210-
211-
const minted = await exchange({
212-
projectRef: seeded.project.externalRef,
213-
env: "prod",
214-
token: await token({ userId: seeded.user.id, environmentId: seeded.prod.id }),
215-
});
216-
217-
expect(minted.status).toBe(200);
218-
expect(payloadOf(minted.body.token).scopes).toEqual(["read:all"]);
219-
},
220-
60_000
221-
);
222-
223186
postgresTest(
224187
"the exchange clamps requested scopes to the token's cap",
225188
async ({ prisma }) => {

0 commit comments

Comments
 (0)