From 526d676a10b25b5bbdf653cd114fc31263435c3b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 9 Apr 2026 20:12:21 +0000 Subject: [PATCH] feat: wire per-database S3 bucket name and publicUrlPrefix into presigned URL plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage_module table already has endpoint, public_url_prefix, and provider columns per-database, but the presigned URL plugin runtime never used them — all S3 operations went through the single global S3Config (from env vars). This commit: 1. Adds BucketNameResolver type + resolveBucketName option to PresignedUrlPluginOptions — lets each database resolve to its own S3 bucket name while sharing a single S3 client (credentials). 2. Adds resolveS3ForDatabase() helper in both plugin.ts and download-url-field.ts that overlays per-database bucket name (from resolveBucketName) and publicUrlPrefix (from storageConfig) onto the global S3Config. 3. Updates requestUploadUrl to generate presigned PUT URLs against the per-database S3 bucket. 4. Updates confirmUpload to verify uploads (HeadObject) against the per-database S3 bucket. 5. Updates downloadUrl field to: - Always resolve storageConfig before building URLs - Use per-database publicUrlPrefix for public file CDN URLs - Use per-database bucket for presigned GET URLs 6. Adds createBucketNameResolver() in presigned-url-resolver.ts that derives bucket names as {BUCKET_NAME}-{databaseId}. 7. Wires resolveBucketName into the ConstructivePreset. S3 credentials (AWS_ACCESS_KEY/AWS_SECRET_KEY) remain global — only the bucket name and publicUrlPrefix are per-database. --- .../src/download-url-field.ts | 60 ++++++++++++++----- .../src/index.ts | 1 + .../src/plugin.ts | 42 +++++++++++-- .../src/types.ts | 19 ++++++ .../src/presets/constructive-preset.ts | 4 +- .../src/presigned-url-resolver.ts | 31 +++++++++- 6 files changed, 133 insertions(+), 24 deletions(-) diff --git a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts index 4e01c2aba..deec304b7 100644 --- a/graphile/graphile-presigned-url-plugin/src/download-url-field.ts +++ b/graphile/graphile-presigned-url-plugin/src/download-url-field.ts @@ -16,7 +16,7 @@ import type { GraphileConfig } from 'graphile-config'; import { Logger } from '@pgpmjs/logger'; -import type { PresignedUrlPluginOptions, S3Config } from './types'; +import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; import { generatePresignedGetUrl } from './s3-signer'; import { getStorageModuleConfig } from './storage-module-cache'; @@ -44,6 +44,32 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config { return options.s3; } +/** + * Build a per-database S3Config by overlaying storage_module overrides + * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase. + */ +function resolveS3ForDatabase( + options: PresignedUrlPluginOptions, + storageConfig: StorageModuleConfig, + databaseId: string, +): S3Config { + const globalS3 = resolveS3(options); + const bucket = options.resolveBucketName + ? options.resolveBucketName(databaseId) + : globalS3.bucket; + const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; + + if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { + return globalS3; + } + + return { + ...globalS3, + bucket, + ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}), + }; +} + export function createDownloadUrlPlugin( options: PresignedUrlPluginOptions, ): GraphileConfig.Plugin { @@ -100,39 +126,41 @@ export function createDownloadUrlPlugin( return null; } - const s3 = resolveS3(options); - - if (isPublic && s3.publicUrlPrefix) { - // Public file: return direct URL - return `${s3.publicUrlPrefix}/${key}`; - } - - // Resolve download URL expiry from storage module config (per-database) + // Resolve per-database config (bucket, publicUrlPrefix, expiry) + let s3ForDb = resolveS3(options); // fallback to global let downloadUrlExpirySeconds = 3600; // fallback default try { const withPgClient = context.pgSettings ? context.withPgClient : null; if (withPgClient) { - const config = await withPgClient(null, async (pgClient: any) => { + const resolved = await withPgClient(null, async (pgClient: any) => { const dbResult = await pgClient.query( `SELECT jwt_private.current_database_id() AS id`, ); const databaseId = dbResult.rows[0]?.id; if (!databaseId) return null; - return getStorageModuleConfig(pgClient, databaseId); + const config = await getStorageModuleConfig(pgClient, databaseId); + if (!config) return null; + return { config, databaseId }; }); - if (config) { - downloadUrlExpirySeconds = config.downloadUrlExpirySeconds; + if (resolved) { + downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds; + s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId); } } } catch { - // Fall back to default if config lookup fails + // Fall back to global config if lookup fails + } + + if (isPublic && s3ForDb.publicUrlPrefix) { + // Public file: return direct CDN URL (per-database prefix) + return `${s3ForDb.publicUrlPrefix}/${key}`; } - // Private file: generate presigned GET URL + // Private file: generate presigned GET URL (per-database bucket) return generatePresignedGetUrl( - resolveS3(options), + s3ForDb, key, downloadUrlExpirySeconds, filename || undefined, diff --git a/graphile/graphile-presigned-url-plugin/src/index.ts b/graphile/graphile-presigned-url-plugin/src/index.ts index 17d8be9e1..803269643 100644 --- a/graphile/graphile-presigned-url-plugin/src/index.ts +++ b/graphile/graphile-presigned-url-plugin/src/index.ts @@ -42,4 +42,5 @@ export type { S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, + BucketNameResolver, } from './types'; diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts index c155e4166..a0e905492 100644 --- a/graphile/graphile-presigned-url-plugin/src/plugin.ts +++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts @@ -22,7 +22,7 @@ import type { GraphileConfig } from 'graphile-config'; import { extendSchema, gql } from 'graphile-utils'; import { Logger } from '@pgpmjs/logger'; -import type { PresignedUrlPluginOptions, S3Config } from './types'; +import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache'; import { generatePresignedPutUrl, headObject } from './s3-signer'; @@ -82,6 +82,36 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config { return options.s3; } +/** + * Build a per-database S3Config by overlaying storage_module overrides + * onto the global S3Config. + * + * - Bucket name: from resolveBucketName(databaseId) if provided, else global + * - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global + * - S3 client (credentials, endpoint): always global (shared IAM key) + */ +function resolveS3ForDatabase( + options: PresignedUrlPluginOptions, + storageConfig: StorageModuleConfig, + databaseId: string, +): S3Config { + const globalS3 = resolveS3(options); + const bucket = options.resolveBucketName + ? options.resolveBucketName(databaseId) + : globalS3.bucket; + const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix; + + if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) { + return globalS3; + } + + return { + ...globalS3, + bucket, + ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}), + }; +} + export function createPresignedUrlPlugin( options: PresignedUrlPluginOptions, ): GraphileConfig.Plugin { @@ -284,9 +314,10 @@ export function createPresignedUrlPlugin( const fileId = fileResult.rows[0].id; - // --- Generate presigned PUT URL --- + // --- Generate presigned PUT URL (per-database bucket) --- + const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId); const uploadUrl = await generatePresignedPutUrl( - resolveS3(options), + s3ForDb, s3Key, contentType, size, @@ -375,8 +406,9 @@ export function createPresignedUrlPlugin( }; } - // --- Verify file exists in S3 --- - const s3Head = await headObject(resolveS3(options), file.key, file.content_type); + // --- Verify file exists in S3 (per-database bucket) --- + const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId); + const s3Head = await headObject(s3ForDb, file.key, file.content_type); if (!s3Head) { throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet'); diff --git a/graphile/graphile-presigned-url-plugin/src/types.ts b/graphile/graphile-presigned-url-plugin/src/types.ts index 6f96b131b..0730c6883 100644 --- a/graphile/graphile-presigned-url-plugin/src/types.ts +++ b/graphile/graphile-presigned-url-plugin/src/types.ts @@ -135,10 +135,29 @@ export interface S3Config { */ export type S3ConfigOrGetter = S3Config | (() => S3Config); +/** + * Function to derive the actual S3 bucket name for a given database. + * + * When provided, the presigned URL plugin calls this on every request + * to determine which S3 bucket to use — enabling per-database bucket + * isolation. If not provided, falls back to `s3Config.bucket` (global). + * + * @param databaseId - The metaschema database UUID + * @returns The S3 bucket name for this database + */ +export type BucketNameResolver = (databaseId: string) => string; + /** * Plugin options for the presigned URL plugin. */ export interface PresignedUrlPluginOptions { /** S3 configuration (concrete or lazy getter) */ s3: S3ConfigOrGetter; + + /** + * Optional function to resolve S3 bucket name per-database. + * When set, each database gets its own S3 bucket instead of sharing + * the global `s3Config.bucket`. The S3 credentials (client) remain shared. + */ + resolveBucketName?: BucketNameResolver; } diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index e2188f5ab..e92e8d8cc 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -19,7 +19,7 @@ import { PresignedUrlPreset } from 'graphile-presigned-url-plugin'; import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator'; import { constructiveUploadFieldDefinitions } from '../upload-resolver'; -import { getPresignedUrlS3Config } from '../presigned-url-resolver'; +import { getPresignedUrlS3Config, createBucketNameResolver } from '../presigned-url-resolver'; import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver'; /** @@ -90,7 +90,7 @@ export const ConstructivePreset: GraphileConfig.Preset = { uploadFieldDefinitions: constructiveUploadFieldDefinitions, maxFileSize: 10 * 1024 * 1024, // 10MB }), - PresignedUrlPreset({ s3: getPresignedUrlS3Config }), + PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver() }), BucketProvisionerPreset({ connection: getBucketProvisionerConnection, allowedOrigins: ['http://localhost:3000'], diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts index 72af621bb..f4b813f1f 100644 --- a/graphile/graphile-settings/src/presigned-url-resolver.ts +++ b/graphile/graphile-settings/src/presigned-url-resolver.ts @@ -5,13 +5,16 @@ * (getEnvOptions → pgpmDefaults + config files + env vars) and lazily * initializes an S3Client on first use. * + * Also provides a per-database bucket name resolver that derives the + * S3 bucket name from the database UUID + a configurable prefix. + * * Follows the same lazy-init pattern as upload-resolver.ts. */ import { S3Client } from '@aws-sdk/client-s3'; import { getEnvOptions } from '@constructive-io/graphql-env'; import { Logger } from '@pgpmjs/logger'; -import type { S3Config } from 'graphile-presigned-url-plugin'; +import type { S3Config, BucketNameResolver } from 'graphile-presigned-url-plugin'; const log = new Logger('presigned-url-resolver'); @@ -23,6 +26,10 @@ let s3Config: S3Config | null = null; * Reads CDN config on first call via getEnvOptions() (which already merges * pgpmDefaults → config file → env vars), creates an S3Client, and caches * the result. Same CDN config as upload-resolver.ts. + * + * NOTE: The `bucket` field here is the global fallback bucket name + * (from BUCKET_NAME env var). When `resolveBucketName` is provided, + * per-database bucket names take precedence for all S3 operations. */ export function getPresignedUrlS3Config(): S3Config { if (s3Config) return s3Config; @@ -52,3 +59,25 @@ export function getPresignedUrlS3Config(): S3Config { return s3Config; } + +/** + * Create a per-database bucket name resolver. + * + * Uses the BUCKET_NAME env var as a prefix. For each database, the S3 bucket + * name becomes `{prefix}-{databaseId}` (e.g., "myapp-abc123def456"). + * + * In local development with MinIO (default BUCKET_NAME="test-bucket"), + * all databases share the same bucket for simplicity — the resolver + * returns the prefix as-is when it looks like a local dev bucket. + * + * In production, set BUCKET_NAME to your org prefix (e.g., "myapp") + * and each database gets its own isolated S3 bucket. + */ +export function createBucketNameResolver(): BucketNameResolver { + const { cdn } = getEnvOptions(); + const prefix = cdn?.bucketName || 'test-bucket'; + + return (databaseId: string): string => { + return `${prefix}-${databaseId}`; + }; +}