1
20
21import { ApiError } from "../common/errors.js";
22import { ActorContext } from "../common/actor-context.js";
23
24
25
26
27import { oauthScopeCap, missingOAuthScope } from "../modules/oauth/scopes.js";
28
29export type Level = "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS";
30
31
44function apiKeyScopeCap(scopes: string[]): Level {
45 if (scopes.includes("ADMIN")) return "FULL_ACCESS";
46 if (scopes.includes("WRITE")) return "EDIT";
47 return "VIEW";
48}
49
50
54export { oauthScopeCap };
55
56
72async function assertCanForOAuth(
73 prisma: AuthorizePrisma,
74 actor: { userId?: string; oauthScopes?: string[] },
75 workspaceId: string,
76 need: Level,
77 resource?: ResourceScope
78): Promise<Level> {
79 const membership =
80 actor.userId && workspaceId
81 ? await prisma.workspaceMember
82 .findUnique({ where: { workspaceId_userId: { workspaceId, userId: actor.userId } } })
83 .catch(() => null)
84 : null;
85 if (!membership) {
86 throw new ApiError("UNAUTHORIZED", 401, "You are not a member of the requested workspace.", [
87 { reason: "workspace_member_required" }
88 ]);
89 }
90 const scopes = actor.oauthScopes ?? [];
91 const cap = oauthScopeCap(scopes);
92 if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) {
93 throw new ApiError("FORBIDDEN", 403, `OAuth scope does not cover ${need} access.`, [
94 { reason: "api_key_scope_write", scope: missingOAuthScope(need, scopes, resource) }
95 ]);
96 }
97 return cap;
98}
99
100export const LEVELS: Level[] = ["NONE", "VIEW", "COMMENT", "EDIT", "FULL_ACCESS"];
101
102export const LEVEL_ORDER: Record<Level, number> = {
103 NONE: 0,
104 VIEW: 1,
105 COMMENT: 2,
106 EDIT: 3,
107 FULL_ACCESS: 4
108};
109
110
111export const ROLE_LEVEL: Record<string, Level> = {
112 OWNER: "FULL_ACCESS",
113 ADMIN: "FULL_ACCESS",
114 MEMBER: "EDIT",
115 GUEST: "VIEW"
116};
117
118
119const RESOURCE_SPECIFICITY: Record<string, number> = {
120 workspace: 0,
121 teamspace: 1,
122 database: 2,
123 page: 3,
124 record: 4,
125 file: 5
126};
127
128export interface ResourceScope {
129 pageId?: string;
130 databaseId?: string;
131 teamId?: string;
132 recordId?: string;
133 fileId?: string;
134}
135
136export interface EffectiveLevelInput {
137 workspaceId: string;
138 userId: string;
139 resource?: ResourceScope;
140}
141
142
153export interface AuthorizePrisma {
154 workspaceMember: {
155 findUnique(args: object): Promise<{ id: string; role: string } | null>;
156 };
157 teamMember: {
158 findMany(args: object): Promise<{ teamId: string }[]>;
159 };
160 permission: {
161 findMany(args: object): Promise<PermissionRow[]>;
162 };
163}
164
165export interface PermissionRow {
166 id: string;
167 workspaceId: string;
168 granteeType: string;
169 granteeId: string;
170 resourceType: string;
171 resourceId: string;
172 level: Level;
173 inheritsFrom?: string | null;
174}
175
176function maxLevel(a: Level, b: Level): Level {
177 return LEVEL_ORDER[a] >= LEVEL_ORDER[b] ? a : b;
178}
179
180
191type GetCachedLevel = (
192 workspaceId: string,
193 userId: string,
194 scope: ResourceScope | undefined,
195 loader: () => Promise<string>
196) => Promise<{ level: string; cached: boolean }>;
197
198let permCacheFn: GetCachedLevel | null = null;
199
200
201export function setPermissionCache(fn: GetCachedLevel | null): void {
202 permCacheFn = fn;
203}
204
205
210export async function effectiveLevel(
211 prisma: AuthorizePrisma,
212 input: EffectiveLevelInput
213): Promise<Level> {
214 const { workspaceId, userId, resource } = input;
215
216
217
218 if (permCacheFn) {
219 const { level } = await permCacheFn(workspaceId, userId, resource, () =>
220 rawEffectiveLevel(prisma, input)
221 );
222 return level as Level;
223 }
224 return rawEffectiveLevel(prisma, input);
225}
226
227
228async function rawEffectiveLevel(
229 prisma: AuthorizePrisma,
230 input: EffectiveLevelInput
231): Promise<Level> {
232 const { workspaceId, userId, resource } = input;
233
234 const membership = await prisma.workspaceMember.findUnique({
235 where: { workspaceId_userId: { workspaceId, userId } }
236 });
237 let level: Level = membership ? ROLE_LEVEL[membership.role] ?? "NONE" : "NONE";
238
239
240
241
242
243
244 const teams = await prisma.teamMember.findMany({ where: { userId }, select: { teamId: true } });
245 const grants = await prisma.permission.findMany({
246 where: {
247 workspaceId,
248 OR: [
249 { granteeType: "user", granteeId: userId },
250 { granteeType: "team", granteeId: { in: teams.map((t) => t.teamId) } }
251 ]
252 }
253 });
254
255 let best: { specificity: number; level: Level } | undefined;
256 for (const grant of grants) {
257 const specificity = RESOURCE_SPECIFICITY[grant.resourceType];
258 if (specificity === undefined) continue;
259 if (!appliesToResource(grant, workspaceId, resource)) continue;
260 if (!best || specificity > best.specificity || (specificity === best.specificity && LEVEL_ORDER[grant.level] > LEVEL_ORDER[best.level])) {
261 best = { specificity, level: grant.level };
262 }
263 }
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288 for (const named of namedResources(resource)) {
289 if (
290 grants.some(
291 (g) => g.resourceType === named.resourceType && g.resourceId === named.resourceId && g.level === "NONE"
292 )
293 ) {
294 return "NONE";
295 }
296 }
297
298 if (best) level = maxLevel(level, best.level);
299 return level;
300}
301
302
307function namedResources(resource: ResourceScope | undefined): { resourceType: string; resourceId: string }[] {
308 if (!resource) return [];
309 const named: { resourceType: string; resourceId: string }[] = [];
310 if (resource.fileId) named.push({ resourceType: "file", resourceId: resource.fileId });
311 if (resource.recordId) named.push({ resourceType: "record", resourceId: resource.recordId });
312 if (resource.pageId) named.push({ resourceType: "page", resourceId: resource.pageId });
313 if (resource.databaseId) named.push({ resourceType: "database", resourceId: resource.databaseId });
314 if (resource.teamId) named.push({ resourceType: "teamspace", resourceId: resource.teamId });
315 return named;
316}
317
318function appliesToResource(
319 grant: Pick<PermissionRow, "resourceType" | "resourceId">,
320 workspaceId: string,
321 resource: ResourceScope | undefined
322): boolean {
323 switch (grant.resourceType) {
324 case "workspace":
325 return grant.resourceId === workspaceId;
326 case "teamspace":
327 return resource?.teamId !== undefined && resource.teamId === grant.resourceId;
328 case "page":
329 return resource?.pageId !== undefined && resource.pageId === grant.resourceId;
330 case "database":
331 return resource?.databaseId !== undefined && resource.databaseId === grant.resourceId;
332 case "record":
333 return resource?.recordId !== undefined && resource.recordId === grant.resourceId;
334 case "file":
335 return resource?.fileId !== undefined && resource.fileId === grant.resourceId;
336 default:
337 return false;
338 }
339}
340
341
345function currentActor() {
346 return ActorContext.get();
347}
348
349
356function assertCanForApiKey(auth: {
357 workspaceId?: string;
358 scopes?: string[];
359 apiKeyId?: string;
360 userId?: string;
361}, workspaceId: string, need: Level): Level {
362 if (auth.workspaceId !== workspaceId) {
363
364
365
366 throw new ApiError("FORBIDDEN", 403, "API key is bound to a different workspace.", [
367 { reason: "workspace_mismatch" }
368 ]);
369 }
370 const cap = apiKeyScopeCap(auth.scopes ?? []);
371 if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) {
372 throw new ApiError(
373 "FORBIDDEN",
374 403,
375 `API key scope does not cover ${need} access.`,
376 [{ reason: "api_key_scope_write" }]
377 );
378 }
379
380
381
382 return cap;
383}
384
385
392export async function actorLevel(prisma: AuthorizePrisma, input: EffectiveLevelInput): Promise<Level> {
393 const actor = currentActor();
394 if (actor?.kind === "api") {
395 if (actor.workspaceId !== input.workspaceId) return "NONE";
396 const scopes = actor.scopes ?? [];
397 if (scopes.includes("ADMIN")) return "FULL_ACCESS";
398 if (scopes.includes("WRITE")) return "EDIT";
399 return "VIEW";
400 }
401
402
403
404 if (actor?.kind === "user" && actor.oauthScopes) {
405 return oauthScopeCap(actor.oauthScopes);
406 }
407 return effectiveLevel(prisma, input);
408}
409
410
411export function actorIsAdminKey(workspaceId: string): boolean {
412 const actor = currentActor();
413 return actor?.kind === "api" && actor.workspaceId === workspaceId && (actor.scopes ?? []).includes("ADMIN");
414}
415
416
424export async function assertCan(
425 prisma: AuthorizePrisma,
426 input: EffectiveLevelInput,
427 need: Level
428): Promise<Level> {
429 const actor = currentActor();
430 if (actor?.kind === "api") {
431 return assertCanForApiKey(actor, input.workspaceId, need);
432 }
433
434
435
436 if (actor?.kind === "user" && actor.oauthScopes) {
437 return assertCanForOAuth(prisma, actor, input.workspaceId, need, input.resource);
438 }
439 const have = await effectiveLevel(prisma, input);
440 if (LEVEL_ORDER[have] < LEVEL_ORDER[need]) {
441 throw new ApiError("FORBIDDEN", 403, `Requires ${need} access (you have ${have}).`, [
442 { reason: `requires_${need}`, have }
443 ]);
444 }
445 return have;
446}
447
448
455export function actorIsWorkspaceKey(actor: { kind?: string; workspaceId?: string } | undefined, workspaceId: string): boolean {
456 return actor?.kind === "api" && actor.workspaceId === workspaceId;
457}
458
459
466export function apiKeyLevelFor(
467 actor: { kind?: string; workspaceId?: string; scopes?: string[] } | undefined,
468 workspaceId: string,
469 need: Level
470): Level {
471 if (actor?.kind !== "api" || actor.workspaceId !== workspaceId) return "NONE";
472 const scopes = actor.scopes ?? [];
473 let cap: Level = "VIEW";
474 if (scopes.includes("WRITE") || scopes.includes("ADMIN")) cap = "EDIT";
475 if (scopes.includes("ADMIN")) cap = "FULL_ACCESS";
476 return LEVEL_ORDER[cap] >= LEVEL_ORDER[need] ? need : "NONE";
477}
478
479
487export async function actorMembership(
488 prisma: AuthorizePrisma,
489 userId: string,
490 workspaceId: string,
491 need: Level = "VIEW"
492): Promise<{ id: string; role: string } | null> {
493 const actor = currentActor();
494 if (actor?.kind === "api") {
495 if (actor.workspaceId !== workspaceId) return null;
496 const scopes = actor.scopes ?? [];
497 const cap: Level = scopes.includes("ADMIN")
498 ? "FULL_ACCESS"
499 : scopes.includes("WRITE")
500 ? "EDIT"
501 : "VIEW";
502 if (LEVEL_ORDER[cap] < LEVEL_ORDER[need]) return null;
503
504
505 const role = cap === "FULL_ACCESS" ? "ADMIN" : cap === "EDIT" ? "MEMBER" : "GUEST";
506 return { id: `apikey-${actor.apiKeyId ?? "unknown"}`, role };
507 }
508
509
510
511 if (actor?.kind === "user" && actor.oauthScopes) {
512 if (!userId) return null;
513 if (LEVEL_ORDER[oauthScopeCap(actor.oauthScopes)] < LEVEL_ORDER[need]) return null;
514 const m = await prisma.workspaceMember.findUnique({
515 where: { workspaceId_userId: { workspaceId, userId } },
516 select: { id: true, role: true }
517 });
518 return m;
519 }
520 if (!userId) return null;
521 const m = await prisma.workspaceMember.findUnique({
522 where: { workspaceId_userId: { workspaceId, userId } },
523 select: { id: true, role: true }
524 });
525 return m;
526}
527
528
536export async function assertRole(
537 prisma: AuthorizePrisma,
538 input: { workspaceId: string; userId: string },
539 roles: string[]
540): Promise<string> {
541 const actor = currentActor();
542 if (actor?.kind === "api") {
543 if (actor.workspaceId !== input.workspaceId) {
544 throw new ApiError("FORBIDDEN", 403, "API key is bound to a different workspace.", [
545 { reason: "workspace_mismatch" }
546 ]);
547 }
548 if (!actor.scopes?.includes("ADMIN")) {
549 throw new ApiError("FORBIDDEN", 403, "This operation requires the ADMIN scope.", [
550 { reason: "api_key_scope_write" }
551 ]);
552 }
553
554
555
556 if (!roles.includes("ADMIN")) {
557 throw new ApiError("FORBIDDEN", 403, `Requires workspace role ${roles.join(" or ")}.`, [
558 { reason: `requires_role_${roles.join("_")}` }
559 ]);
560 }
561 return "ADMIN";
562 }
563
564
565
566 if (actor?.kind === "user" && actor.oauthScopes) {
567 if (oauthScopeCap(actor.oauthScopes) !== "FULL_ACCESS") {
568 throw new ApiError("FORBIDDEN", 403, `This operation requires workspace role ${roles.join(" or ")}.`, [
569 { reason: "oauth_role_denied" }
570 ]);
571 }
572 }
573 const membership = await prisma.workspaceMember.findUnique({
574 where: { workspaceId_userId: { workspaceId: input.workspaceId, userId: input.userId } }
575 });
576 if (!membership || !roles.includes(membership.role)) {
577 throw new ApiError("FORBIDDEN", 403, `Requires workspace role ${roles.join(" or ")}.`, [
578 { reason: `requires_role_${roles.join("_")}` }
579 ]);
580 }
581 return membership.role;
582}
583