Skip to content

Commit c9af9a8

Browse files
committed
Update RBAC plugin interface: authenticateBearer/Session, drop PrismaClient from public interface
- Replace buildBearerAbility/buildSessionAbility with authenticateBearer/authenticateSession - Add RbacEnvironment, RbacUser, BearerAuthResult, SessionAuthResult types - Remove PrismaClient from @trigger.dev/plugins interface (no Prisma crossing repo boundary) - Remove @trigger.dev/database dependency and api-extractor from plugins package - Switch plugins build to tsup --dts, delete api-extractor.json and tsconfig.dts.json - OSS fallback imports PrismaClient from @trigger.dev/database directly - OSS loader passes helpers-only to enterprise plugin, (prisma, helpers) to fallback - Add rbac.server.ts singleton to webapp - PoC: migrate admin.concurrency route to rbac.authenticateSession + canSuper()
1 parent 1003e08 commit c9af9a8

15 files changed

Lines changed: 428 additions & 96 deletions

File tree

apps/webapp/app/routes/admin.concurrency.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
44
import { Header1 } from "~/components/primitives/Headers";
55
import { InfoPanel } from "~/components/primitives/InfoPanel";
66
import { Paragraph } from "~/components/primitives/Paragraph";
7-
import { requireUser } from "~/services/session.server";
7+
import { rbac } from "~/services/rbac.server";
88
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
99

10-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
11-
const user = await requireUser(request);
12-
if (!user.admin) {
13-
return redirect("/");
14-
}
10+
export const loader = async ({ request }: LoaderFunctionArgs) => {
11+
const auth = await rbac.authenticateSession(request, {});
12+
if (!auth.ok) return redirect("/login");
13+
if (!auth.ability.canSuper()) return redirect("/");
1514

1615
const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
1716
const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { prisma } from "~/db.server";
2+
import plugin from "@trigger.dev/rbac";
3+
import { getUserId } from "./session.server";
4+
5+
async function getSessionUserId(request: Request): Promise<string | null> {
6+
const id = await getUserId(request);
7+
return id ?? null;
8+
}
9+
10+
export const rbac = await plugin.create(prisma, { getSessionUserId });

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"@trigger.dev/companyicons": "^1.5.35",
125125
"@trigger.dev/core": "workspace:*",
126126
"@trigger.dev/database": "workspace:*",
127+
"@trigger.dev/rbac": "workspace:*",
127128
"@trigger.dev/otlp-importer": "workspace:*",
128129
"@trigger.dev/platform": "1.0.27",
129130
"@trigger.dev/redis-worker": "workspace:*",

internal-packages/rbac/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"name": "@trigger.dev/rbac",
33
"private": true,
44
"version": "0.0.1",
5-
"main": "./dist/index.js",
6-
"types": "./dist/index.d.ts",
5+
"main": "./dist/src/index.js",
6+
"types": "./dist/src/index.d.ts",
77
"dependencies": {
88
"@trigger.dev/plugins": "workspace:*"
99
},
@@ -15,6 +15,8 @@
1515
"clean": "rimraf dist",
1616
"typecheck": "tsc --noEmit",
1717
"build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration",
18-
"dev": "tsc --noEmit false --outDir dist --declaration --watch"
18+
"dev": "tsc --noEmit false --outDir dist --declaration --watch",
19+
"test": "vitest run",
20+
"test:watch": "vitest"
1921
}
2022
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from "vitest";
2+
import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility } from "./ability.js";
3+
4+
describe("permissiveAbility", () => {
5+
it("allows any action on any resource type", () => {
6+
expect(permissiveAbility.can("read", { type: "run" })).toBe(true);
7+
expect(permissiveAbility.can("write", { type: "deployment" })).toBe(true);
8+
expect(permissiveAbility.can("delete", { type: "task" })).toBe(true);
9+
});
10+
11+
it("allows actions on specific resource instances", () => {
12+
expect(permissiveAbility.can("read", { type: "run", id: "run_abc123" })).toBe(true);
13+
});
14+
15+
it("does not grant super-user access", () => {
16+
expect(permissiveAbility.canSuper()).toBe(false);
17+
});
18+
});
19+
20+
describe("superAbility", () => {
21+
it("allows any action on any resource", () => {
22+
expect(superAbility.can("read", { type: "run" })).toBe(true);
23+
expect(superAbility.can("write", { type: "deployment" })).toBe(true);
24+
});
25+
26+
it("grants super-user access", () => {
27+
expect(superAbility.canSuper()).toBe(true);
28+
});
29+
});
30+
31+
describe("denyAbility", () => {
32+
it("denies all actions", () => {
33+
expect(denyAbility.can("read", { type: "run" })).toBe(false);
34+
expect(denyAbility.can("write", { type: "deployment" })).toBe(false);
35+
});
36+
37+
it("does not grant super-user access", () => {
38+
expect(denyAbility.canSuper()).toBe(false);
39+
});
40+
});
41+
42+
describe("buildFallbackAbility", () => {
43+
it("returns permissiveAbility for non-admin users", () => {
44+
const ability = buildFallbackAbility(false);
45+
expect(ability.can("read", { type: "run" })).toBe(true);
46+
expect(ability.canSuper()).toBe(false);
47+
});
48+
49+
it("returns superAbility for admin users", () => {
50+
const ability = buildFallbackAbility(true);
51+
expect(ability.can("read", { type: "run" })).toBe(true);
52+
expect(ability.canSuper()).toBe(true);
53+
});
54+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { RbacAbility } from "@trigger.dev/plugins";
2+
3+
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
4+
export const permissiveAbility: RbacAbility = {
5+
can: () => true,
6+
canSuper: () => false,
7+
};
8+
9+
/** Platform admin (user.admin = true): can do everything including super-user actions. */
10+
export const superAbility: RbacAbility = {
11+
can: () => true,
12+
canSuper: () => true,
13+
};
14+
15+
/** Deprecated PUBLIC tokens and unauthenticated subjects: denied everything. */
16+
export const denyAbility: RbacAbility = {
17+
can: () => false,
18+
canSuper: () => false,
19+
};
20+
21+
export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
22+
return isAdmin ? superAbility : permissiveAbility;
23+
}
Lines changed: 181 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,200 @@
11
import type {
22
Permission,
33
Role,
4+
RbacEnvironment,
5+
RbacUser,
6+
RbacSubject,
7+
RbacResource,
8+
BearerAuthResult,
9+
SessionAuthResult,
410
RoleBaseAccessController,
5-
RoleBasedAccessControlPlugin,
611
} from "@trigger.dev/plugins";
12+
import type { PrismaClient } from "@trigger.dev/database";
13+
import { buildFallbackAbility, permissiveAbility } from "./ability.js";
714

8-
export class RoleBaseAccessFallback implements RoleBasedAccessControlPlugin {
9-
async create() {
10-
return new RoleBaseAccessFallbackController();
15+
export class RoleBaseAccessFallback {
16+
constructor(private readonly prisma: PrismaClient) {}
17+
18+
create(
19+
helpers: { getSessionUserId: (request: Request) => Promise<string | null> }
20+
): RoleBaseAccessFallbackController {
21+
return new RoleBaseAccessFallbackController(this.prisma, helpers);
1122
}
1223
}
1324

14-
const accountWildcard: Permission = {
15-
name: "*:account",
16-
description: "Full abilities for an account",
17-
};
25+
class RoleBaseAccessFallbackController implements RoleBaseAccessController {
26+
constructor(
27+
private readonly prisma: PrismaClient,
28+
private readonly helpers: { getSessionUserId: (request: Request) => Promise<string | null> }
29+
) {}
30+
31+
async authenticateBearer(request: Request): Promise<BearerAuthResult> {
32+
const apiKey = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
33+
if (!apiKey) return { ok: false, status: 401, error: "Invalid or Missing API key" };
1834

19-
const superWildcard: Permission = {
20-
name: "*:super",
21-
description: "Full abilities for a super user",
22-
};
35+
const env = await this.prisma.runtimeEnvironment.findFirst({
36+
where: { apiKey },
37+
include: {
38+
project: true,
39+
organization: true,
40+
orgMember: { select: { userId: true } },
41+
},
42+
});
2343

24-
const owner: Role = {
25-
name: "owner",
26-
description: "Full access to all features",
27-
permissions: [accountWildcard, superWildcard],
28-
};
44+
if (!env || env.project.deletedAt !== null) {
45+
return { ok: false, status: 401, error: "Invalid API key" };
46+
}
2947

30-
const superAdmin: Role = {
31-
name: "super_admin",
32-
description: "Full access to all features and the ability to manage the Trigger.dev platform",
33-
permissions: [accountWildcard, superWildcard],
34-
};
48+
const subject: RbacSubject = {
49+
type: "user",
50+
userId: env.orgMember?.userId ?? "",
51+
organizationId: env.organizationId,
52+
projectId: env.projectId,
53+
};
54+
55+
return {
56+
ok: true,
57+
environment: toRbacEnvironment(env),
58+
subject,
59+
ability: permissiveAbility,
60+
};
61+
}
62+
63+
async authenticateSession(
64+
request: Request,
65+
context: { organizationId?: string; projectId?: string }
66+
): Promise<SessionAuthResult> {
67+
const userId = await this.helpers.getSessionUserId(request);
68+
if (!userId) return { ok: false, reason: "unauthenticated" };
69+
70+
const user = await this.prisma.user.findFirst({ where: { id: userId } });
71+
if (!user) return { ok: false, reason: "unauthenticated" };
72+
73+
const subject: RbacSubject = {
74+
type: "user",
75+
userId: user.id,
76+
organizationId: context.organizationId ?? "",
77+
projectId: context.projectId,
78+
};
79+
80+
return {
81+
ok: true,
82+
user: toRbacUser(user),
83+
subject,
84+
ability: buildFallbackAbility(user.admin),
85+
};
86+
}
87+
88+
async authenticateAuthorizeBearer(
89+
request: Request,
90+
check: { action: string; resource: RbacResource }
91+
): Promise<BearerAuthResult> {
92+
const auth = await this.authenticateBearer(request);
93+
if (!auth.ok) return auth;
94+
if (!auth.ability.can(check.action, check.resource)) {
95+
return { ok: false, status: 403, error: "Unauthorized" };
96+
}
97+
return auth;
98+
}
99+
100+
async authenticateAuthorizeSession(
101+
request: Request,
102+
context: { organizationId?: string; projectId?: string },
103+
check: { action: string; resource: RbacResource }
104+
): Promise<SessionAuthResult> {
105+
const auth = await this.authenticateSession(request, context);
106+
if (!auth.ok) return auth;
107+
if (!auth.ability.can(check.action, check.resource)) {
108+
return { ok: false, reason: "unauthorized" };
109+
}
110+
return auth;
111+
}
35112

36-
class RoleBaseAccessFallbackController implements RoleBaseAccessController {
37113
async allPermissions(): Promise<Permission[]> {
38-
return [owner, superAdmin];
114+
return [];
39115
}
40116

41117
async allRoles(): Promise<Role[]> {
42-
return [owner, superAdmin];
118+
return [];
43119
}
120+
121+
async createRole(): Promise<Role> {
122+
throw new Error("RBAC plugin not installed");
123+
}
124+
125+
async updateRole(): Promise<Role> {
126+
throw new Error("RBAC plugin not installed");
127+
}
128+
129+
async deleteRole(): Promise<void> {}
130+
131+
async getUserRole(): Promise<Role | null> {
132+
return null;
133+
}
134+
135+
async setUserRole(): Promise<void> {}
136+
async removeUserRole(): Promise<void> {}
137+
138+
async getTokenRole(): Promise<Role | null> {
139+
return null;
140+
}
141+
142+
async setTokenRole(): Promise<void> {}
143+
async removeTokenRole(): Promise<void> {}
144+
}
145+
146+
function toRbacEnvironment(
147+
env: {
148+
id: string;
149+
slug: string;
150+
type: string;
151+
apiKey: string;
152+
pkApiKey: string;
153+
organizationId: string;
154+
projectId: string;
155+
organization: { id: string; slug: string; title: string };
156+
project: { id: string; slug: string; name: string; externalRef: string };
157+
}
158+
): RbacEnvironment {
159+
return {
160+
id: env.id,
161+
slug: env.slug,
162+
type: env.type,
163+
apiKey: env.apiKey,
164+
pkApiKey: env.pkApiKey,
165+
organizationId: env.organizationId,
166+
projectId: env.projectId,
167+
organization: {
168+
id: env.organization.id,
169+
slug: env.organization.slug,
170+
title: env.organization.title,
171+
},
172+
project: {
173+
id: env.project.id,
174+
slug: env.project.slug,
175+
name: env.project.name,
176+
externalRef: env.project.externalRef,
177+
},
178+
};
179+
}
180+
181+
function toRbacUser(user: {
182+
id: string;
183+
email: string;
184+
name: string | null;
185+
displayName: string | null;
186+
avatarUrl: string | null;
187+
admin: boolean;
188+
confirmedBasicDetails: boolean;
189+
}): RbacUser {
190+
return {
191+
id: user.id,
192+
email: user.email,
193+
name: user.name,
194+
displayName: user.displayName,
195+
avatarUrl: user.avatarUrl,
196+
admin: user.admin,
197+
confirmedBasicDetails: user.confirmedBasicDetails,
198+
isImpersonating: false,
199+
};
44200
}

0 commit comments

Comments
 (0)