Skip to content

Commit b6be5e4

Browse files
committed
Plugin: authenticatePat for cap-and-floor PAT auth (TRI-9087)
Adds RoleBaseAccessController.authenticatePat — PATs identify the user; the effective ability is min(user's role in target org, max-role cap). The user's actual membership is the floor (auto-narrows on demotion or removal); the cap is set at PAT creation as a deliberate ceiling. OSS-side: - @trigger.dev/plugins gains the PatAuthResult type + authenticatePat on the controller interface. - Fallback validates the PAT (prefix, hashed lookup, revoked check) and returns a permissive ability — preserves the pre-RBAC behaviour where PATs were pure user-identity tokens. Self-hosters see no change. - LazyController delegates with the existing withActionAliases wrapper. apiBuilder: - createLoaderPATApiRoute accepts an optional context callback to derive { organizationId?, projectId? } and an optional authorization block. When either is declared, rbac.authenticatePat runs and the ability flows into the handler. Routes that don't opt in stay on the legacy permissive path. - api.v1.projects.$projectRef.runs.ts opts in: context resolves projectRef -> organizationId, authorization is read on type runs. UI: - account.tokens picker reframed as 'Maximum role' with a hint explaining the cap-vs-floor model. Underlying TokenRole storage unchanged; semantic flip from 'bound role' to 'max role cap'. The role chosen at PAT creation now actually constrains the token (previously TokenRole was written but never read at request time).
1 parent 81e0b8e commit b6be5e4

7 files changed

Lines changed: 180 additions & 3 deletions

File tree

apps/webapp/app/routes/account.tokens/route.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ function CreatePersonalAccessToken({
372372

373373
{showRolePicker && (
374374
<InputGroup>
375-
<Label>Role</Label>
375+
<Label>Maximum role</Label>
376376
<Select<string, SystemRole>
377377
value={selectedRoleId}
378378
setValue={(v) => setSelectedRoleId(v)}
@@ -395,8 +395,8 @@ function CreatePersonalAccessToken({
395395
}
396396
</Select>
397397
<Hint>
398-
The token's permissions are bound to this role. Defaults to your own role so the
399-
token can't do more than you can.
398+
The token can act with up to this role. Your current role in each org is the
399+
actual ceiling — the token never grants more than you have.
400400
</Hint>
401401
</InputGroup>
402402
)}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from "@remix-run/server-runtime";
22
import { z } from "zod";
3+
import { $replica } from "~/db.server";
34
import { findProjectByRef } from "~/models/project.server";
45
import {
56
ApiRunListPresenter,
@@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute(
1617
params: ParamsSchema,
1718
searchParams: ApiRunListSearchParams,
1819
corsStrategy: "all",
20+
// Resolve projectRef → org so the PAT plugin can ground its
21+
// role-floor calculation. We deliberately don't filter by user
22+
// membership here — that's the plugin's job (`authenticatePat`
23+
// checks OrgMember in the target org and rejects if the user
24+
// isn't a member). Keeps the contract clean: context is "what
25+
// org does this URL target?" and auth is "is this user allowed?"
26+
context: async (params) => {
27+
const project = await $replica.project.findFirst({
28+
where: { externalRef: params.projectRef },
29+
select: { organizationId: true },
30+
});
31+
return project ? { organizationId: project.organizationId } : {};
32+
},
33+
authorization: { action: "read", resource: () => ({ type: "runs" }) },
1934
},
2035
async ({ searchParams, params, authentication, apiVersion }) => {
2136
const project = await findProjectByRef(params.projectRef, authentication.userId);

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ async function authenticateRequestForApiBuilder(
8383

8484
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
8585

86+
// Sentinel ability for routes that don't opt into the cap-and-floor PAT
87+
// model — preserves pre-RBAC behaviour where PATs were pure user-identity
88+
// tokens. New routes that want gated PAT auth declare a `context` and
89+
// `authorization` block; the actual ability comes from `rbac.authenticatePat`.
90+
const PERMISSIVE_ABILITY: RbacAbility = {
91+
can: () => true,
92+
canSuper: () => false,
93+
};
94+
8695
// Most route auth checks pass an array of resources to ability.can() with
8796
// "any-element-passes" semantics — a single record carries multiple
8897
// identifiers (a run is addressable by friendlyId / batch / tags / task) so a
@@ -365,6 +374,37 @@ type PATRouteBuilderOptions<
365374
searchParams?: TSearchParamsSchema;
366375
headers?: THeadersSchema;
367376
corsStrategy?: "all" | "none";
377+
// Resolves the target org/project for the request. Fed to
378+
// `rbac.authenticatePat` so the plugin can compute the user's role
379+
// floor (their authority in that org) for the cap intersection.
380+
// When omitted, the PAT runs in identity-only mode — no role floor,
381+
// no per-route ability gating beyond what authorization (if any)
382+
// declares against a permissive baseline. Routes added before TRI-9087
383+
// run in this mode by default.
384+
context?: (
385+
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
386+
? z.infer<TParamsSchema>
387+
: undefined,
388+
request: Request
389+
) =>
390+
| { organizationId?: string; projectId?: string }
391+
| Promise<{ organizationId?: string; projectId?: string }>;
392+
authorization?: {
393+
action: string;
394+
resource: (
395+
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
396+
? z.infer<TParamsSchema>
397+
: undefined,
398+
searchParams: TSearchParamsSchema extends
399+
| z.ZodFirstPartySchemaTypes
400+
| z.ZodDiscriminatedUnion<any, any>
401+
? z.infer<TSearchParamsSchema>
402+
: undefined,
403+
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
404+
? z.infer<THeadersSchema>
405+
: undefined
406+
) => AuthResource;
407+
};
368408
};
369409

370410
type PATHandlerFunction<
@@ -384,6 +424,7 @@ type PATHandlerFunction<
384424
? z.infer<THeadersSchema>
385425
: undefined;
386426
authentication: PersonalAccessTokenAuthenticationResult;
427+
ability: RbacAbility;
387428
request: Request;
388429
apiVersion: API_VERSIONS;
389430
}) => Promise<Response>;
@@ -402,6 +443,8 @@ export function createLoaderPATApiRoute<
402443
searchParams: searchParamsSchema,
403444
headers: headersSchema,
404445
corsStrategy = "none",
446+
context: contextFn,
447+
authorization,
405448
} = options;
406449

407450
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
@@ -471,11 +514,53 @@ export function createLoaderPATApiRoute<
471514

472515
const apiVersion = getApiVersion(request);
473516

517+
// Resolve ability via the rbac plugin. When neither `context` nor
518+
// `authorization` is declared, the legacy permissive ability stands
519+
// in — preserves the pre-RBAC PAT behaviour for routes that
520+
// haven't opted into the cap-and-floor model yet.
521+
let ability: RbacAbility = PERMISSIVE_ABILITY;
522+
if (contextFn || authorization) {
523+
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
524+
const patAuth = await rbac.authenticatePat(request, ctx);
525+
if (!patAuth.ok) {
526+
return await wrapResponse(
527+
request,
528+
json({ error: patAuth.error }, { status: patAuth.status }),
529+
corsStrategy !== "none"
530+
);
531+
}
532+
ability = patAuth.ability;
533+
534+
if (authorization) {
535+
const $resource = authorization.resource(
536+
parsedParams,
537+
parsedSearchParams,
538+
parsedHeaders
539+
);
540+
if (!checkAuth(ability, authorization.action, $resource)) {
541+
return await wrapResponse(
542+
request,
543+
json(
544+
{
545+
error: "Unauthorized",
546+
code: "unauthorized",
547+
param: "access_token",
548+
type: "authorization",
549+
},
550+
{ status: 403 }
551+
),
552+
corsStrategy !== "none"
553+
);
554+
}
555+
}
556+
}
557+
474558
const result = await handler({
475559
params: parsedParams,
476560
searchParams: parsedSearchParams,
477561
headers: parsedHeaders,
478562
authentication: authenticationResult,
563+
ability,
479564
request,
480565
apiVersion,
481566
});

internal-packages/rbac/src/fallback.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import type {
66
RbacSubject,
77
RbacResource,
88
BearerAuthResult,
9+
PatAuthResult,
910
SessionAuthResult,
1011
RoleAssignmentResult,
1112
RoleBaseAccessController,
1213
RoleMutationResult,
1314
} from "@trigger.dev/plugins";
15+
import { createHash } from "node:crypto";
1416
import type { PrismaClient } from "@trigger.dev/database";
1517
import { validateJWT } from "@trigger.dev/core/v3/jwt";
1618
import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js";
@@ -157,6 +159,45 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
157159
return auth;
158160
}
159161

162+
async authenticatePat(
163+
request: Request,
164+
context: { organizationId?: string; projectId?: string }
165+
): Promise<PatAuthResult> {
166+
const rawToken = request.headers
167+
.get("Authorization")
168+
?.replace(/^Bearer /, "")
169+
.trim();
170+
if (!rawToken || !rawToken.startsWith("tr_pat_")) {
171+
return { ok: false, status: 401, error: "Invalid or Missing PAT" };
172+
}
173+
174+
const hashedToken = createHash("sha256").update(rawToken).digest("hex");
175+
const pat = await this.prisma.personalAccessToken.findFirst({
176+
where: { hashedToken, revokedAt: null },
177+
select: { id: true, userId: true },
178+
});
179+
if (!pat) {
180+
return { ok: false, status: 401, error: "Invalid PAT" };
181+
}
182+
183+
return {
184+
ok: true,
185+
tokenId: pat.id,
186+
userId: pat.userId,
187+
subject: {
188+
type: "personalAccessToken",
189+
tokenId: pat.id,
190+
organizationId: context.organizationId ?? "",
191+
projectId: context.projectId,
192+
},
193+
// No plugin → no role lookup. PATs in the OSS world are pure
194+
// user-identity tokens; the route's own authorization block (or
195+
// the absence of one) decides what they can do, same as it did
196+
// before this method existed.
197+
ability: permissiveAbility,
198+
};
199+
}
200+
160201
async systemRoles(_organizationId: string) {
161202
// No plugin installed → no seeded roles. Callers handle null by
162203
// hiding role-picker UI / skipping role assignment writes.

internal-packages/rbac/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ class LazyController implements RoleBaseAccessController {
149149
return auth;
150150
}
151151

152+
async authenticatePat(...args: Parameters<RoleBaseAccessController["authenticatePat"]>) {
153+
const result = await (await this.c()).authenticatePat(...args);
154+
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
155+
}
156+
152157
async systemRoles(...args: Parameters<RoleBaseAccessController["systemRoles"]>) {
153158
return (await this.c()).systemRoles(...args);
154159
}

packages/plugins/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ export type {
1212
RbacUser,
1313
BearerAuthResult,
1414
SessionAuthResult,
15+
PatAuthResult,
16+
SystemRole,
1517
} from "./rbac.js";

packages/plugins/src/rbac.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export type SessionAuthResult =
106106
| { ok: false; reason: "unauthenticated" | "unauthorized" }
107107
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
108108

109+
// PAT auth deliberately omits `environment` — PATs are user identity
110+
// tokens, not environment tokens. The ability is resolved per-request
111+
// from the user's role in the target org (passed via `context`),
112+
// intersected with the PAT's optional max-role cap.
113+
export type PatAuthResult =
114+
| { ok: false; status: 401 | 403; error: string }
115+
| {
116+
ok: true;
117+
tokenId: string;
118+
userId: string;
119+
subject: RbacSubject;
120+
ability: RbacAbility;
121+
};
122+
109123
export interface RoleBaseAccessController {
110124
// API routes (Bearer token): one DB query → identity + pre-built ability
111125
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
@@ -117,6 +131,21 @@ export interface RoleBaseAccessController {
117131
context: { organizationId?: string; projectId?: string }
118132
): Promise<SessionAuthResult>;
119133

134+
// PAT-authenticated routes (Authorization: Bearer tr_pat_…). The token
135+
// identifies the user; the effective ability is `min(user's current
136+
// role in the target org, the PAT's optional max-role cap)`. The user's
137+
// actual org membership is the floor — if they've been demoted or
138+
// removed, the PAT auto-narrows. The cap is set at PAT creation and
139+
// ceilings the token even when the user is more privileged.
140+
//
141+
// No plugin installed → fallback returns a permissive ability so PAT
142+
// routes that don't yet declare an `authorization` block keep working
143+
// exactly as they did pre-RBAC.
144+
authenticatePat(
145+
request: Request,
146+
context: { organizationId?: string; projectId?: string }
147+
): Promise<PatAuthResult>;
148+
120149
// Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails.
121150
// resource accepts the same single-or-array shape as RbacAbility.can — array form means
122151
// "grant access if any element passes".

0 commit comments

Comments
 (0)