Skip to content
File

Blob: src/worker/lib/permissions.ts

typescript306 lines
1import { sql, type SQL } from "drizzle-orm";
2import { checkMembership } from "@/worker/lib/membership";
3import { MAX_TREE_DEPTH } from "@/shared/constants";
4import type { Db } from "@/worker/db/d1/client";
5import type { WorkspaceRole } from "@/shared/types";
6 
7export function canEdit(role: string): boolean {
8 return role === "owner" || role === "admin" || role === "member";
9}
10 
11export function isAdminOrOwner(role: string): boolean {
12 return role === "owner" || role === "admin";
13}
14 
15/**
16 * Writer-role predicate for the role axis. Owner/admin/member are writers;
17 * guest and non-members (null) are not. Used by `toResolvedViewerContext`,
18 * the WS `member_edit` tag, and anywhere the writer/full-member fast path
19 * needs to be separated from the membership-presence question.
20 */
21export function isWriterRole(role: WorkspaceRole | null): boolean {
22 return role === "owner" || role === "admin" || role === "member";
23}
24 
25export type ShareAction = "view" | "edit";
26export type AccessLevel = "none" | "view" | "edit";
27const FULL_WORKSPACE_ACCESS_LEVEL: AccessLevel = "edit";
28const ACCESS_RANK: Record<AccessLevel, number> = {
29 none: 0,
30 view: 1,
31 edit: 2,
32};
33 
34/**
35 * Principal represents who is requesting access.
36 * Either an authenticated user or a link share token.
37 */
38export type Principal = { type: "user"; userId: string } | { type: "link"; token: string };
39 
40/**
41 * Resolved principal carries the caller's effective workspace role alongside the
42 * principal. Two axes live here:
43 * - membership: `workspaceRole !== null` iff the caller holds a memberships
44 * row on the canonical surface. Drives `access_mode`.
45 * - writer: `isWriterRole(workspaceRole)` iff the caller can edit via their
46 * role. Drives the WS `member_edit` tag and the writer fast-path shortcut.
47 * On the shared surface `workspaceRole` is always null — `/s/:token` and
48 * `?share=` requests are link-scoped end to end, even for workspace members.
49 */
50export type ResolvedPrincipal = { principal: Principal; workspaceRole: WorkspaceRole | null };
51export type ViewerSurface = "canonical" | "shared";
52 
53export interface ResolvePrincipalOptions {
54 surface: ViewerSurface;
55 shareToken?: string;
56}
57 
58/**
59 * Resolve the access principal from an optionalAuth context.
60 *
61 * Route surface is authoritative: when `surface === "shared"` and a share token is
62 * present, the principal resolves as a link even for a workspace member. That keeps
63 * `/s/:token` link-scoped end to end.
64 */
65export async function resolvePrincipal(
66 db: Db,
67 user: { id: string } | null,
68 workspaceId: string,
69 opts: ResolvePrincipalOptions,
70): Promise<ResolvedPrincipal | null> {
71 const { surface, shareToken } = opts;
72 
73 if (surface === "shared" && shareToken) {
74 return { principal: { type: "link", token: shareToken }, workspaceRole: null };
75 }
76 
77 if (user) {
78 const membership = await checkMembership(db, user.id, workspaceId);
79 if (membership && membership.role !== "guest") {
80 // Writer role (owner/admin/member) on canonical surface — user principal
81 // flows through the writer fast-path in `resolvePageAccessLevels`.
82 return { principal: { type: "user", userId: user.id }, workspaceRole: membership.role };
83 }
84 // Guest or non-member on canonical surface. Prefer link share token for
85 // page access (spec §10.8) so a guest with a link grant can still reach a
86 // shared page via the link principal. `workspaceRole` is orthogonal —
87 // "guest" for a membership row, null for a share-only canonical visitor.
88 const principal: Principal = shareToken ? { type: "link", token: shareToken } : { type: "user", userId: user.id };
89 return { principal, workspaceRole: membership ? membership.role : null };
90 }
91 if (shareToken) {
92 return { principal: { type: "link", token: shareToken }, workspaceRole: null };
93 }
94 return null;
95}
96 
97export function toResolvedViewerContext(
98 resolved: ResolvedPrincipal,
99 workspaceSlug: string,
100 surface: ViewerSurface,
101): {
102 access_mode: "member" | "shared";
103 principal_type: "user" | "link";
104 route_kind: "canonical" | "shared";
105 workspace_slug: string | null;
106 workspace_role: WorkspaceRole | null;
107} {
108 // Shared surface is always `access_mode: "shared"` with `workspace_role: null`
109 // so the wire contract matches the link-scoped invariant. On canonical surface,
110 // membership presence drives access_mode; role is surfaced for entitlement
111 // gating (AI, member-management, affordances).
112 if (surface === "shared") {
113 return {
114 access_mode: "shared",
115 principal_type: resolved.principal.type,
116 route_kind: surface,
117 workspace_slug: null,
118 workspace_role: null,
119 };
120 }
121 const hasMembership = resolved.workspaceRole !== null;
122 return {
123 access_mode: hasMembership ? "member" : "shared",
124 principal_type: resolved.principal.type,
125 route_kind: surface,
126 workspace_slug: workspaceSlug,
127 workspace_role: resolved.workspaceRole,
128 };
129}
130 
131/**
132 * Resolve the effective access level for each requested page.
133 *
134 * Assumption: v1 has monotonic positive permissions only (`none` < `view` < `edit`).
135 * There are no page-level deny rules, so resolving the strongest applicable grant once
136 * is enough to answer both "can view?" and "can edit?" checks.
137 */
138export async function resolvePageAccessLevels(
139 db: Db,
140 principal: Principal,
141 pageIds: string[],
142 workspaceId: string,
143): Promise<Map<string, AccessLevel>> {
144 const uniquePageIds = [...new Set(pageIds)];
145 const levels = new Map(uniquePageIds.map((pageId) => [pageId, "none" as AccessLevel]));
146 
147 if (uniquePageIds.length === 0) {
148 return levels;
149 }
150 
151 if (principal.type === "user") {
152 // Assumption: in v1, workspace owner/admin/member always have full page access.
153 // If page-level denies or weaker workspace roles are added later, update this
154 // short-circuit together with the access-rank comparison helpers below.
155 const membership = await checkMembership(db, principal.userId, workspaceId);
156 if (membership && canEdit(membership.role)) {
157 return new Map(uniquePageIds.map((pageId) => [pageId, FULL_WORKSPACE_ACCESS_LEVEL]));
158 }
159 }
160 
161 const resolved = await db.all<{ page_id: string; access_rank: number }>(
162 buildBatchPageAccessQuery(uniquePageIds, principal, workspaceId),
163 );
164 
165 for (const row of resolved) {
166 levels.set(row.page_id, rankToAccessLevel(row.access_rank));
167 }
168 
169 return levels;
170}
171 
172/**
173 * Resolve access for many pages at once.
174 *
175 * This intentionally wraps the richer access-level resolver instead of returning booleans
176 * directly from SQL. Several callers need both view and edit answers for the same page,
177 * and reusing the resolved level avoids paying for the tree walk twice.
178 */
179export async function canAccessPages(
180 db: Db,
181 principal: Principal,
182 pageIds: string[],
183 workspaceId: string,
184 action: ShareAction,
185): Promise<Map<string, boolean>> {
186 const uniquePageIds = [...new Set(pageIds)];
187 const allowed = new Map(uniquePageIds.map((pageId) => [pageId, false]));
188 
189 if (uniquePageIds.length === 0) {
190 return allowed;
191 }
192 
193 const levels = await resolvePageAccessLevels(db, principal, uniquePageIds, workspaceId);
194 
195 for (const pageId of uniquePageIds) {
196 allowed.set(pageId, accessLevelSatisfies(levels.get(pageId) ?? "none", action));
197 }
198 
199 return allowed;
200}
201 
202/**
203 * Resolve access for a single page.
204 *
205 * Implements spec §9 / §20.2:
206 * 1. Workspace owner/admin/member → use role
207 * 2. Walk page_shares up the tree (replace-not-merge)
208 * 3. Deny if no shares found
209 */
210export async function canAccessPage(
211 db: Db,
212 principal: Principal,
213 pageId: string,
214 workspaceId: string,
215 action: ShareAction,
216): Promise<boolean> {
217 const results = await canAccessPages(db, principal, [pageId], workspaceId, action);
218 return results.get(pageId) ?? false;
219}
220 
221function buildBatchPageAccessQuery(pageIds: string[], principal: Principal, workspaceId: string): SQL {
222 const requestedValues = sql.join(
223 pageIds.map((pageId) => sql`(${pageId})`),
224 sql`, `,
225 );
226 
227 const granteeId = principal.type === "user" ? principal.userId : null;
228 const linkToken = principal.type === "link" ? principal.token : null;
229 
230 return sql`
231 WITH RECURSIVE
232 requested(root_id) AS (
233 VALUES ${requestedValues}
234 ),
235 ancestors(root_id, id, parent_id, depth) AS (
236 SELECT r.root_id, p.id, p.parent_id, 0
237 FROM requested r
238 JOIN pages p ON p.id = r.root_id
239 WHERE p.workspace_id = ${workspaceId}
240 AND p.archived_at IS NULL
241
242 UNION ALL
243
244 SELECT a.root_id, p.id, p.parent_id, a.depth + 1
245 FROM pages p
246 JOIN ancestors a ON p.id = a.parent_id
247 WHERE p.workspace_id = ${workspaceId}
248 AND p.archived_at IS NULL
249 AND a.depth < ${MAX_TREE_DEPTH - 1}
250 ),
251 nearest_shared_depth AS (
252 SELECT a.root_id, MIN(a.depth) AS depth
253 FROM ancestors a
254 WHERE EXISTS (
255 SELECT 1
256 FROM page_shares s
257 WHERE s.page_id = a.id
258 )
259 GROUP BY a.root_id
260 ),
261 nearest_shared AS (
262 SELECT a.root_id, a.id AS shared_page_id
263 FROM ancestors a
264 JOIN nearest_shared_depth d
265 ON d.root_id = a.root_id
266 AND d.depth = a.depth
267 )
268 SELECT
269 r.root_id AS page_id,
270 -- Assumption: v1 share inheritance is replace-not-merge, so only the nearest
271 -- shared ancestor can contribute grants for a requested page.
272 --
273 -- MAX() is defensive. The route layer prevents duplicate user shares, but the
274 -- schema does not enforce one share row per principal/page pair yet.
275 COALESCE(
276 MAX(
277 CASE s.permission
278 WHEN 'edit' THEN ${ACCESS_RANK.edit}
279 WHEN 'view' THEN ${ACCESS_RANK.view}
280 ELSE ${ACCESS_RANK.none}
281 END
282 ),
283 ${ACCESS_RANK.none}
284 ) AS access_rank
285 FROM requested r
286 LEFT JOIN nearest_shared n ON n.root_id = r.root_id
287 LEFT JOIN page_shares s
288 ON s.page_id = n.shared_page_id
289 AND (
290 (s.grantee_type = 'user' AND s.grantee_id = ${granteeId}) OR
291 (s.grantee_type = 'link' AND s.link_token = ${linkToken})
292 )
293 GROUP BY r.root_id
294 `;
295}
296 
297function accessLevelSatisfies(granted: AccessLevel, required: ShareAction): boolean {
298 return ACCESS_RANK[granted] >= ACCESS_RANK[required];
299}
300 
301function rankToAccessLevel(rank: number): AccessLevel {
302 if (rank >= ACCESS_RANK.edit) return "edit";
303 if (rank >= ACCESS_RANK.view) return "view";
304 return "none";
305}