1
56
57import { ApiError, ErrorCodes } from "@factory/shared/errors";
58
59import {
60 levelAtLeast,
61 maxLevel,
62 type PermissionLevel,
63 type ResourceType,
64 type WorkspaceRole,
65} from "./levels.js";
66import { shareVisibility, type Share } from "./rules.js";
67
68
69export type GranteeType = "user" | "team" | "role";
70
71
75export interface Grant {
76 granteeType: GranteeType;
77
78 granteeId: string;
79 resourceType: ResourceType;
80 resourceId: string;
81 level: PermissionLevel;
82
83 inheritsFrom?: string;
84}
85
86
87export interface AncestorRef {
88 resourceType: ResourceType;
89 id: string;
90}
91
92
93export interface PermissionRequest {
94 userId: string;
95 userName?: string;
96
97 userTeams: string[];
98 workspaceRole: WorkspaceRole;
99 workspaceId: string;
100 resourceType: ResourceType;
101 resourceId: string;
102
108 ancestorChain?: AncestorRef[];
109 grants: Grant[];
110 shares: Share[];
111}
112
113
114export interface PermissionResult {
115 level: PermissionLevel;
116 reasons: string[];
117}
118
119const ROLE_BASE_AT_ROOT: Record<WorkspaceRole, PermissionLevel> = {
120 owner: "full_access",
121 admin: "full_access",
122 member: "edit",
123 guest: "view",
124};
125
126const ROLE_BASE_ON_DESCENDANTS: Record<WorkspaceRole, PermissionLevel> = {
127 owner: "full_access",
128 admin: "full_access",
129 member: "view",
130 guest: "none",
131};
132
133
134function grantAppliesToUser(
135 grant: Grant,
136 req: Pick<PermissionRequest, "userId" | "userTeams" | "workspaceRole">,
137): boolean {
138 switch (grant.granteeType) {
139 case "user":
140 return grant.granteeId === req.userId;
141 case "team":
142 return req.userTeams.includes(grant.granteeId);
143 case "role":
144 return grant.granteeId === req.workspaceRole;
145 }
146}
147
148function describeGrantee(grant: Grant): string {
149 switch (grant.granteeType) {
150 case "user":
151 return `user ${grant.granteeId}`;
152 case "team":
153 return `team ${grant.granteeId}`;
154 case "role":
155 return `role ${grant.granteeId}`;
156 }
157}
158
159
160function maxApplicable(grants: Grant[], req: PermissionRequest): PermissionLevel {
161 let best: PermissionLevel = "none";
162 for (const grant of grants) {
163 if (grantAppliesToUser(grant, req) && grant.level !== "none") {
164 best = maxLevel(best, grant.level);
165 }
166 }
167 return best;
168}
169
170
171function baselineFor(req: PermissionRequest, workspaceRoot: boolean): PermissionLevel {
172 return (workspaceRoot ? ROLE_BASE_AT_ROOT : ROLE_BASE_ON_DESCENDANTS)[req.workspaceRole];
173}
174
175
176function inheritedFromAncestors(
177 req: PermissionRequest,
178): { level: PermissionLevel; ancestor: AncestorRef } | null {
179 const chain = req.ancestorChain;
180 if (chain === undefined || chain.length === 0) return null;
181
182
183
184
185 for (let i = chain.length - 2; i >= 0; i--) {
186 const ancestor = chain[i];
187 if (ancestor === undefined) continue;
188 const applicable = req.grants.filter(
189 (g) =>
190 grantAppliesToUser(g, req) &&
191 g.resourceType === ancestor.resourceType &&
192 g.resourceId === ancestor.id,
193 );
194 if (applicable.length > 0) {
195 let level: PermissionLevel = "none";
196 for (const grant of applicable) level = maxLevel(level, grant.level);
197 return { level, ancestor };
198 }
199 }
200 return null;
201}
202
203
207export function resolvePermission(req: PermissionRequest): PermissionResult {
208 const reasons: string[] = [];
209 const workspaceRoot = req.resourceType === "workspace";
210 const who = req.userName !== undefined ? ` (${req.userName})` : "";
211
212
213 const base = baselineFor(req, workspaceRoot);
214 reasons.push(
215 workspaceRoot
216 ? `role ${req.workspaceRole}${who} → baseline ${base} at workspace root`
217 : `role ${req.workspaceRole}${who} → baseline ${base} on descendant resource`,
218 );
219
220
221 const visibleShares = req.shares.filter((share) => shareVisibility(share, req));
222 let shareLevel: PermissionLevel = "none";
223 for (const share of visibleShares) {
224 shareLevel = maxLevel(shareLevel, share.level);
225 if (share.scope === "public") {
226 reasons.push(`public share → floor ${share.level}`);
227 } else if (share.scope === "workspace") {
228 reasons.push(`workspace share → floor ${share.level}`);
229 } else if (share.scope === "team") {
230 reasons.push(`team share (${share.teamId}) → floor ${share.level}`);
231 } else if (share.scope === "users") {
232 reasons.push(`share to listed users → floor ${share.level}`);
233 }
234 }
235 if (visibleShares.length === 0) {
236 reasons.push("no shares visible to requester → no share contribution");
237 }
238
239
240 const inherited = inheritedFromAncestors(req);
241 let inheritedLevel: PermissionLevel = "none";
242 if (inherited !== null) {
243 inheritedLevel = inherited.level;
244 reasons.push(
245 `inherited from ${inherited.ancestor.resourceType} ${inherited.ancestor.id} → ${inheritedLevel}`,
246 );
247 } else {
248 reasons.push("no applicable grant on any ancestor → no inheritance");
249 }
250
251
252 const explicit = maxApplicable(
253 req.grants.filter(
254 (g) => g.resourceType === req.resourceType && g.resourceId === req.resourceId,
255 ),
256 req,
257 );
258 if (explicit !== "none") {
259 const matched = req.grants
260 .filter(
261 (g) =>
262 g.resourceType === req.resourceType &&
263 g.resourceId === req.resourceId &&
264 grantAppliesToUser(g, req),
265 )
266 .map((g) => describeGrantee(g));
267 reasons.push(
268 `explicit grant on ${req.resourceType} ${req.resourceId} by ${matched.join(", ")} → ${explicit}`,
269 );
270 }
271
272
273 let level: PermissionLevel;
274 if (explicit !== "none") {
275 level = explicit;
276 const superseded = [
277 inheritedLevel !== "none" ? `inherited ${inheritedLevel}` : null,
278 base !== "none" ? `baseline ${base}` : null,
279 shareLevel !== "none" ? `shares ${shareLevel}` : null,
280 ]
281 .filter((part): part is string => part !== null)
282 .join(", ");
283 reasons.push(
284 superseded.length > 0
285 ? `final = explicit ${explicit} (overrides ${superseded})`
286 : `final = explicit ${explicit}`,
287 );
288 } else {
289 level = maxLevel(inheritedLevel, maxLevel(base, shareLevel));
290 reasons.push(
291 `final = max(inherited ${inheritedLevel}, baseline ${base}, shares ${shareLevel}) = ${level}`,
292 );
293 }
294
295 return { level, reasons };
296}
297
298
303export function can(req: PermissionRequest, required: Exclude<PermissionLevel, "none">): boolean {
304 return levelAtLeast(resolvePermission(req).level, required);
305}
306
307
313export function check(req: PermissionRequest, required: Exclude<PermissionLevel, "none">): void {
314 const { level, reasons } = resolvePermission(req);
315 if (!levelAtLeast(level, required)) {
316 throw new ApiError(ErrorCodes.FORBIDDEN, {
317 message: `Permission denied: requires ${required}, granted ${level}`,
318 details: { required, granted: level, reasons },
319 });
320 }
321}
322