Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mint-token-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"trigger.dev": patch
---

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.

```bash
UAT=$(trigger.dev mint-token --ttl 3600 --cap read:runs)
```
83 changes: 83 additions & 0 deletions apps/webapp/app/routes/api.v1.auth.user-actor-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { signUserActorToken } from "@trigger.dev/rbac";
import { z } from "zod";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";

// Callers pick the TTL (default 1h) up to a hard ceiling; renewal = mint again
// with the PAT. The default is short, but the ceiling allows long-lived tokens
// for callers that need them (e.g. a long-running integration).
const DEFAULT_UAT_TTL_SECONDS = 60 * 60; // 1 hour
const MAX_UAT_TTL_SECONDS = 365 * 24 * 60 * 60; // 365 days

// Mint a short-lived delegated user-actor token (`tr_uat_`) from a personal
// access token. A UAT is a strict downgrade of the PAT: same user identity,
// short-lived, optionally narrowed by `cap`. It lets a holder (an agent, the
// MCP server, an IDE) act as the user without carrying a long-lived PAT.
const RequestBodySchema = z
.object({
// Optional scope cap (e.g. ["read:runs"]) — ceilings the UAT below the
// user's role. Absent → identity-only, floored by the user's role at
// use-time.
cap: z.array(z.string()).optional(),
// Attribution label recorded in the token's `act.client` (e.g. the agent
// or tool that requested it).
client: z.string().min(1).max(255).optional(),
// Lifetime in seconds. Omitted → 1h. Over the ceiling → 400 (we don't
// silently clamp, so a caller never thinks it got longer than it did).
ttlSeconds: z.number().int().positive().max(MAX_UAT_TTL_SECONDS).optional(),
})
.optional();

export async function action({ request }: ActionFunctionArgs) {
try {
// Mint only from a real PAT. authenticatePat requires the `tr_pat_`
// prefix, so a UAT can't mint another UAT (no indefinite renewal) and an
// env API key / OAT can't mint one either.
const patAuth = await rbac.authenticatePat(request, {});
if (!patAuth.ok) {
return json({ error: patAuth.error }, { status: patAuth.status });
}

// A role-restricted PAT (one with a TokenRole cap) can't mint a UAT: the
// UAT is floored by the user's role at use-time and wouldn't carry the
// PAT's narrower ceiling, so minting would widen the grant. Reject rather
// than silently escalate. (The OSS fallback has no TokenRoles, so this
// only takes effect with the cloud RBAC plugin installed.)
const tokenRole = await rbac.getTokenRole(patAuth.tokenId);
if (tokenRole) {
return json(
{
error:
"Cannot mint a user-actor token from a role-restricted personal access token",
},
{ status: 403 }
);
}

const parsedBody = RequestBodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsedBody.success) {
return json(
{ error: "Invalid request body", issues: parsedBody.error.issues },
{ status: 400 }
);
}
const body = parsedBody.data ?? {};
const ttlSeconds = body.ttlSeconds ?? DEFAULT_UAT_TTL_SECONDS;

const token = await signUserActorToken(env.SESSION_SECRET, {
userId: patAuth.userId,
client: body.client ?? "personal-access-token",
cap: body.cap,
// Absolute exp (seconds since epoch). jose treats a number as absolute.
expirationTime: Math.floor(Date.now() / 1000) + ttlSeconds,
});

return json({ token, expiresInSeconds: ttlSeconds });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to mint user-actor token", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
57 changes: 51 additions & 6 deletions apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
import { z } from "zod";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
type AuthenticationResult,
} from "~/services/apiAuth.server";
import { env as appEnv } from "~/env.server";
import { logger } from "~/services/logger.server";
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";

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

export async function action({ request, params }: ActionFunctionArgs) {
try {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
const isUat = !!bearer && isUserActorToken(bearer);

// A delegated user-actor token authenticates as its user, like a PAT. We
// resolve it here (not through authenticateRequest) so the exchange stays
// scoped to this route — UATs deliberately aren't accepted on every
// PAT route. `uatCap` (the token's optional scope cap) ceilings the
// minted env JWT below.
let uatCap: string[] | undefined;
let userActorId: string | undefined;
let authenticationResult: AuthenticationResult | undefined;
if (isUat) {
const claims = await verifyUserActorToken(appEnv.SESSION_SECRET, bearer!);
if (!claims) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
uatCap = claims.cap;
userActorId = claims.userId;
// The env lookup keys purely on the user, identical to a PAT.
authenticationResult = {
type: "personalAccessToken",
result: { userId: claims.userId },
};
} else {
authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
}

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

// The env JWT carries scopes only — downstream auth builds its ability
// from them with no role context. So for a user-actor token we ceiling
// the scopes by the token's own cap here (a read-only agent token can't
// widen its grant through the exchange) and stamp the user via `act` so
// the minted env JWT stays attributable. The cap is a ceiling, not a
// replacement: intersect what the caller asked for with the cap (or use
// the full cap if they asked for nothing). No cap → the request passes
// through, same as a PAT.
const requestedScopes = parsedBody.data.claims?.scopes;
const scopes =
isUat && uatCap
? requestedScopes && requestedScopes.length > 0
? requestedScopes.filter((scope) => uatCap.includes(scope))
: uatCap
: requestedScopes;

const claims = {
sub: runtimeEnv.id,
pub: true,
...parsedBody.data.claims,
...(scopes ? { scopes } : {}),
...(userActorId ? { act: { sub: userActorId } } : {}),
};

const jwt = await internal_generateJWT({
Expand Down
29 changes: 19 additions & 10 deletions apps/webapp/app/services/environmentVariableApiAccess.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { json } from "@remix-run/server-runtime";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { isUserActorToken } from "@trigger.dev/rbac";
import { rbac } from "~/services/rbac.server";

type EnvironmentScopedResource = "envvars" | "apiKeys";
Expand All @@ -15,11 +16,12 @@ const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
*
* Machine credentials (an environment's secret/public API key) are already
* scoped to a single environment, so they pass through unchanged. A personal
* access token carries a user, so enforce that user's role for the targeted
* environment tier — e.g. a Developer can't read deployed env vars or API keys
* via the API, matching the dashboard restriction. Blocking the credential read
* for deployed tiers is also what stops a restricted role deploying via the CLI
* (deploy needs the environment's secret key).
* access token (or a delegated user-actor token) carries a user, so enforce
* that user's role for the targeted environment tier — e.g. a Developer can't
* read deployed env vars or API keys via the API, matching the dashboard
* restriction. Blocking the credential read for deployed tiers is also what
* stops a restricted role deploying via the CLI (deploy needs the
* environment's secret key).
*
* Returns a `Response` to short-circuit with when access is denied, or
* `undefined` when the request may proceed.
Expand All @@ -41,16 +43,23 @@ export async function authorizePatEnvironmentAccess({
resource: EnvironmentScopedResource;
action: "read" | "write";
}): Promise<Response | undefined> {
if (authType !== "personalAccessToken") {
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
const isUat = !!bearer && isUserActorToken(bearer);

// Machine creds (apiKey) and org tokens carry no user role to enforce. A
// user-actor token carries a user just like a PAT, so it's gated too.
if (authType !== "personalAccessToken" && !isUat) {
return undefined;
}

const patAuth = await rbac.authenticatePat(request, { organizationId, projectId });
if (!patAuth.ok) {
return json({ error: patAuth.error }, { status: patAuth.status });
const userAuth = isUat
? await rbac.authenticateUserActor(request, { organizationId, projectId })
: await rbac.authenticatePat(request, { organizationId, projectId });
if (!userAuth.ok) {
return json({ error: userAuth.error }, { status: userAuth.status });
}

if (!patAuth.ability.can(action, { type: resource, envType })) {
if (!userAuth.ability.can(action, { type: resource, envType })) {
return json(
{
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/app/services/personalAccessToken.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from "./logger.server";
import { rbac } from "./rbac.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
import { isUserActorToken } from "@trigger.dev/rbac";

const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
Expand Down Expand Up @@ -165,6 +166,13 @@ export async function authenticateApiRequestWithPersonalAccessToken(
return;
}

// A user-actor token authenticates as the user wherever a PAT does.
// The plugin verifies it (identity path → no org context to floor against).
if (isUserActorToken(token)) {
const result = await rbac.authenticateUserActor(request, {});
return result.ok ? { userId: result.userId } : undefined;
}
Comment thread
ericallam marked this conversation as resolved.

return authenticatePersonalAccessToken(token);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/services/rbac.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ export const rbac = plugin.create(
// $replica is structurally a PrismaClient minus `$transaction` — the
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
{ primary: prisma, replica: $replica as PrismaClient },
{ forceFallback: env.RBAC_FORCE_FALLBACK }
// SESSION_SECRET signs delegated user-actor tokens; the plugin verifies
// them with it in authenticateUserActor.
{ forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET }
);
54 changes: 33 additions & 21 deletions apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { apiCors } from "~/utils/apiCors";
import { logger } from "../logger.server";
import { rbac } from "../rbac.server";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import { isUserActorToken } from "@trigger.dev/rbac";
import {
PersonalAccessTokenAuthenticationResult,
updateLastAccessedAtIfStale,
Expand Down Expand Up @@ -552,28 +553,39 @@ export function createLoaderPATApiRoute<
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
// cached timestamp is fresher than the throttle window).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
const patAuth = await rbac.authenticatePat(request, ctx);
if (!patAuth.ok) {
return await wrapResponse(
request,
json({ error: patAuth.error }, { status: patAuth.status }),
corsStrategy !== "none"
);
}

const authenticationResult: PersonalAccessTokenAuthenticationResult = {
userId: patAuth.userId,
};
const ability: RbacAbility = patAuth.ability;

// Fire the `lastAccessedAt` write conditionally. Two-layer throttle:
// JS skips the SQL when the value is fresh (most requests); the
// SQL `WHERE` clause inside the helper is race-safe for concurrent
// auths that both decide to fire. Don't `await` it from the
// critical path? — it's a one-row update on a small hot table and
// we want to surface failures, so it's awaited (same shape as the
// legacy `authenticatePersonalAccessToken`).
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
let authenticationResult: PersonalAccessTokenAuthenticationResult;
let ability: RbacAbility;

const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
if (bearer && isUserActorToken(bearer)) {
// A user-actor token validates + computes the cap-and-floor ability
// in one call, same shape as a PAT.
const uatAuth = await rbac.authenticateUserActor(request, ctx);
if (!uatAuth.ok) {
return await wrapResponse(
request,
json({ error: uatAuth.error }, { status: uatAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: uatAuth.userId };
ability = uatAuth.ability;
} else {
// PAT: validate + compute the cap-and-floor ability in one query.
const patAuth = await rbac.authenticatePat(request, ctx);
if (!patAuth.ok) {
return await wrapResponse(
request,
json({ error: patAuth.error }, { status: patAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: patAuth.userId };
ability = patAuth.ability;
// Throttled in the helper (no DB write when the cached value is fresh).
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
}

if (authorization) {
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);
Expand Down
Loading
Loading