Skip to content

Commit 7be3df7

Browse files
committed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents 110a427 + f891584 commit 7be3df7

7 files changed

Lines changed: 149 additions & 209 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/envJwtActorClaim.test.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mocks = vi.hoisted(() => ({
4+
authenticateRequest: vi.fn<(...args: any[]) => Promise<any>>(),
5+
verifyUserActorToken: vi.fn<(...args: any[]) => Promise<any>>(),
6+
isUserActorToken: vi.fn<(value: string) => boolean>(),
47
authenticateUatOrApiRequest: vi.fn<(...args: any[]) => Promise<any>>(),
58
authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise<any>>(),
69
}));
710

8-
vi.mock("~/services/uatRoutePreamble.server", () => ({
9-
authenticateUatOrApiRequest: mocks.authenticateUatOrApiRequest,
11+
vi.mock("@trigger.dev/rbac", async (importOriginal) => ({
12+
...(await importOriginal<Record<string, unknown>>()),
13+
isUserActorToken: mocks.isUserActorToken,
14+
verifyUserActorToken: mocks.verifyUserActorToken,
1015
}));
1116
vi.mock("~/services/environmentVariableApiAccess.server", () => ({
1217
authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess,
1318
}));
1419
vi.mock("~/services/apiAuth.server", () => ({
1520
authenticatedEnvironmentForAuthentication: vi.fn(async () => environment),
1621
branchNameFromRequest: () => undefined,
22+
authenticateRequest: mocks.authenticateRequest,
1723
}));
1824
vi.mock("~/services/logger.server", () => ({
19-
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
25+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
2026
}));
27+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
28+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
2129

2230
import { validateJWT } from "@trigger.dev/core/v3/jwt";
2331
import { action } from "~/routes/api.v1.projects.$projectRef.$env.jwt";
@@ -32,10 +40,10 @@ const environment = {
3240

3341
const params = { projectRef: "proj_abc", env: "prod" };
3442

35-
function request(body: unknown = {}) {
43+
function request(body: unknown = {}, bearer = "tr_pat_test") {
3644
return new Request("https://example.com/api/v1/projects/proj_abc/prod/jwt", {
3745
method: "POST",
38-
headers: { "Content-Type": "application/json" },
46+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${bearer}` },
3947
body: JSON.stringify(body),
4048
});
4149
}
@@ -50,14 +58,18 @@ async function mintedClaims(body?: unknown) {
5058

5159
describe("env JWT exchange — act claim", () => {
5260
beforeEach(() => {
53-
mocks.authenticateUatOrApiRequest.mockReset();
61+
mocks.authenticateRequest.mockReset();
62+
mocks.verifyUserActorToken.mockReset();
63+
mocks.isUserActorToken.mockReset();
64+
mocks.isUserActorToken.mockReturnValue(false);
5465
mocks.authorizePatEnvironmentAccess.mockReset();
5566
mocks.authorizePatEnvironmentAccess.mockResolvedValue(undefined);
5667
});
5768

5869
it("stamps the PAT's user with the personal-access-token client", async () => {
59-
mocks.authenticateUatOrApiRequest.mockResolvedValue({
60-
authenticationResult: { type: "personalAccessToken", result: { userId: "usr_42" } },
70+
mocks.authenticateRequest.mockResolvedValue({
71+
type: "personalAccessToken",
72+
result: { userId: "usr_42" },
6173
});
6274

6375
const claims = await mintedClaims();
@@ -67,9 +79,13 @@ describe("env JWT exchange — act claim", () => {
6779
});
6880

6981
it("passes through a user-actor token's own client", async () => {
70-
mocks.authenticateUatOrApiRequest.mockResolvedValue({
71-
authenticationResult: { type: "personalAccessToken", result: { userId: "usr_7" } },
72-
userActor: { userId: "usr_7", client: "dashboard-agent", cap: ["read:runs"] },
82+
mocks.isUserActorToken.mockReturnValue(true);
83+
mocks.verifyUserActorToken.mockResolvedValue({
84+
userId: "usr_7",
85+
client: "dashboard-agent",
86+
// An agent token always carries the environment it was minted for.
87+
environmentId: environment.id,
88+
cap: ["read:runs"],
7389
});
7490

7591
const claims = await mintedClaims({ claims: { scopes: ["read:runs"] } });
@@ -79,11 +95,9 @@ describe("env JWT exchange — act claim", () => {
7995
});
8096

8197
it("omits act for an org access token (no user)", async () => {
82-
mocks.authenticateUatOrApiRequest.mockResolvedValue({
83-
authenticationResult: {
84-
type: "organizationAccessToken",
85-
result: { organizationId: "org_1" },
86-
},
98+
mocks.authenticateRequest.mockResolvedValue({
99+
type: "organizationAccessToken",
100+
result: { organizationId: "org_1" },
87101
});
88102

89103
const claims = await mintedClaims();
@@ -93,7 +107,7 @@ describe("env JWT exchange — act claim", () => {
93107
});
94108

95109
it("401s without a token", async () => {
96-
mocks.authenticateUatOrApiRequest.mockResolvedValue(undefined);
110+
mocks.authenticateRequest.mockResolvedValue(undefined);
97111

98112
const response = await action({ request: request(), params, context: {} as any });
99113

0 commit comments

Comments
 (0)