Skip to content

Commit 5d6ea33

Browse files
authored
refactor: share the public-token JWT scope decoder; make @trigger.dev/plugins internal (#3919)
## What `buildJwtAbility` — the decoder for public-token scope strings (`read:tags:…`, `read:runs:run_abc`, `admin`, …) — now lives in `@trigger.dev/plugins` as the single source of truth. `@trigger.dev/rbac` re-exports it, so the built-in fallback and any auth plugin interpret a token identically. Scope strings are split on only the first **two** colons (`action:type:id`), so a resource id that itself contains colons — e.g. a tag like `user:123` — is matched in full rather than truncated to its first segment. (The fallback already did this; this makes it the one shared implementation.) `@trigger.dev/plugins` is now **private (unpublished)** and gains a `@triggerdotdev/source` export condition, so consumers bundle it from source per-commit like `@trigger.dev/core` instead of resolving a published version — no cross-version coordination. ## Why Two hand-maintained copies of the scope grammar drift, and the difference silently changes what a token grants. One shared decoder removes that class of bug. ## Notes - No changeset: `@trigger.dev/plugins` is now private and `@trigger.dev/rbac` is internal — neither is published. - Unit coverage for the colon-id path lives in `internal-packages/rbac/src/ability.test.ts` (now exercising the shared function).
1 parent 78b7136 commit 5d6ea33

4 files changed

Lines changed: 78 additions & 58 deletions

File tree

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { RbacAbility, RbacResource } from "@trigger.dev/plugins";
1+
import type { RbacAbility } from "@trigger.dev/plugins";
2+
3+
// Scope-string interpretation is shared with any auth plugin via
4+
// @trigger.dev/plugins so a public token decodes identically whoever
5+
// serves the request. Re-exported here so existing importers keep their
6+
// `./ability.js` import.
7+
export { buildJwtAbility } from "@trigger.dev/plugins";
28

39
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
410
export const permissiveAbility: RbacAbility = {
@@ -21,43 +27,3 @@ export const denyAbility: RbacAbility = {
2127
export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
2228
return isAdmin ? superAbility : permissiveAbility;
2329
}
24-
25-
/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */
26-
export function buildJwtAbility(scopes: string[]): RbacAbility {
27-
const matches = (action: string, r: RbacResource): boolean =>
28-
scopes.some((scope) => {
29-
// Only the first two colons are delimiters — everything after the
30-
// second colon is the resource id (which may itself contain colons,
31-
// e.g. user-provided tags like "env:staging"). Naive
32-
// `split(":")` + 3-tuple destructuring truncated such ids to the
33-
// first segment and silently failed to match.
34-
const parts = scope.split(":");
35-
const scopeAction = parts[0];
36-
const scopeType = parts[1];
37-
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
38-
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
39-
// it falls through to normal matching as action="admin" against
40-
// resources of that type. Pre-RBAC, the legacy checkAuthorization
41-
// string-matched superScopes; `admin:sessions` only granted access
42-
// to routes that explicitly listed it. Treating `admin:<anything>`
43-
// as universal here would silently broaden any such tokens.
44-
if (scopeAction === "admin" && !scopeType) return true;
45-
if (scopeAction !== action && scopeAction !== "*") return false;
46-
if (scopeType === "all") return true;
47-
if (scopeType !== r.type) return false;
48-
if (!scopeId) return true;
49-
return scopeId === r.id;
50-
});
51-
return {
52-
can(action: string, resource: RbacResource | RbacResource[]): boolean {
53-
// Array form means "any element passes → authorized", matching the
54-
// legacy multi-key checkAuthorization semantic.
55-
return Array.isArray(resource)
56-
? resource.some((r) => matches(action, r))
57-
: matches(action, resource);
58-
},
59-
canSuper(): boolean {
60-
return false;
61-
},
62-
};
63-
}

packages/plugins/package.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
"version": "4.5.0-rc.5",
44
"description": "Plugin contracts and interfaces for Trigger.dev",
55
"license": "MIT",
6-
"publishConfig": {
7-
"access": "public"
8-
},
6+
"private": true,
97
"repository": {
108
"type": "git",
119
"url": "https://github.com/triggerdotdev/trigger.dev",
@@ -38,9 +36,15 @@
3836
"types": "./dist/index.d.ts",
3937
"exports": {
4038
".": {
41-
"types": "./dist/index.d.ts",
42-
"import": "./dist/index.js",
43-
"require": "./dist/index.cjs"
39+
"import": {
40+
"@triggerdotdev/source": "./src/index.ts",
41+
"types": "./dist/index.d.ts",
42+
"default": "./dist/index.js"
43+
},
44+
"require": {
45+
"types": "./dist/index.d.cts",
46+
"default": "./dist/index.cjs"
47+
}
4448
}
4549
}
4650
}

packages/plugins/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export type {
1717
AuthenticatedEnvironment,
1818
} from "./rbac.js";
1919

20-
// Convenience re-exports — gives plugin authors (and the cloud workspace
21-
// link) one import surface without reaching into @trigger.dev/core
22-
// directly. Both helpers live in core; this is purely a forwarder.
20+
export { buildJwtAbility } from "./rbac.js";
21+
22+
// Convenience re-exports — give plugin authors one import surface
23+
// without reaching into @trigger.dev/core directly. Both helpers live in
24+
// core; this is purely a forwarder.
2325
export { sanitizeBranchName, isValidGitBranchName } from "@trigger.dev/core/v3/utils/gitBranch";

packages/plugins/src/rbac.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
* these in canonical order (highest authority first) so the dashboard
44
* can render columns / build a level ladder without knowing role names.
55
*
6-
* Roles the plugin doesn't expose at all (e.g. seeded but with the
7-
* `is_hidden` flag set in the cloud plugin) are not returned by
8-
* `systemRoles()` — there's no "advertised but absent" state.
6+
* Roles the plugin chooses not to expose at all (e.g. seeded but hidden)
7+
* are not returned by `systemRoles()` — there's no "advertised but
8+
* absent" state.
99
*
1010
* `available` indicates whether the role is assignable on the *org's
1111
* plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
@@ -28,9 +28,9 @@ export type Permission = {
2828
// first appear in `allPermissions()`, so the plugin owns both the
2929
// bucket label and the section ordering. Omit for "no grouping".
3030
group?: string;
31-
// Inverted rules (CASL `cannot`) surface as ✗ in the Roles page.
31+
// Inverted (deny) rules surface as ✗ in the Roles page.
3232
inverted?: boolean;
33-
// CASL conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
33+
// Rule conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
3434
// the Roles page renders a tier badge alongside the permission row.
3535
conditions?: Record<string, unknown>;
3636
};
@@ -54,7 +54,7 @@ export type RbacResource = {
5454
// Extra fields a route may pass for condition-based ability checks —
5555
// e.g. `envType` for env-tier-scoped rules ("Member can read envvars
5656
// unless envType === 'PRODUCTION'"). The plugin's ability matcher
57-
// (CASL) reads these off the resource object; routes that don't use
57+
// reads these off the resource object; routes that don't use
5858
// conditional rules can keep passing `{ type, id? }`.
5959
[key: string]: unknown;
6060
};
@@ -89,6 +89,54 @@ export interface RbacAbility {
8989
canSuper(): boolean;
9090
}
9191

92+
/**
93+
* Builds an ability from JWT scope strings like "read:runs",
94+
* "read:runs:run_abc", "read:all", "admin".
95+
*
96+
* This is the single source of truth for interpreting public-token scope
97+
* strings. Both the host's built-in fallback and any auth plugin import it
98+
* from here so a token minted by the host is decoded identically no matter
99+
* which auth path serves the request — two copies of this grammar would
100+
* drift, and the difference would silently change what a token grants.
101+
*/
102+
export function buildJwtAbility(scopes: string[]): RbacAbility {
103+
const matches = (action: string, r: RbacResource): boolean =>
104+
scopes.some((scope) => {
105+
// Only the first two colons are delimiters — everything after the
106+
// second colon is the resource id (which may itself contain colons,
107+
// e.g. user-provided tags like "env:staging"). Naive
108+
// `split(":")` + 3-tuple destructuring truncated such ids to the
109+
// first segment and silently failed to match.
110+
const parts = scope.split(":");
111+
const scopeAction = parts[0];
112+
const scopeType = parts[1];
113+
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
114+
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
115+
// it falls through to normal matching as action="admin" against
116+
// resources of that type. Treating `admin:<anything>` as universal
117+
// would silently broaden any such tokens beyond the narrow,
118+
// route-listed grant they had before scope-based abilities.
119+
if (scopeAction === "admin" && !scopeType) return true;
120+
if (scopeAction !== action && scopeAction !== "*") return false;
121+
if (scopeType === "all") return true;
122+
if (scopeType !== r.type) return false;
123+
if (!scopeId) return true;
124+
return scopeId === r.id;
125+
});
126+
return {
127+
can(action: string, resource: RbacResource | RbacResource[]): boolean {
128+
// Array form means "any element passes → authorized", matching the
129+
// legacy multi-key authorization semantic.
130+
return Array.isArray(resource)
131+
? resource.some((r) => matches(action, r))
132+
: matches(action, resource);
133+
},
134+
canSuper(): boolean {
135+
return false;
136+
},
137+
};
138+
}
139+
92140
export type BearerAuthResult =
93141
| { ok: false; status: 401 | 403; error: string }
94142
| {
@@ -127,8 +175,8 @@ export type PatAuthResult =
127175
};
128176

129177
export interface RoleBaseAccessController {
130-
// True when a real RBAC plugin is loaded (i.e. cloud); false when the
131-
// OSS fallback is in use. Hosts gate behaviour that's only meaningful
178+
// True when a real RBAC plugin is loaded; false when the built-in
179+
// fallback is in use. Hosts gate behaviour that's only meaningful
132180
// when the plugin is present (e.g. skipping role-attachment writes,
133181
// hiding role-pickers in the UI, branching on whether ability checks
134182
// are authoritative or permissive).

0 commit comments

Comments
 (0)