-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathindex.ts
More file actions
279 lines (245 loc) · 10.7 KB
/
Copy pathindex.ts
File metadata and controls
279 lines (245 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
import type {
Permission,
RbacAbility,
Role,
RbacResource,
RoleAssignmentResult,
RoleBaseAccessController,
RoleBasedAccessControlPlugin,
RoleMutationResult,
} from "@trigger.dev/plugins";
import type { PrismaClient } from "@trigger.dev/database";
import { RoleBaseAccessFallback } from "./fallback.js";
export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
// Either a single PrismaClient (used for both writes and reads — fine
// for callers that don't have a separate replica), or `{primary, replica}`
// where reads on the auth hot path go to the replica. The fallback
// reads on every request, so callers with a replica should pass both.
export type RbacPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient };
export type RbacCreateOptions = {
// When true, skip loading the plugin, useful for tests
forceFallback?: boolean;
};
// Route actions that historically authorised via the legacy checkAuthorization's
// superScopes escape hatch — e.g. a JWT with scope "write:tasks" was accepted by
// a route with action: "trigger" because "write:tasks" was listed in the route's
// superScopes array. The new ability model matches scope-action strictly, so we
// restore the prior semantic here: when the underlying ability denies for action
// X, retry with each aliased action.
const ACTION_ALIASES: Record<string, readonly string[]> = {
trigger: ["write"],
batchTrigger: ["write"],
update: ["write"],
};
export function withActionAliases(underlying: RbacAbility): RbacAbility {
return {
can(action: string, resource: RbacResource | RbacResource[]): boolean {
if (underlying.can(action, resource)) return true;
const aliases = ACTION_ALIASES[action] ?? [];
return aliases.some((a) => underlying.can(a, resource));
},
canSuper: () => underlying.canSuper(),
};
}
// Loads the plugin lazily; falls back to the fallback implementation if not installed.
// Synchronous create() avoids top-level await (not supported in the webapp's CJS build).
class LazyController implements RoleBaseAccessController {
private readonly _init: Promise<RoleBaseAccessController>;
constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) {
this._init = this.load(prisma, options);
// load() runs eagerly but the result is awaited lazily on first method
// call. If load() rejects (e.g. REQUIRE_PLUGINS=1 + plugin missing) and
// nothing awaits _init before Node ticks past, the rejection surfaces
// as unhandledRejection and kills the process. Attach a no-op .catch
// so Node sees the rejection as handled; the error is re-thrown when
// any consumer awaits this._init via c().
this._init.catch(() => {});
}
private async load(
prisma: RbacPrismaInput,
options?: RbacCreateOptions
): Promise<RoleBaseAccessController> {
if (options?.forceFallback) {
return new RoleBaseAccessFallback(prisma).create();
}
const moduleName = "@triggerdotdev/plugins/rbac";
try {
const module = await import(moduleName);
const plugin: RoleBasedAccessControlPlugin = module.default;
console.log("RBAC: using plugin implementation");
return plugin.create();
} catch (err) {
// The dynamic import either succeeded or failed for one of two
// distinct reasons. Distinguishing them is critical for debugging
// — silently swallowing the error here is what produced "why is
// the fallback being used?" mysteries before.
//
// 1. The plugin itself is absent (no install) — expected.
// Logged at info level only when RBAC_LOG_FALLBACK=1 so
// production logs stay quiet.
// 2. Anything else (transitive dep missing, init error, syntax
// error in the plugin's dist, etc.) — a real bug. Always
// logged loudly so it surfaces in CI / production logs.
//
// Node throws ERR_MODULE_NOT_FOUND for both cases — the *plugin*
// module being absent and a *transitive* dep of the plugin
// being absent. Disambiguate by checking whether the missing
// specifier in the error message is the plugin's own moduleName.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const message = err instanceof Error ? err.message : String(err);
const isModuleNotFound =
code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
const isPluginItselfMissing =
isModuleNotFound && message.includes(moduleName);
if (!isPluginItselfMissing) {
// Either the error wasn't a missing-module error at all, or the
// plugin was found but a transitive dep failed to resolve.
// Either way: a real problem worth surfacing.
console.error(
"RBAC: plugin found but failed to load; falling back to default implementation",
err
);
} else if (process.env.RBAC_LOG_FALLBACK === "1") {
console.log(
"RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback"
);
}
// Fail-fast for deployments that require plugins to be present. Set
// REQUIRE_PLUGINS=1 in environments where the fallback is not an
// acceptable degraded state — the throw surfaces on the first method
// call on the lazy controller (e.g. via the webapp's /healthcheck
// route), so the rollout's readiness probe fails and the deploy is
// rolled back. Self-hosters leave REQUIRE_PLUGINS unset and continue
// to use the fallback when no plugin is installed.
if (process.env.REQUIRE_PLUGINS === "1") {
throw new Error(
`REQUIRE_PLUGINS=1 but plugin "${moduleName}" did not load: ${message}`
);
}
return new RoleBaseAccessFallback(prisma).create();
}
}
private async c(): Promise<RoleBaseAccessController> {
return this._init;
}
async isUsingPlugin(): Promise<boolean> {
return (await this.c()).isUsingPlugin();
}
async authenticateBearer(...args: Parameters<RoleBaseAccessController["authenticateBearer"]>) {
const result = await (await this.c()).authenticateBearer(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
async authenticateSession(...args: Parameters<RoleBaseAccessController["authenticateSession"]>) {
const result = await (await this.c()).authenticateSession(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
// Don't delegate to the underlying Authorize variants — that would run the
// inline ability check against the unwrapped ability. Use our wrapped
// authenticate* and do the ability check here instead.
async authenticateAuthorizeBearer(
request: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[0],
check: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[1],
options?: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[2]
) {
const auth = await this.authenticateBearer(request, options);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false as const, status: 403 as const, error: "Unauthorized" };
}
return auth;
}
async authenticateAuthorizeSession(
request: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[0],
context: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[1],
check: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[2]
) {
const auth = await this.authenticateSession(request, context);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false as const, reason: "unauthorized" as const };
}
return auth;
}
async authenticatePat(...args: Parameters<RoleBaseAccessController["authenticatePat"]>) {
const result = await (await this.c()).authenticatePat(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
async systemRoles(...args: Parameters<RoleBaseAccessController["systemRoles"]>) {
return (await this.c()).systemRoles(...args);
}
async allPermissions(
...args: Parameters<RoleBaseAccessController["allPermissions"]>
): Promise<Permission[]> {
return (await this.c()).allPermissions(...args);
}
async allRoles(...args: Parameters<RoleBaseAccessController["allRoles"]>): Promise<Role[]> {
return (await this.c()).allRoles(...args);
}
async getAssignableRoleIds(
...args: Parameters<RoleBaseAccessController["getAssignableRoleIds"]>
): Promise<string[]> {
return (await this.c()).getAssignableRoleIds(...args);
}
async createRole(
...args: Parameters<RoleBaseAccessController["createRole"]>
): Promise<RoleMutationResult> {
return (await this.c()).createRole(...args);
}
async updateRole(
...args: Parameters<RoleBaseAccessController["updateRole"]>
): Promise<RoleMutationResult> {
return (await this.c()).updateRole(...args);
}
async deleteRole(
...args: Parameters<RoleBaseAccessController["deleteRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).deleteRole(...args);
}
async getUserRole(
...args: Parameters<RoleBaseAccessController["getUserRole"]>
): Promise<Role | null> {
return (await this.c()).getUserRole(...args);
}
async getUserRoles(
...args: Parameters<RoleBaseAccessController["getUserRoles"]>
): Promise<Map<string, Role | null>> {
return (await this.c()).getUserRoles(...args);
}
async setUserRole(
...args: Parameters<RoleBaseAccessController["setUserRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).setUserRole(...args);
}
async removeUserRole(
...args: Parameters<RoleBaseAccessController["removeUserRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).removeUserRole(...args);
}
async getTokenRole(
...args: Parameters<RoleBaseAccessController["getTokenRole"]>
): Promise<Role | null> {
return (await this.c()).getTokenRole(...args);
}
async setTokenRole(
...args: Parameters<RoleBaseAccessController["setTokenRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).setTokenRole(...args);
}
async removeTokenRole(
...args: Parameters<RoleBaseAccessController["removeTokenRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).removeTokenRole(...args);
}
}
class RoleBaseAccess {
// Synchronous — returns a lazy controller that resolves any installed
// plugin on first call.
create(
prisma: RbacPrismaInput,
options?: RbacCreateOptions
): RoleBaseAccessController {
return new LazyController(prisma, options);
}
}
const loader = new RoleBaseAccess();
export default loader;