Skip to content

Commit e154745

Browse files
committed
RBAC plugin: array resources + action alias wrapper (TRI-8719 Phase A)
Foundational changes before swapping apiBuilder to rbac.authenticateBearer. No behaviour change yet — apiBuilder is still on the legacy path. Array resources: - @trigger.dev/plugins RbacAbility.can now accepts RbacResource | RbacResource[]. Array form means 'grant access if any element passes', preserving the legacy checkAuthorization multi-key semantic once TRI-8719 completes. - internal-packages/rbac ability.ts: permissive/super/deny pass through unchanged; buildJwtAbility iterates the array and short-circuits on first match. Action alias wrapper (internal-packages/rbac/src/index.ts): - ACTION_ALIASES map + withActionAliases function. Wraps an underlying RbacAbility so that can(action, resource) retries with alias actions when the direct check fails. Currently: trigger, batchTrigger, update are all satisfied by a scope whose action is write — matching legacy superScope behaviour for route.action values that don't align with scope prefixes. - LazyController wraps the ability it gets from authenticateBearer / authenticateSession. authenticateAuthorize* stop delegating to the underlying's own Authorize methods (that would bypass the wrapper) and instead do the inline ability.can check against the wrapped ability. The enterprise plugin (TRI-8720) does not need to know about aliases — the wrapper applies uniformly regardless of which ability came back. Tests: - ability.test.ts: +4 tests for array resource form (31 total in file). - loader.test.ts: +11 tests for withActionAliases (direct match, alias retry for trigger/batchTrigger/update, id-scoped retry, admin passes, array form retry, canSuper delegation). - Unit suite: 31 tests, all passing. - Webapp typecheck: clean.
1 parent 399e4b1 commit e154745

5 files changed

Lines changed: 188 additions & 22 deletions

File tree

internal-packages/rbac/src/ability.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ describe("buildJwtAbility", () => {
8383
});
8484
});
8585

86+
describe("buildJwtAbility — array resources", () => {
87+
it("authorizes when any resource in the array passes a scope check", () => {
88+
const ability = buildJwtAbility(["read:batch:batch_abc"]);
89+
const resources = [
90+
{ type: "runs", id: "run_xyz" },
91+
{ type: "batch", id: "batch_abc" },
92+
{ type: "tasks", id: "task_other" },
93+
];
94+
expect(ability.can("read", resources)).toBe(true);
95+
});
96+
97+
it("rejects when no resource in the array passes a scope check", () => {
98+
const ability = buildJwtAbility(["read:batch:batch_abc"]);
99+
const resources = [
100+
{ type: "runs", id: "run_xyz" },
101+
{ type: "batch", id: "batch_other" },
102+
{ type: "tasks", id: "task_other" },
103+
];
104+
expect(ability.can("read", resources)).toBe(false);
105+
});
106+
107+
it("empty array never authorizes", () => {
108+
const ability = buildJwtAbility(["read:all"]);
109+
expect(ability.can("read", [])).toBe(false);
110+
});
111+
112+
it("authorizes a single resource via the non-array form (backwards compatible)", () => {
113+
const ability = buildJwtAbility(["read:runs:run_abc"]);
114+
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
115+
});
116+
});
117+
86118
describe("buildFallbackAbility", () => {
87119
it("returns permissiveAbility for non-admin users", () => {
88120
const ability = buildFallbackAbility(false);

internal-packages/rbac/src/ability.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import type { RbacAbility } from "@trigger.dev/plugins";
1+
import type { RbacAbility, RbacResource } from "@trigger.dev/plugins";
2+
3+
// Applies a per-resource predicate across single or multi-resource inputs.
4+
// Array form means "any element passes → authorized", matching the legacy
5+
// multi-key checkAuthorization semantic.
6+
function anyResource(
7+
resource: RbacResource | RbacResource[],
8+
predicate: (r: RbacResource) => boolean
9+
): boolean {
10+
return Array.isArray(resource) ? resource.some(predicate) : predicate(resource);
11+
}
212

313
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
414
export const permissiveAbility: RbacAbility = {
@@ -25,16 +35,18 @@ export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
2535
/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */
2636
export function buildJwtAbility(scopes: string[]): RbacAbility {
2737
return {
28-
can(action: string, resource: { type: string; id?: string }): boolean {
29-
return scopes.some((scope) => {
30-
const [scopeAction, scopeType, scopeId] = scope.split(":");
31-
if (scopeAction === "admin") return true;
32-
if (scopeAction !== action && scopeAction !== "*") return false;
33-
if (scopeType === "all") return true;
34-
if (scopeType !== resource.type) return false;
35-
if (!scopeId) return true;
36-
return scopeId === resource.id;
37-
});
38+
can(action: string, resource: RbacResource | RbacResource[]): boolean {
39+
return anyResource(resource, (r) =>
40+
scopes.some((scope) => {
41+
const [scopeAction, scopeType, scopeId] = scope.split(":");
42+
if (scopeAction === "admin") return true;
43+
if (scopeAction !== action && scopeAction !== "*") return false;
44+
if (scopeType === "all") return true;
45+
if (scopeType !== r.type) return false;
46+
if (!scopeId) return true;
47+
return scopeId === r.id;
48+
})
49+
);
3850
},
3951
canSuper(): boolean {
4052
return false;

internal-packages/rbac/src/index.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Permission,
3+
RbacAbility,
34
Role,
45
RbacResource,
56
RoleBaseAccessController,
@@ -17,6 +18,31 @@ export type RbacCreateOptions = {
1718
forceFallback?: boolean;
1819
};
1920

21+
// Route actions that historically authorised via the legacy checkAuthorization's
22+
// superScopes escape hatch — e.g. a JWT with scope "write:tasks" was accepted by
23+
// a route with action: "trigger" because "write:tasks" was listed in the route's
24+
// superScopes array. The new ability model matches scope-action strictly, so we
25+
// restore the prior semantic here: when the underlying ability denies for action
26+
// X, retry with each aliased action. The retry covers both OSS fallback
27+
// (scope-based buildJwtAbility) and enterprise (DB/CASL-based) paths
28+
// transparently — neither implementation needs to know about aliases.
29+
const ACTION_ALIASES: Record<string, readonly string[]> = {
30+
trigger: ["write"],
31+
batchTrigger: ["write"],
32+
update: ["write"],
33+
};
34+
35+
export function withActionAliases(underlying: RbacAbility): RbacAbility {
36+
return {
37+
can(action: string, resource: RbacResource | RbacResource[]): boolean {
38+
if (underlying.can(action, resource)) return true;
39+
const aliases = ACTION_ALIASES[action] ?? [];
40+
return aliases.some((a) => underlying.can(a, resource));
41+
},
42+
canSuper: () => underlying.canSuper(),
43+
};
44+
}
45+
2046
// Loads the enterprise plugin lazily; falls back to the OSS implementation if not installed.
2147
// Synchronous create() avoids top-level await (not supported in the webapp's CJS build).
2248
class LazyController implements RoleBaseAccessController {
@@ -49,19 +75,42 @@ class LazyController implements RoleBaseAccessController {
4975
}
5076

5177
async authenticateBearer(...args: Parameters<RoleBaseAccessController["authenticateBearer"]>) {
52-
return (await this.c()).authenticateBearer(...args);
78+
const result = await (await this.c()).authenticateBearer(...args);
79+
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
5380
}
5481

5582
async authenticateSession(...args: Parameters<RoleBaseAccessController["authenticateSession"]>) {
56-
return (await this.c()).authenticateSession(...args);
57-
}
58-
59-
async authenticateAuthorizeBearer(...args: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>) {
60-
return (await this.c()).authenticateAuthorizeBearer(...args);
61-
}
62-
63-
async authenticateAuthorizeSession(...args: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>) {
64-
return (await this.c()).authenticateAuthorizeSession(...args);
83+
const result = await (await this.c()).authenticateSession(...args);
84+
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
85+
}
86+
87+
// Don't delegate to the underlying Authorize variants — that would run the
88+
// inline ability check against the unwrapped ability. Use our wrapped
89+
// authenticate* and do the ability check here instead.
90+
async authenticateAuthorizeBearer(
91+
request: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[0],
92+
check: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[1],
93+
options?: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[2]
94+
) {
95+
const auth = await this.authenticateBearer(request, options);
96+
if (!auth.ok) return auth;
97+
if (!auth.ability.can(check.action, check.resource)) {
98+
return { ok: false as const, status: 403 as const, error: "Unauthorized" };
99+
}
100+
return auth;
101+
}
102+
103+
async authenticateAuthorizeSession(
104+
request: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[0],
105+
context: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[1],
106+
check: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[2]
107+
) {
108+
const auth = await this.authenticateSession(request, context);
109+
if (!auth.ok) return auth;
110+
if (!auth.ability.can(check.action, check.resource)) {
111+
return { ok: false as const, reason: "unauthorized" as const };
112+
}
113+
return auth;
65114
}
66115

67116
async allPermissions(...args: Parameters<RoleBaseAccessController["allPermissions"]>): Promise<Permission[]> {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { RbacAbility } from "@trigger.dev/plugins";
2+
import { describe, expect, it } from "vitest";
3+
import { buildJwtAbility } from "./ability.js";
4+
import { withActionAliases } from "./index.js";
5+
6+
describe("withActionAliases", () => {
7+
it("direct action match passes through unchanged", () => {
8+
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
9+
expect(ability.can("write", { type: "tasks", id: "task_x" })).toBe(true);
10+
});
11+
12+
it("trigger action is satisfied by a write:tasks scope (alias retry)", () => {
13+
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
14+
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
15+
});
16+
17+
it("batchTrigger action is satisfied by a write:tasks scope (alias retry)", () => {
18+
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
19+
expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
20+
});
21+
22+
it("update action is satisfied by a write:prompts scope (alias retry)", () => {
23+
const ability = withActionAliases(buildJwtAbility(["write:prompts"]));
24+
expect(ability.can("update", { type: "prompts", id: "p_x" })).toBe(true);
25+
});
26+
27+
it("id-scoped write scope satisfies the aliased action on matching id", () => {
28+
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
29+
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
30+
});
31+
32+
it("id-scoped write scope denies the aliased action on a different id", () => {
33+
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
34+
expect(ability.can("trigger", { type: "tasks", id: "task_other" })).toBe(false);
35+
});
36+
37+
it("read scope does not satisfy a trigger action (aliases are write-only)", () => {
38+
const ability = withActionAliases(buildJwtAbility(["read:tasks"]));
39+
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(false);
40+
});
41+
42+
it("non-aliased custom action only matches its direct action scope", () => {
43+
const ability = withActionAliases(buildJwtAbility(["read:runs"]));
44+
expect(ability.can("someOtherAction", { type: "runs", id: "run_x" })).toBe(false);
45+
});
46+
47+
it("admin scope continues to grant everything regardless of aliases", () => {
48+
const ability = withActionAliases(buildJwtAbility(["admin"]));
49+
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
50+
expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
51+
expect(ability.can("anything", { type: "whatever", id: "x" })).toBe(true);
52+
});
53+
54+
it("array resource form: alias retry applies when any element passes", () => {
55+
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
56+
const resources = [
57+
{ type: "tasks", id: "task_other" },
58+
{ type: "tasks", id: "task_x" },
59+
];
60+
expect(ability.can("trigger", resources)).toBe(true);
61+
});
62+
63+
it("canSuper is delegated unchanged", () => {
64+
const allowSuper: RbacAbility = { can: () => false, canSuper: () => true };
65+
const denySuper: RbacAbility = { can: () => false, canSuper: () => false };
66+
expect(withActionAliases(allowSuper).canSuper()).toBe(true);
67+
expect(withActionAliases(denySuper).canSuper()).toBe(false);
68+
});
69+
});

packages/plugins/src/rbac.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ export type RbacUser = {
4646

4747
/** Pre-built ability returned by authenticate* — all checks are sync, no DB call. */
4848
export interface RbacAbility {
49-
can(action: string, resource: RbacResource): boolean;
49+
// Array form means "grant access if any resource in the array passes" —
50+
// used by routes that touch multiple resources (e.g. a run also carries
51+
// a batch id, tags, a task identifier) so a JWT scoped to any of them
52+
// grants access.
53+
can(action: string, resource: RbacResource | RbacResource[]): boolean;
5054
canSuper(): boolean;
5155
}
5256

0 commit comments

Comments
 (0)