Skip to content

Commit 837ee01

Browse files
authored
Merge branch 'main' into runstore-read-path
2 parents e20e451 + 06969b2 commit 837ee01

14 files changed

Lines changed: 511 additions & 46 deletions

File tree

.changeset/mint-token-command.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Adds `trigger.dev mint-token`, which mints a short-lived delegated token from your stored personal access token. The token authenticates against the API as you, can be narrowed with `--cap` and given a lifetime with `--ttl`, and prints to stdout so it can be captured.
6+
7+
```bash
8+
UAT=$(trigger.dev mint-token --ttl 3600 --cap read:runs)
9+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { signUserActorToken } from "@trigger.dev/rbac";
3+
import { z } from "zod";
4+
import { env } from "~/env.server";
5+
import { logger } from "~/services/logger.server";
6+
import { rbac } from "~/services/rbac.server";
7+
8+
// Callers pick the TTL (default 1h) up to a hard ceiling; renewal = mint again
9+
// with the PAT. The default is short, but the ceiling allows long-lived tokens
10+
// for callers that need them (e.g. a long-running integration).
11+
const DEFAULT_UAT_TTL_SECONDS = 60 * 60; // 1 hour
12+
const MAX_UAT_TTL_SECONDS = 365 * 24 * 60 * 60; // 365 days
13+
14+
// Mint a short-lived delegated user-actor token (`tr_uat_`) from a personal
15+
// access token. A UAT is a strict downgrade of the PAT: same user identity,
16+
// short-lived, optionally narrowed by `cap`. It lets a holder (an agent, the
17+
// MCP server, an IDE) act as the user without carrying a long-lived PAT.
18+
const RequestBodySchema = z
19+
.object({
20+
// Optional scope cap (e.g. ["read:runs"]) — ceilings the UAT below the
21+
// user's role. Absent → identity-only, floored by the user's role at
22+
// use-time.
23+
cap: z.array(z.string()).optional(),
24+
// Attribution label recorded in the token's `act.client` (e.g. the agent
25+
// or tool that requested it).
26+
client: z.string().min(1).max(255).optional(),
27+
// Lifetime in seconds. Omitted → 1h. Over the ceiling → 400 (we don't
28+
// silently clamp, so a caller never thinks it got longer than it did).
29+
ttlSeconds: z.number().int().positive().max(MAX_UAT_TTL_SECONDS).optional(),
30+
})
31+
.optional();
32+
33+
export async function action({ request }: ActionFunctionArgs) {
34+
try {
35+
// Mint only from a real PAT. authenticatePat requires the `tr_pat_`
36+
// prefix, so a UAT can't mint another UAT (no indefinite renewal) and an
37+
// env API key / OAT can't mint one either.
38+
const patAuth = await rbac.authenticatePat(request, {});
39+
if (!patAuth.ok) {
40+
return json({ error: patAuth.error }, { status: patAuth.status });
41+
}
42+
43+
// A role-restricted PAT (one with a TokenRole cap) can't mint a UAT: the
44+
// UAT is floored by the user's role at use-time and wouldn't carry the
45+
// PAT's narrower ceiling, so minting would widen the grant. Reject rather
46+
// than silently escalate. (The OSS fallback has no TokenRoles, so this
47+
// only takes effect with the cloud RBAC plugin installed.)
48+
const tokenRole = await rbac.getTokenRole(patAuth.tokenId);
49+
if (tokenRole) {
50+
return json(
51+
{
52+
error:
53+
"Cannot mint a user-actor token from a role-restricted personal access token",
54+
},
55+
{ status: 403 }
56+
);
57+
}
58+
59+
const parsedBody = RequestBodySchema.safeParse(await request.json().catch(() => ({})));
60+
if (!parsedBody.success) {
61+
return json(
62+
{ error: "Invalid request body", issues: parsedBody.error.issues },
63+
{ status: 400 }
64+
);
65+
}
66+
const body = parsedBody.data ?? {};
67+
const ttlSeconds = body.ttlSeconds ?? DEFAULT_UAT_TTL_SECONDS;
68+
69+
const token = await signUserActorToken(env.SESSION_SECRET, {
70+
userId: patAuth.userId,
71+
client: body.client ?? "personal-access-token",
72+
cap: body.cap,
73+
// Absolute exp (seconds since epoch). jose treats a number as absolute.
74+
expirationTime: Math.floor(Date.now() / 1000) + ttlSeconds,
75+
});
76+
77+
return json({ token, expiresInSeconds: ttlSeconds });
78+
} catch (error) {
79+
if (error instanceof Response) throw error;
80+
logger.error("Failed to mint user-actor token", { error });
81+
return json({ error: "Internal Server Error" }, { status: 500 });
82+
}
83+
}

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/node";
22
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
3+
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
34
import { z } from "zod";
45
import {
56
authenticatedEnvironmentForAuthentication,
67
authenticateRequest,
8+
type AuthenticationResult,
79
} from "~/services/apiAuth.server";
10+
import { env as appEnv } from "~/env.server";
811
import { logger } from "~/services/logger.server";
912
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
1013

@@ -24,11 +27,36 @@ const RequestBodySchema = z.object({
2427

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

3361
if (!authenticationResult) {
3462
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -73,10 +101,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
73101
);
74102
}
75103

104+
// The env JWT carries scopes only — downstream auth builds its ability
105+
// from them with no role context. So for a user-actor token we ceiling
106+
// the scopes by the token's own cap here (a read-only agent token can't
107+
// widen its grant through the exchange) and stamp the user via `act` so
108+
// the minted env JWT stays attributable. The cap is a ceiling, not a
109+
// replacement: intersect what the caller asked for with the cap (or use
110+
// the full cap if they asked for nothing). No cap → the request passes
111+
// through, same as a PAT.
112+
const requestedScopes = parsedBody.data.claims?.scopes;
113+
const scopes =
114+
isUat && uatCap
115+
? requestedScopes && requestedScopes.length > 0
116+
? requestedScopes.filter((scope) => uatCap.includes(scope))
117+
: uatCap
118+
: requestedScopes;
119+
76120
const claims = {
77121
sub: runtimeEnv.id,
78122
pub: true,
79-
...parsedBody.data.claims,
123+
...(scopes ? { scopes } : {}),
124+
...(userActorId ? { act: { sub: userActorId } } : {}),
80125
};
81126

82127
const jwt = await internal_generateJWT({

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from "@remix-run/server-runtime";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
3+
import { isUserActorToken } from "@trigger.dev/rbac";
34
import { rbac } from "~/services/rbac.server";
45

56
type EnvironmentScopedResource = "envvars" | "apiKeys";
@@ -15,11 +16,12 @@ const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
1516
*
1617
* Machine credentials (an environment's secret/public API key) are already
1718
* scoped to a single environment, so they pass through unchanged. A personal
18-
* access token carries a user, so enforce that user's role for the targeted
19-
* environment tier — e.g. a Developer can't read deployed env vars or API keys
20-
* via the API, matching the dashboard restriction. Blocking the credential read
21-
* for deployed tiers is also what stops a restricted role deploying via the CLI
22-
* (deploy needs the environment's secret key).
19+
* access token (or a delegated user-actor token) carries a user, so enforce
20+
* that user's role for the targeted environment tier — e.g. a Developer can't
21+
* read deployed env vars or API keys via the API, matching the dashboard
22+
* restriction. Blocking the credential read for deployed tiers is also what
23+
* stops a restricted role deploying via the CLI (deploy needs the
24+
* environment's secret key).
2325
*
2426
* Returns a `Response` to short-circuit with when access is denied, or
2527
* `undefined` when the request may proceed.
@@ -41,16 +43,23 @@ export async function authorizePatEnvironmentAccess({
4143
resource: EnvironmentScopedResource;
4244
action: "read" | "write";
4345
}): Promise<Response | undefined> {
44-
if (authType !== "personalAccessToken") {
46+
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
47+
const isUat = !!bearer && isUserActorToken(bearer);
48+
49+
// Machine creds (apiKey) and org tokens carry no user role to enforce. A
50+
// user-actor token carries a user just like a PAT, so it's gated too.
51+
if (authType !== "personalAccessToken" && !isUat) {
4552
return undefined;
4653
}
4754

48-
const patAuth = await rbac.authenticatePat(request, { organizationId, projectId });
49-
if (!patAuth.ok) {
50-
return json({ error: patAuth.error }, { status: patAuth.status });
55+
const userAuth = isUat
56+
? await rbac.authenticateUserActor(request, { organizationId, projectId })
57+
: await rbac.authenticatePat(request, { organizationId, projectId });
58+
if (!userAuth.ok) {
59+
return json({ error: userAuth.error }, { status: userAuth.status });
5160
}
5261

53-
if (!patAuth.ability.can(action, { type: resource, envType })) {
62+
if (!userAuth.ability.can(action, { type: resource, envType })) {
5463
return json(
5564
{
5665
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from "./logger.server";
66
import { rbac } from "./rbac.server";
77
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
88
import { env } from "~/env.server";
9+
import { isUserActorToken } from "@trigger.dev/rbac";
910

1011
const tokenValueLength = 40;
1112
//lowercase only, removed 0 and l to avoid confusion
@@ -165,6 +166,13 @@ export async function authenticateApiRequestWithPersonalAccessToken(
165166
return;
166167
}
167168

169+
// A user-actor token authenticates as the user wherever a PAT does.
170+
// The plugin verifies it (identity path → no org context to floor against).
171+
if (isUserActorToken(token)) {
172+
const result = await rbac.authenticateUserActor(request, {});
173+
return result.ok ? { userId: result.userId } : undefined;
174+
}
175+
168176
return authenticatePersonalAccessToken(token);
169177
}
170178

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,7 @@ export const rbac = plugin.create(
2525
// $replica is structurally a PrismaClient minus `$transaction` — the
2626
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
2727
{ primary: prisma, replica: $replica as PrismaClient },
28-
{ forceFallback: env.RBAC_FORCE_FALLBACK }
28+
// SESSION_SECRET signs delegated user-actor tokens; the plugin verifies
29+
// them with it in authenticateUserActor.
30+
{ forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET }
2931
);

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { apiCors } from "~/utils/apiCors";
66
import { logger } from "../logger.server";
77
import { rbac } from "../rbac.server";
88
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
9+
import { isUserActorToken } from "@trigger.dev/rbac";
910
import {
1011
PersonalAccessTokenAuthenticationResult,
1112
updateLastAccessedAtIfStale,
@@ -552,28 +553,39 @@ export function createLoaderPATApiRoute<
552553
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
553554
// cached timestamp is fresher than the throttle window).
554555
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
555-
const patAuth = await rbac.authenticatePat(request, ctx);
556-
if (!patAuth.ok) {
557-
return await wrapResponse(
558-
request,
559-
json({ error: patAuth.error }, { status: patAuth.status }),
560-
corsStrategy !== "none"
561-
);
562-
}
563556

564-
const authenticationResult: PersonalAccessTokenAuthenticationResult = {
565-
userId: patAuth.userId,
566-
};
567-
const ability: RbacAbility = patAuth.ability;
568-
569-
// Fire the `lastAccessedAt` write conditionally. Two-layer throttle:
570-
// JS skips the SQL when the value is fresh (most requests); the
571-
// SQL `WHERE` clause inside the helper is race-safe for concurrent
572-
// auths that both decide to fire. Don't `await` it from the
573-
// critical path? — it's a one-row update on a small hot table and
574-
// we want to surface failures, so it's awaited (same shape as the
575-
// legacy `authenticatePersonalAccessToken`).
576-
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
557+
let authenticationResult: PersonalAccessTokenAuthenticationResult;
558+
let ability: RbacAbility;
559+
560+
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
561+
if (bearer && isUserActorToken(bearer)) {
562+
// A user-actor token validates + computes the cap-and-floor ability
563+
// in one call, same shape as a PAT.
564+
const uatAuth = await rbac.authenticateUserActor(request, ctx);
565+
if (!uatAuth.ok) {
566+
return await wrapResponse(
567+
request,
568+
json({ error: uatAuth.error }, { status: uatAuth.status }),
569+
corsStrategy !== "none"
570+
);
571+
}
572+
authenticationResult = { userId: uatAuth.userId };
573+
ability = uatAuth.ability;
574+
} else {
575+
// PAT: validate + compute the cap-and-floor ability in one query.
576+
const patAuth = await rbac.authenticatePat(request, ctx);
577+
if (!patAuth.ok) {
578+
return await wrapResponse(
579+
request,
580+
json({ error: patAuth.error }, { status: patAuth.status }),
581+
corsStrategy !== "none"
582+
);
583+
}
584+
authenticationResult = { userId: patAuth.userId };
585+
ability = patAuth.ability;
586+
// Throttled in the helper (no DB write when the cached value is fresh).
587+
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
588+
}
577589

578590
if (authorization) {
579591
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);

0 commit comments

Comments
 (0)