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
48 changes: 7 additions & 41 deletions internal-packages/rbac/src/ability.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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;
},
};
}
16 changes: 10 additions & 6 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
matt-aitken marked this conversation as resolved.
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
Expand Down Expand Up @@ -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"
}
}
}
}
8 changes: 5 additions & 3 deletions packages/plugins/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
64 changes: 56 additions & 8 deletions packages/plugins/src/rbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>;
};
Expand All @@ -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;
};
Expand Down Expand Up @@ -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 }
| {
Expand Down Expand Up @@ -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).
Expand Down
Loading