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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user