storage-grants.ts257 lines · main
1import { newId, NotFoundError, ValidationError } from '@briven/shared';
2import { and, eq, isNull, sql } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { projectStorageGrants, type ProjectStorageGrant } from '../db/schema.js';
6import { getFile } from './storage.js';
7
8/**
9 * Cross-project storage sharing — GRANTS (M5).
10 *
11 * This is the ONE sanctioned exception to strict cross-project storage
12 * isolation. A GRANTER project explicitly shares either a single file (by file
13 * id) or a whole path prefix with a GRANTEE project. The grantee can then mint a
14 * download URL for exactly the granted resource — and nothing else.
15 *
16 * SECURITY MODEL (strict-deny by construction):
17 * - `isGranted()` returns true ONLY when an ACTIVE (revoked_at IS NULL) grant
18 * from granter→grantee covers the resource. No matching row, a revoked row,
19 * an unknown project, OR ANY error → false. It never throws.
20 * - Prefix grants match on the file's OBJECT PATH (`projects/<id>/<fileId>`),
21 * not the human name — the path is what actually addresses the bytes.
22 * - Only the granter may revoke its own grant (the service scopes every
23 * mutation by granterProjectId, so a grantee can never touch a grant row).
24 *
25 * The control-plane table is created idempotently on first use (CREATE TABLE IF
26 * NOT EXISTS — same pattern as auth-origin-allowlist / storage-keys) so the code
27 * is safe even before migration 0050 has been applied.
28 */
29
30let tableReady = false;
31async function ensureTable(): Promise<void> {
32 if (tableReady) return;
33 const db = getDb();
34 await db.execute(
35 sql.raw(
36 `CREATE TABLE IF NOT EXISTS "project_storage_grants" (
37 "id" text PRIMARY KEY NOT NULL,
38 "granter_project_id" text NOT NULL,
39 "grantee_project_id" text NOT NULL,
40 "resource" text NOT NULL,
41 "is_prefix" boolean DEFAULT false NOT NULL,
42 "created_by" text,
43 "created_at" timestamp with time zone DEFAULT now() NOT NULL,
44 "revoked_at" timestamp with time zone
45 )`,
46 ),
47 );
48 await db.execute(
49 sql.raw(
50 `CREATE UNIQUE INDEX IF NOT EXISTS "project_storage_grants_unique_idx" ON "project_storage_grants" ("granter_project_id","grantee_project_id","resource")`,
51 ),
52 );
53 await db.execute(
54 sql.raw(
55 `CREATE INDEX IF NOT EXISTS "project_storage_grants_grantee_idx" ON "project_storage_grants" ("grantee_project_id")`,
56 ),
57 );
58 tableReady = true;
59}
60
61export interface StorageGrantRecord {
62 id: string;
63 granterProjectId: string;
64 granteeProjectId: string;
65 resource: string;
66 isPrefix: boolean;
67 createdBy: string | null;
68 createdAt: string;
69 revokedAt: string | null;
70}
71
72function toRecord(r: ProjectStorageGrant): StorageGrantRecord {
73 const iso = (d: Date | null): string | null =>
74 d ? (d instanceof Date ? d : new Date(d)).toISOString() : null;
75 return {
76 id: r.id,
77 granterProjectId: r.granterProjectId,
78 granteeProjectId: r.granteeProjectId,
79 resource: r.resource,
80 isPrefix: r.isPrefix,
81 createdBy: r.createdBy ?? null,
82 createdAt: iso(r.createdAt) ?? new Date().toISOString(),
83 revokedAt: iso(r.revokedAt),
84 };
85}
86
87export interface CreateGrantInput {
88 granterProjectId: string;
89 granteeProjectId: string;
90 /** An exact file id (isPrefix=false) or a path prefix string (isPrefix=true). */
91 resource: string;
92 isPrefix: boolean;
93 createdBy: string | null;
94}
95
96/**
97 * Create (or re-activate) a grant. A granter can never grant to itself. When a
98 * matching (granter, grantee, resource) row already exists we reactivate it
99 * (clear revoked_at) rather than inserting a duplicate — this keeps the unique
100 * index satisfied and makes re-granting a revoked resource a no-surprise op.
101 */
102export async function createGrant(input: CreateGrantInput): Promise<StorageGrantRecord> {
103 const resource = input.resource?.trim();
104 if (!resource) {
105 throw new ValidationError('resource is required (a file id or a path prefix)');
106 }
107 if (input.granteeProjectId === input.granterProjectId) {
108 throw new ValidationError('cannot grant a project access to its own storage');
109 }
110 await ensureTable();
111 const db = getDb();
112
113 // Re-activate an existing (possibly revoked) matching grant, else insert.
114 const [existing] = await db
115 .select()
116 .from(projectStorageGrants)
117 .where(
118 and(
119 eq(projectStorageGrants.granterProjectId, input.granterProjectId),
120 eq(projectStorageGrants.granteeProjectId, input.granteeProjectId),
121 eq(projectStorageGrants.resource, resource),
122 ),
123 )
124 .limit(1);
125
126 if (existing) {
127 const [updated] = await db
128 .update(projectStorageGrants)
129 .set({ revokedAt: null, isPrefix: input.isPrefix, createdBy: input.createdBy })
130 .where(eq(projectStorageGrants.id, existing.id))
131 .returning();
132 return toRecord(updated ?? existing);
133 }
134
135 const row = {
136 id: newId('sg'),
137 granterProjectId: input.granterProjectId,
138 granteeProjectId: input.granteeProjectId,
139 resource,
140 isPrefix: input.isPrefix,
141 createdBy: input.createdBy,
142 };
143 const [inserted] = await db.insert(projectStorageGrants).values(row).returning();
144 if (!inserted) throw new Error('storage grant insert returned nothing');
145 return toRecord(inserted);
146}
147
148/**
149 * Revoke a grant. ONLY the granter can revoke — the WHERE is scoped by
150 * granterProjectId, so a grantee (or any other project) presenting the grant id
151 * matches zero rows and gets a NotFoundError. Sets revoked_at (soft), so the
152 * unique index row survives for a future re-grant. Idempotent: re-revoking an
153 * already-revoked grant still succeeds (the row is still owned by the granter).
154 */
155export async function revokeGrant(
156 granterProjectId: string,
157 grantId: string,
158): Promise<StorageGrantRecord> {
159 await ensureTable();
160 const db = getDb();
161 const [row] = await db
162 .update(projectStorageGrants)
163 .set({ revokedAt: new Date() })
164 .where(
165 and(
166 eq(projectStorageGrants.id, grantId),
167 eq(projectStorageGrants.granterProjectId, granterProjectId),
168 ),
169 )
170 .returning();
171 if (!row) throw new NotFoundError('storage_grant', grantId);
172 return toRecord(row);
173}
174
175/** Every grant the granter has created (active + revoked), newest first. */
176export async function listGrants(granterProjectId: string): Promise<StorageGrantRecord[]> {
177 await ensureTable();
178 const db = getDb();
179 const rows = await db
180 .select()
181 .from(projectStorageGrants)
182 .where(eq(projectStorageGrants.granterProjectId, granterProjectId));
183 return rows.map(toRecord).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
184}
185
186/**
187 * THE ENFORCEMENT CHECK. Returns true ONLY if an ACTIVE grant from
188 * `granterProjectId` → `granteeProjectId` covers `fileIdOrPath`:
189 * - an exact grant (is_prefix = false) whose `resource` equals the file id, OR
190 * - a prefix grant (is_prefix = true) whose `resource` is a prefix of the
191 * file's OBJECT PATH (`projects/<granterProjectId>/<fileId>`).
192 *
193 * STRICT-DENY: any error, an unknown file, a self-reference, or no matching
194 * active grant → false. Never throws.
195 */
196export async function isGranted(
197 granteeProjectId: string,
198 granterProjectId: string,
199 fileIdOrPath: string,
200): Promise<boolean> {
201 try {
202 if (!granteeProjectId || !granterProjectId || !fileIdOrPath) return false;
203 // A project always "has" its own files through the normal isolated path;
204 // grants are strictly cross-project, so a self-reference is not a grant.
205 if (granteeProjectId === granterProjectId) return false;
206
207 await ensureTable();
208 const db = getDb();
209
210 // Pull every ACTIVE grant from this granter to this grantee. The set is tiny
211 // (a project shares a handful of resources), so evaluating prefix matching in
212 // code is both correct and cheap — and keeps the SQL a plain equality filter.
213 const rows = await db
214 .select({
215 resource: projectStorageGrants.resource,
216 isPrefix: projectStorageGrants.isPrefix,
217 })
218 .from(projectStorageGrants)
219 .where(
220 and(
221 eq(projectStorageGrants.granterProjectId, granterProjectId),
222 eq(projectStorageGrants.granteeProjectId, granteeProjectId),
223 isNull(projectStorageGrants.revokedAt),
224 ),
225 );
226 if (rows.length === 0) return false;
227
228 // Resolve the file's real object path so a prefix grant matches the bytes'
229 // actual address, not a client-supplied string. getFile is scoped to the
230 // GRANTER (the owner) and throws if the file is missing/deleted → deny.
231 let objectPath: string | null = null;
232 try {
233 const file = await getFile(fileIdOrPath, granterProjectId);
234 objectPath = file.objectKey;
235 } catch {
236 // Not a resolvable file id for this granter. An exact-id grant can still
237 // match on the raw string below (e.g. grant created before enforcement),
238 // but a prefix grant needs a real path — leave objectPath null.
239 objectPath = null;
240 }
241
242 for (const g of rows) {
243 if (!g.isPrefix) {
244 // Exact grant: the granted resource must equal the file id exactly.
245 if (g.resource === fileIdOrPath) return true;
246 } else {
247 // Prefix grant: the granted prefix must be a prefix of the file's path.
248 // Require a resolved path so a caller can't smuggle an arbitrary string.
249 if (objectPath !== null && objectPath.startsWith(g.resource)) return true;
250 }
251 }
252 return false;
253 } catch {
254 // STRICT-DENY: any unexpected failure denies access.
255 return false;
256 }
257}