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).
This commit is contained in:
Matt Aitken
2026-06-12 12:44:32 +01:00
committed by GitHub
parent 78b7136bf7
commit 5d6ea33166
4 changed files with 78 additions and 58 deletions
+7 -41
View File
@@ -1,4 +1,10 @@
import type { RbacAbility, RbacResource } from "@trigger.dev/plugins";
import type { RbacAbility } from "@trigger.dev/plugins";
// Scope-string interpretation is shared with any auth plugin via
// @trigger.dev/plugins so a public token decodes identically whoever
// serves the request. Re-exported here so existing importers keep their
// `./ability.js` import.
export { buildJwtAbility } from "@trigger.dev/plugins";
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
export const permissiveAbility: RbacAbility = {
@@ -21,43 +27,3 @@ export const denyAbility: RbacAbility = {
export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
return isAdmin ? superAbility : permissiveAbility;
}
/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */
export function buildJwtAbility(scopes: string[]): RbacAbility {
const matches = (action: string, r: RbacResource): boolean =>
scopes.some((scope) => {
// Only the first two colons are delimiters — everything after the
// second colon is the resource id (which may itself contain colons,
// e.g. user-provided tags like "env:staging"). Naive
// `split(":")` + 3-tuple destructuring truncated such ids to the
// first segment and silently failed to match.
const parts = scope.split(":");
const scopeAction = parts[0];
const scopeType = parts[1];
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
// it falls through to normal matching as action="admin" against
// resources of that type. Pre-RBAC, the legacy checkAuthorization
// string-matched superScopes; `admin:sessions` only granted access
// to routes that explicitly listed it. Treating `admin:<anything>`
// as universal here would silently broaden any such tokens.
if (scopeAction === "admin" && !scopeType) return true;
if (scopeAction !== action && scopeAction !== "*") return false;
if (scopeType === "all") return true;
if (scopeType !== r.type) return false;
if (!scopeId) return true;
return scopeId === r.id;
});
return {
can(action: string, resource: RbacResource | RbacResource[]): boolean {
// Array form means "any element passes → authorized", matching the
// legacy multi-key checkAuthorization semantic.
return Array.isArray(resource)
? resource.some((r) => matches(action, r))
: matches(action, resource);
},
canSuper(): boolean {
return false;
},
};
}
+10 -6
View File
@@ -3,9 +3,7 @@
"version": "4.5.0-rc.5",
"description": "Plugin contracts and interfaces for Trigger.dev",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
@@ -38,9 +36,15 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": {
"@triggerdotdev/source": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
+5 -3
View File
@@ -17,7 +17,9 @@ export type {
AuthenticatedEnvironment,
} from "./rbac.js";
// Convenience re-exports — gives plugin authors (and the cloud workspace
// link) one import surface without reaching into @trigger.dev/core
// directly. Both helpers live in core; this is purely a forwarder.
export { buildJwtAbility } from "./rbac.js";
// Convenience re-exports — give plugin authors one import surface
// without reaching into @trigger.dev/core directly. Both helpers live in
// core; this is purely a forwarder.
export { sanitizeBranchName, isValidGitBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
+56 -8
View File
@@ -3,9 +3,9 @@
* these in canonical order (highest authority first) so the dashboard
* can render columns / build a level ladder without knowing role names.
*
* Roles the plugin doesn't expose at all (e.g. seeded but with the
* `is_hidden` flag set in the cloud plugin) are not returned by
* `systemRoles()` — there's no "advertised but absent" state.
* Roles the plugin chooses not to expose at all (e.g. seeded but hidden)
* are not returned by `systemRoles()` — there's no "advertised but
* absent" state.
*
* `available` indicates whether the role is assignable on the *org's
* plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
@@ -28,9 +28,9 @@ export type Permission = {
// first appear in `allPermissions()`, so the plugin owns both the
// bucket label and the section ordering. Omit for "no grouping".
group?: string;
// Inverted rules (CASL `cannot`) surface as ✗ in the Roles page.
// Inverted (deny) rules surface as ✗ in the Roles page.
inverted?: boolean;
// CASL conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
// Rule conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
// the Roles page renders a tier badge alongside the permission row.
conditions?: Record<string, unknown>;
};
@@ -54,7 +54,7 @@ export type RbacResource = {
// Extra fields a route may pass for condition-based ability checks —
// e.g. `envType` for env-tier-scoped rules ("Member can read envvars
// unless envType === 'PRODUCTION'"). The plugin's ability matcher
// (CASL) reads these off the resource object; routes that don't use
// reads these off the resource object; routes that don't use
// conditional rules can keep passing `{ type, id? }`.
[key: string]: unknown;
};
@@ -89,6 +89,54 @@ export interface RbacAbility {
canSuper(): boolean;
}
/**
* Builds an ability from JWT scope strings like "read:runs",
* "read:runs:run_abc", "read:all", "admin".
*
* This is the single source of truth for interpreting public-token scope
* strings. Both the host's built-in fallback and any auth plugin import it
* from here so a token minted by the host is decoded identically no matter
* which auth path serves the request — two copies of this grammar would
* drift, and the difference would silently change what a token grants.
*/
export function buildJwtAbility(scopes: string[]): RbacAbility {
const matches = (action: string, r: RbacResource): boolean =>
scopes.some((scope) => {
// Only the first two colons are delimiters — everything after the
// second colon is the resource id (which may itself contain colons,
// e.g. user-provided tags like "env:staging"). Naive
// `split(":")` + 3-tuple destructuring truncated such ids to the
// first segment and silently failed to match.
const parts = scope.split(":");
const scopeAction = parts[0];
const scopeType = parts[1];
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
// it falls through to normal matching as action="admin" against
// resources of that type. Treating `admin:<anything>` as universal
// would silently broaden any such tokens beyond the narrow,
// route-listed grant they had before scope-based abilities.
if (scopeAction === "admin" && !scopeType) return true;
if (scopeAction !== action && scopeAction !== "*") return false;
if (scopeType === "all") return true;
if (scopeType !== r.type) return false;
if (!scopeId) return true;
return scopeId === r.id;
});
return {
can(action: string, resource: RbacResource | RbacResource[]): boolean {
// Array form means "any element passes → authorized", matching the
// legacy multi-key authorization semantic.
return Array.isArray(resource)
? resource.some((r) => matches(action, r))
: matches(action, resource);
},
canSuper(): boolean {
return false;
},
};
}
export type BearerAuthResult =
| { ok: false; status: 401 | 403; error: string }
| {
@@ -127,8 +175,8 @@ export type PatAuthResult =
};
export interface RoleBaseAccessController {
// True when a real RBAC plugin is loaded (i.e. cloud); false when the
// OSS fallback is in use. Hosts gate behaviour that's only meaningful
// True when a real RBAC plugin is loaded; false when the built-in
// fallback is in use. Hosts gate behaviour that's only meaningful
// when the plugin is present (e.g. skipping role-attachment writes,
// hiding role-pickers in the UI, branching on whether ability checks
// are authoritative or permissive).