1
14import type { PrismaClient } from "@prisma/client";
15import { getLogger } from "../../../logger.js";
16import { FatalError } from "./classify.js";
17
18const log = getLogger("automation-perm");
19
20const ROLE_LEVEL: Record<string, string> = {
21 OWNER: "FULL_ACCESS",
22 ADMIN: "FULL_ACCESS",
23 MEMBER: "EDIT",
24 GUEST: "VIEW"
25};
26
27const LEVEL_ORDER: Record<string, number> = {
28 NONE: 0,
29 VIEW: 1,
30 COMMENT: 2,
31 EDIT: 3,
32 FULL_ACCESS: 4
33};
34
35const RESOURCE_SPECIFICITY: Record<string, number> = {
36 workspace: 0,
37 teamspace: 1,
38 database: 2,
39 page: 3,
40 record: 4,
41 file: 5
42};
43
44interface Scope {
45 pageId?: string | null;
46 databaseId?: string | null;
47 teamId?: string | null;
48 recordId?: string | null;
49 fileId?: string | null;
50}
51
52function namedResources(resource: Scope): { resourceType: string; resourceId: string }[] {
53 const named: { resourceType: string; resourceId: string }[] = [];
54 if (resource.fileId) named.push({ resourceType: "file", resourceId: resource.fileId });
55 if (resource.recordId) named.push({ resourceType: "record", resourceId: resource.recordId });
56 if (resource.pageId) named.push({ resourceType: "page", resourceId: resource.pageId });
57 if (resource.databaseId) named.push({ resourceType: "database", resourceId: resource.databaseId });
58 if (resource.teamId) named.push({ resourceType: "teamspace", resourceId: resource.teamId });
59 return named;
60}
61
62function appliesToResource(grant: { resourceType: string; resourceId: string }, workspaceId: string, resource: Scope): boolean {
63 switch (grant.resourceType) {
64 case "workspace":
65 return grant.resourceId === workspaceId;
66 case "teamspace":
67 return resource.teamId !== null && resource.teamId !== undefined && resource.teamId === grant.resourceId;
68 case "page":
69 return resource.pageId !== null && resource.pageId !== undefined && resource.pageId === grant.resourceId;
70 case "database":
71 return resource.databaseId !== null && resource.databaseId !== undefined && resource.databaseId === grant.resourceId;
72 case "record":
73 return resource.recordId !== null && resource.recordId !== undefined && resource.recordId === grant.resourceId;
74 case "file":
75 return resource.fileId !== null && resource.fileId !== undefined && resource.fileId === grant.resourceId;
76 default:
77 return false;
78 }
79}
80
81const maxLevel = (a: string, b: string): string => ((LEVEL_ORDER[a] ?? 0) >= (LEVEL_ORDER[b] ?? 0) ? a : b);
82
83
87export async function effectiveLevel(
88 prisma: PrismaClient,
89 workspaceId: string,
90 userId: string,
91 resource: Scope
92): Promise<string> {
93 const membership = await prisma.workspaceMember.findUnique({
94 where: { workspaceId_userId: { workspaceId, userId } },
95 select: { role: true }
96 });
97 let level: string = membership ? ROLE_LEVEL[membership.role] ?? "NONE" : "NONE";
98
99 const teams = await prisma.teamMember.findMany({ where: { userId }, select: { teamId: true } });
100 const grants = await prisma.permission.findMany({
101 where: {
102 workspaceId,
103 OR: [
104 { granteeType: "user", granteeId: userId },
105 { granteeType: "team", granteeId: { in: teams.map((t) => t.teamId) } }
106 ]
107 }
108 });
109
110 let best: { specificity: number; level: string } | undefined;
111 for (const grant of grants) {
112 const specificity = RESOURCE_SPECIFICITY[grant.resourceType];
113 if (specificity === undefined) continue;
114 if (!appliesToResource(grant, workspaceId, resource)) continue;
115 if (
116 !best ||
117 specificity > best.specificity ||
118 (specificity === best.specificity && (LEVEL_ORDER[grant.level] ?? 0) > (LEVEL_ORDER[best.level] ?? 0))
119 ) {
120 best = { specificity, level: grant.level };
121 }
122 }
123
124
125 for (const named of namedResources(resource)) {
126 if (grants.some((g) => g.resourceType === named.resourceType && g.resourceId === named.resourceId && g.level === "NONE")) {
127 return "NONE";
128 }
129 }
130
131 if (best) level = maxLevel(level, best.level);
132 return level;
133}
134
135
140export async function assertAutomationCanEdit(
141 prisma: PrismaClient,
142 workspaceId: string,
143 userId: string,
144 resource: Scope,
145 need: string = "EDIT"
146): Promise<void> {
147 const have = await effectiveLevel(prisma, workspaceId, userId, resource);
148 if ((LEVEL_ORDER[have] ?? 0) < (LEVEL_ORDER[need] ?? 0)) {
149 log.warn({ workspaceId, userId, need, have, resourceType: "record", resource: resource.recordId ?? "?" }, "automation permission denied");
150 throw new FatalError("PERMISSION_DENIED", `automation actor has ${have}, requires ${need}`);
151 }
152}
153