Skip to content

Commit b7d7d8c

Browse files
authored
Merge pull request #1009 from constructive-io/devin/1776582549-multi-scope-bucket-resolution
feat: multi-scope bucket resolution (Option C: bucketKey + ownerId)
2 parents 769d49c + f3bd2fc commit b7d7d8c

13 files changed

Lines changed: 528 additions & 127 deletions

File tree

graphile/graphile-bucket-provisioner-plugin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
},
4242
"dependencies": {
4343
"@constructive-io/bucket-provisioner": "workspace:^",
44-
"@pgpmjs/logger": "workspace:^"
44+
"@pgpmjs/logger": "workspace:^",
45+
"@pgsql/quotes": "^17.1.0"
4546
},
4647
"peerDependencies": {
4748
"grafast": "1.0.0",

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

Lines changed: 105 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { context as grafastContext, lambda, object } from 'grafast';
3535
import type { GraphileConfig } from 'graphile-config';
3636
import { extendSchema, gql } from 'graphile-utils';
3737
import { Logger } from '@pgpmjs/logger';
38+
import { QuoteUtils } from '@pgsql/quotes';
3839
import {
3940
BucketProvisioner,
4041
} from '@constructive-io/bucket-provisioner';
@@ -47,11 +48,16 @@ import type {
4748

4849
const log = new Logger('graphile-bucket-provisioner:plugin');
4950

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

52-
const STORAGE_MODULE_QUERY = `
53+
/**
54+
* Resolve the app-level storage module (membership_type IS NULL).
55+
*/
56+
const APP_STORAGE_MODULE_QUERY = `
5357
SELECT
5458
sm.id,
59+
sm.membership_type,
60+
sm.entity_table_id,
5561
bs.schema_name AS buckets_schema,
5662
bt.name AS buckets_table,
5763
sm.endpoint,
@@ -62,17 +68,81 @@ const STORAGE_MODULE_QUERY = `
6268
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
6369
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
6470
WHERE sm.database_id = $1
71+
AND sm.membership_type IS NULL
6572
LIMIT 1
6673
`;
6774

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

78148
interface BucketRow {
@@ -187,8 +257,7 @@ async function provisionBucketForRow(
187257
const accessType = bucketType as 'public' | 'private' | 'temp';
188258

189259
// 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;
260+
const storageModule = await resolveStorageModule(pgClient, databaseId);
192261

193262
// Resolve CORS origins using the 3-tier hierarchy
194263
const effectiveOrigins = resolveAllowedOrigins(
@@ -234,8 +303,7 @@ async function updateBucketCors(
234303
const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
235304
const accessType = bucketType as 'public' | 'private' | 'temp';
236305

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

240308
const effectiveOrigins = resolveAllowedOrigins(
241309
bucketAllowedOrigins,
@@ -287,6 +355,11 @@ export function createBucketProvisionerPlugin(
287355
input ProvisionBucketInput {
288356
"""The logical bucket key (e.g., "public", "private")"""
289357
bucketKey: String!
358+
"""
359+
Owner entity ID for entity-scoped bucket provisioning.
360+
Omit for app-level (database-wide) storage.
361+
"""
362+
ownerId: UUID
290363
}
291364
292365
type ProvisionBucketPayload {
@@ -329,7 +402,7 @@ export function createBucketProvisionerPlugin(
329402
});
330403

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

334407
if (!bucketKey || typeof bucketKey !== 'string') {
335408
throw new Error('INVALID_BUCKET_KEY');
@@ -342,20 +415,30 @@ export function createBucketProvisionerPlugin(
342415
throw new Error('DATABASE_NOT_FOUND');
343416
}
344417

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');
418+
// Resolve storage module (app-level or entity-scoped via ownerId)
419+
const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
420+
if (!storageModule) {
421+
throw new Error(
422+
ownerId
423+
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
424+
: 'STORAGE_MODULE_NOT_PROVISIONED',
425+
);
349426
}
350-
const storageModule = smResult.rows[0] as StorageModuleRow;
351427

352428
// Look up the bucket row (RLS enforced via pgSettings)
429+
const hasOwner = ownerId && storageModule.membership_type !== null;
430+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
353431
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],
432+
hasOwner
433+
? `SELECT id, key, type, is_public, allowed_origins
434+
FROM ${bucketsTable}
435+
WHERE key = $1 AND owner_id = $2
436+
LIMIT 1`
437+
: `SELECT id, key, type, is_public, allowed_origins
438+
FROM ${bucketsTable}
439+
WHERE key = $1
440+
LIMIT 1`,
441+
hasOwner ? [bucketKey, ownerId] : [bucketKey],
359442
);
360443

361444
if (bucketResult.rows.length === 0) {
@@ -522,13 +605,12 @@ export function createBucketProvisionerPlugin(
522605
return;
523606
}
524607

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) {
608+
// Read the storage module config (app-level; auto-hook doesn't have ownerId context)
609+
const storageModule = await resolveStorageModule(pgClient, databaseId);
610+
if (!storageModule) {
528611
log.warn('CORS update skipped: storage module not provisioned');
529612
return;
530613
}
531-
const storageModule = smResult.rows[0] as StorageModuleRow;
532614

533615
// We need the bucket key — it may come from input or patch
534616
// For updates, PostGraphile uses nodeId or the row's PK, so
@@ -543,9 +625,10 @@ export function createBucketProvisionerPlugin(
543625
}
544626

545627
// Read the full bucket row (post-update) to get type + origins
628+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
546629
const bucketResult = await pgClient.query(
547630
`SELECT id, key, type, is_public, allowed_origins
548-
FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
631+
FROM ${bucketsTable}
549632
WHERE key = $1
550633
LIMIT 1`,
551634
[patchKey],

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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@aws-sdk/client-s3": "^3.1009.0",
4444
"@aws-sdk/s3-request-presigner": "^3.1009.0",
4545
"@pgpmjs/logger": "workspace:^",
46+
"@pgsql/quotes": "^17.1.0",
4647
"lru-cache": "^11.2.7"
4748
},
4849
"peerDependencies": {

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,

0 commit comments

Comments
 (0)