storage-grants.ts257 lines · main
| 1 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 2 | import { and, eq, isNull, sql } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { projectStorageGrants, type ProjectStorageGrant } from '../db/schema.js'; |
| 6 | import { 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 | |
| 30 | let tableReady = false; |
| 31 | async 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 | |
| 61 | export 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 | |
| 72 | function 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 | |
| 87 | export 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 | */ |
| 102 | export 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 | */ |
| 155 | export 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. */ |
| 176 | export 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 | */ |
| 196 | export 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 | } |