Skip to content

Commit 9968adf

Browse files
committed
feat: multi-scope bucket resolution (Option C: bucketKey + ownerId)
- presigned-url-plugin: Add optional ownerId to RequestUploadUrlInput - getStorageModuleConfig now filters to app-level (membership_type IS NULL) - New getStorageModuleConfigForOwner resolves entity-scoped modules by probing entity tables - New resolveStorageModuleByFileId for confirmUpload (probes all file tables by UUID) - getBucketConfig supports entity-scoped lookups with (owner_id, key) composite - File INSERT adapts to presence/absence of owner_id column per scope - bucket-provisioner-plugin: Add optional ownerId to ProvisionBucketInput - Replace LIMIT 1 query with scope-aware resolveStorageModule function - provisionBucket mutation resolves storage module via ownerId - Auto-provisioning hook uses app-level resolution (no ownerId context) - Backward compatible: omitting ownerId defaults to app-level storage - No DB changes required (builds on PR #876 membership_type + entity_table_id columns)
1 parent b421fce commit 9968adf

6 files changed

Lines changed: 411 additions & 101 deletions

File tree

graphile/graphile-bucket-provisioner-plugin/src/plugin.ts

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,16 @@ import type {
4747

4848
const log = new Logger('graphile-bucket-provisioner:plugin');
4949

50-
// --- Storage module query (same as presigned-url-plugin) ---
50+
// --- Storage module queries ---
5151

52-
const STORAGE_MODULE_QUERY = `
52+
/**
53+
* Resolve the app-level storage module (membership_type IS NULL).
54+
*/
55+
const APP_STORAGE_MODULE_QUERY = `
5356
SELECT
5457
sm.id,
58+
sm.membership_type,
59+
sm.entity_table_id,
5560
bs.schema_name AS buckets_schema,
5661
bt.name AS buckets_table,
5762
sm.endpoint,
@@ -62,17 +67,79 @@ const STORAGE_MODULE_QUERY = `
6267
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
6368
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
6469
WHERE sm.database_id = $1
70+
AND sm.membership_type IS NULL
6571
LIMIT 1
6672
`;
6773

74+
/**
75+
* Resolve ALL storage modules for a database (for ownerId-based resolution).
76+
*/
77+
const ALL_STORAGE_MODULES_QUERY = `
78+
SELECT
79+
sm.id,
80+
sm.membership_type,
81+
sm.entity_table_id,
82+
bs.schema_name AS buckets_schema,
83+
bt.name AS buckets_table,
84+
sm.endpoint,
85+
sm.public_url_prefix,
86+
sm.provider,
87+
sm.allowed_origins,
88+
es.schema_name AS entity_schema,
89+
et.name AS entity_table
90+
FROM metaschema_modules_public.storage_module sm
91+
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
92+
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
93+
LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
94+
LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
95+
WHERE sm.database_id = $1
96+
`;
97+
6898
interface StorageModuleRow {
6999
id: string;
100+
membership_type: number | null;
101+
entity_table_id: string | null;
70102
buckets_schema: string;
71103
buckets_table: string;
72104
endpoint: string | null;
73105
public_url_prefix: string | null;
74106
provider: string | null;
75107
allowed_origins: string[] | null;
108+
entity_schema?: string | null;
109+
entity_table?: string | null;
110+
}
111+
112+
/**
113+
* Resolve the storage module for a given scope.
114+
* If ownerId is provided, probes entity tables to find the matching module.
115+
* Otherwise, returns the app-level module.
116+
*/
117+
async function resolveStorageModule(
118+
pgClient: any,
119+
databaseId: string,
120+
ownerId?: string,
121+
): Promise<StorageModuleRow | null> {
122+
if (!ownerId) {
123+
const result = await pgClient.query(APP_STORAGE_MODULE_QUERY, [databaseId]);
124+
return (result.rows[0] as StorageModuleRow) ?? null;
125+
}
126+
127+
// Load all modules and probe entity tables
128+
const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]);
129+
const modules = result.rows as StorageModuleRow[];
130+
const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
131+
132+
for (const mod of entityModules) {
133+
const probe = await pgClient.query(
134+
`SELECT 1 FROM "${mod.entity_schema}"."${mod.entity_table}" WHERE id = $1 LIMIT 1`,
135+
[ownerId],
136+
);
137+
if (probe.rows.length > 0) {
138+
return mod;
139+
}
140+
}
141+
142+
return null;
76143
}
77144

78145
interface BucketRow {
@@ -187,8 +254,7 @@ async function provisionBucketForRow(
187254
const accessType = bucketType as 'public' | 'private' | 'temp';
188255

189256
// Read storage module config to check for endpoint/provider/CORS overrides
190-
const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
191-
const storageModule: StorageModuleRow | null = smResult.rows[0] ?? null;
257+
const storageModule = await resolveStorageModule(pgClient, databaseId);
192258

193259
// Resolve CORS origins using the 3-tier hierarchy
194260
const effectiveOrigins = resolveAllowedOrigins(
@@ -234,8 +300,7 @@ async function updateBucketCors(
234300
const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
235301
const accessType = bucketType as 'public' | 'private' | 'temp';
236302

237-
const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
238-
const storageModule: StorageModuleRow | null = smResult.rows[0] ?? null;
303+
const storageModule = await resolveStorageModule(pgClient, databaseId);
239304

240305
const effectiveOrigins = resolveAllowedOrigins(
241306
bucketAllowedOrigins,
@@ -287,6 +352,11 @@ export function createBucketProvisionerPlugin(
287352
input ProvisionBucketInput {
288353
"""The logical bucket key (e.g., "public", "private")"""
289354
bucketKey: String!
355+
"""
356+
Owner entity ID for entity-scoped bucket provisioning.
357+
Omit for app-level (database-wide) storage.
358+
"""
359+
ownerId: UUID
290360
}
291361
292362
type ProvisionBucketPayload {
@@ -329,7 +399,7 @@ export function createBucketProvisionerPlugin(
329399
});
330400

331401
return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => {
332-
const { bucketKey } = input;
402+
const { bucketKey, ownerId } = input;
333403

334404
if (!bucketKey || typeof bucketKey !== 'string') {
335405
throw new Error('INVALID_BUCKET_KEY');
@@ -342,20 +412,29 @@ export function createBucketProvisionerPlugin(
342412
throw new Error('DATABASE_NOT_FOUND');
343413
}
344414

345-
// Read storage module config
346-
const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
347-
if (smResult.rows.length === 0) {
348-
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
415+
// Resolve storage module (app-level or entity-scoped via ownerId)
416+
const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
417+
if (!storageModule) {
418+
throw new Error(
419+
ownerId
420+
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
421+
: 'STORAGE_MODULE_NOT_PROVISIONED',
422+
);
349423
}
350-
const storageModule = smResult.rows[0] as StorageModuleRow;
351424

352425
// Look up the bucket row (RLS enforced via pgSettings)
426+
const hasOwner = ownerId && storageModule.membership_type !== null;
353427
const bucketResult = await pgClient.query(
354-
`SELECT id, key, type, is_public, allowed_origins
355-
FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
356-
WHERE key = $1
357-
LIMIT 1`,
358-
[bucketKey],
428+
hasOwner
429+
? `SELECT id, key, type, is_public, allowed_origins
430+
FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
431+
WHERE key = $1 AND owner_id = $2
432+
LIMIT 1`
433+
: `SELECT id, key, type, is_public, allowed_origins
434+
FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
435+
WHERE key = $1
436+
LIMIT 1`,
437+
hasOwner ? [bucketKey, ownerId] : [bucketKey],
359438
);
360439

361440
if (bucketResult.rows.length === 0) {
@@ -522,13 +601,12 @@ export function createBucketProvisionerPlugin(
522601
return;
523602
}
524603

525-
// Read the updated bucket row to get full state
526-
const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
527-
if (smResult.rows.length === 0) {
604+
// Read the storage module config (app-level; auto-hook doesn't have ownerId context)
605+
const storageModule = await resolveStorageModule(pgClient, databaseId);
606+
if (!storageModule) {
528607
log.warn('CORS update skipped: storage module not provisioned');
529608
return;
530609
}
531-
const storageModule = smResult.rows[0] as StorageModuleRow;
532610

533611
// We need the bucket key — it may come from input or patch
534612
// For updates, PostGraphile uses nodeId or the row's PK, so

graphile/graphile-bucket-provisioner-plugin/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export interface BucketProvisionerPluginOptions {
8989
export interface ProvisionBucketInput {
9090
/** The logical bucket key (e.g., "public", "private") */
9191
bucketKey: string;
92+
/**
93+
* Owner entity ID for entity-scoped bucket provisioning.
94+
* Omit for app-level (database-wide) storage.
95+
*/
96+
ownerId?: string;
9297
}
9398

9499
/**

graphile/graphile-presigned-url-plugin/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
3131
export { createDownloadUrlPlugin } from './download-url-field';
3232
export { PresignedUrlPreset } from './preset';
33-
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33+
export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
3434
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
3535
export type {
3636
BucketConfig,

graphile/graphile-presigned-url-plugin/src/plugin.ts

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { extendSchema, gql } from 'graphile-utils';
2323
import { Logger } from '@pgpmjs/logger';
2424

2525
import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig, BucketConfig } from './types';
26-
import { getStorageModuleConfig, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
26+
import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
2727
import { generatePresignedPutUrl, headObject } from './s3-signer';
2828

2929
const log = new Logger('graphile-presigned-url:plugin');
@@ -147,6 +147,13 @@ export function createPresignedUrlPlugin(
147147
input RequestUploadUrlInput {
148148
"""Bucket key (e.g., "public", "private")"""
149149
bucketKey: String!
150+
"""
151+
Owner entity ID for entity-scoped uploads.
152+
Omit for app-level (database-wide) storage.
153+
When provided, resolves the storage module for the entity type
154+
that owns this entity instance (e.g., a data room ID, team ID).
155+
"""
156+
ownerId: UUID
150157
"""SHA-256 content hash computed by the client (hex-encoded, 64 chars)"""
151158
contentHash: String!
152159
"""MIME type of the file (e.g., "image/png")"""
@@ -219,7 +226,7 @@ export function createPresignedUrlPlugin(
219226

220227
return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => {
221228
// --- Input validation ---
222-
const { bucketKey, contentHash, contentType, size, filename } = input;
229+
const { bucketKey, ownerId, contentHash, contentType, size, filename } = input;
223230

224231
if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
225232
throw new Error('INVALID_BUCKET_KEY');
@@ -242,9 +249,16 @@ export function createPresignedUrlPlugin(
242249
throw new Error('DATABASE_NOT_FOUND');
243250
}
244251

245-
const storageConfig = await getStorageModuleConfig(txClient, databaseId);
252+
// --- Resolve storage module (app-level or entity-scoped) ---
253+
const storageConfig = ownerId
254+
? await getStorageModuleConfigForOwner(txClient, databaseId, ownerId)
255+
: await getStorageModuleConfig(txClient, databaseId);
246256
if (!storageConfig) {
247-
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
257+
throw new Error(
258+
ownerId
259+
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
260+
: 'STORAGE_MODULE_NOT_PROVISIONED',
261+
);
248262
}
249263

250264
// --- Validate size against storage module default (bucket override checked below) ---
@@ -258,7 +272,7 @@ export function createPresignedUrlPlugin(
258272
}
259273

260274
// --- Look up the bucket (cached; first miss queries via RLS) ---
261-
const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey);
275+
const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey, ownerId);
262276
if (!bucket) {
263277
throw new Error('BUCKET_NOT_FOUND');
264278
}
@@ -319,21 +333,38 @@ export function createPresignedUrlPlugin(
319333
}
320334

321335
// --- Create file record (status=pending) ---
336+
// For app-level storage (no owner_id column), omit owner_id from the INSERT.
337+
const hasOwnerColumn = storageConfig.membershipType !== null;
322338
const fileResult = await txClient.query({
323-
text: `INSERT INTO ${storageConfig.filesQualifiedName}
324-
(bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
325-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
326-
RETURNING id`,
327-
values: [
328-
bucket.id,
329-
s3Key,
330-
contentType,
331-
contentHash,
332-
size,
333-
filename || null,
334-
bucket.owner_id,
335-
bucket.is_public,
336-
],
339+
text: hasOwnerColumn
340+
? `INSERT INTO ${storageConfig.filesQualifiedName}
341+
(bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
342+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
343+
RETURNING id`
344+
: `INSERT INTO ${storageConfig.filesQualifiedName}
345+
(bucket_id, key, content_type, content_hash, size, filename, is_public, status)
346+
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
347+
RETURNING id`,
348+
values: hasOwnerColumn
349+
? [
350+
bucket.id,
351+
s3Key,
352+
contentType,
353+
contentHash,
354+
size,
355+
filename || null,
356+
bucket.owner_id,
357+
bucket.is_public,
358+
]
359+
: [
360+
bucket.id,
361+
s3Key,
362+
contentType,
363+
contentHash,
364+
size,
365+
filename || null,
366+
bucket.is_public,
367+
],
337368
});
338369

339370
const fileId = fileResult.rows[0].id;
@@ -392,31 +423,18 @@ export function createPresignedUrlPlugin(
392423

393424
return withPgClient(pgSettings, async (pgClient: any) => {
394425
return pgClient.withTransaction(async (txClient: any) => {
395-
// --- Resolve storage module config ---
426+
// --- Resolve storage module by file ID (probes all file tables) ---
396427
const databaseId = await resolveDatabaseId(txClient);
397428
if (!databaseId) {
398429
throw new Error('DATABASE_NOT_FOUND');
399430
}
400431

401-
const storageConfig = await getStorageModuleConfig(txClient, databaseId);
402-
if (!storageConfig) {
403-
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
404-
}
405-
406-
// --- Look up the file (RLS enforced) ---
407-
const fileResult = await txClient.query({
408-
text: `SELECT id, key, content_type, status, bucket_id
409-
FROM ${storageConfig.filesQualifiedName}
410-
WHERE id = $1
411-
LIMIT 1`,
412-
values: [fileId],
413-
});
414-
415-
if (fileResult.rows.length === 0) {
432+
const resolved = await resolveStorageModuleByFileId(txClient, databaseId, fileId);
433+
if (!resolved) {
416434
throw new Error('FILE_NOT_FOUND');
417435
}
418436

419-
const file = fileResult.rows[0];
437+
const { storageConfig, file } = resolved;
420438

421439
if (file.status !== 'pending') {
422440
// File is already confirmed or processed — idempotent success
@@ -429,7 +447,7 @@ export function createPresignedUrlPlugin(
429447

430448
// --- Verify file exists in S3 (per-database bucket) ---
431449
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
432-
const s3Head = await headObject(s3ForDb, file.key, file.content_type);
450+
const s3Head = await headObject(s3ForDb, file.key, file.content_type as string);
433451

434452
if (!s3Head) {
435453
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');

0 commit comments

Comments
 (0)