Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/default-bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Server-side bucket resolution.
*
* Which bucket a write lands in belongs to the database, never to the client and
* never to the server's environment: a client-chosen key means a different
* bucket per tenant, and an env-level bucket name means storage that belongs to
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
* place that answers it — a logical key when the field declares one, otherwise
* the reserved default tag for the requested access ('default' / 'default-public').
*
* Zero matches and several matches both raise inside SQL, so there is nothing to
* guess here: this module only carries the question in and the coordinate out.
*/

import { Logger } from '@pgpmjs/logger';

const log = new Logger('graphile-presigned-url:default-bucket');

/**
* The resolved bucket coordinate.
*
* `physicalName` is the recorded S3 bucket name, or null when the logical
* bucket has never been provisioned — the caller mints and records it then.
*/
export interface ResolvedBucketCoordinate {
bucketId: string;
resolvedKey: string;
bucketType: 'public' | 'private' | 'temp';
physicalName: string | null;
}

const RESOLVE_DEFAULT_BUCKET_QUERY = `
SELECT bucket_id, resolved_key, bucket_type, physical_name
FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
`;

/**
* Resolve the bucket a write should land in.
*
* @param scope - The storage module's scope ('app' for database-wide storage)
* @param entityId - The owning entity row for an entity-scoped module, else null
* @param publicAccess - Which reserved default tag to use when no key is named,
* and an assertion on the named bucket's type when one is
* @param bucketKey - The field's declared logical key, or null for the default
*/
export async function resolveDefaultBucket(
pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> },
databaseId: string,
scope: string,
entityId: string | null,
publicAccess: boolean,
bucketKey: string | null,
): Promise<ResolvedBucketCoordinate> {
const result = await pgClient.query({
text: RESOLVE_DEFAULT_BUCKET_QUERY,
values: [databaseId, scope, entityId, publicAccess, bucketKey],
});

const row = result.rows[0] as {
bucket_id: string;
resolved_key: string;
bucket_type: string;
physical_name: string | null;
} | undefined;

if (!row) {
// resolve_default_bucket raises on zero and on several matches, so an empty
// result means the function did not run as declared rather than "no bucket".
throw new Error(
`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
`database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`,
);
}

log.debug(
`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`,
);

return {
bucketId: row.bucket_id,
resolvedKey: row.resolved_key,
bucketType: row.bucket_type as ResolvedBucketCoordinate['bucketType'],
physicalName: row.physical_name,
};
}
163 changes: 163 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/file-ref-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* The `file_ref_field` registry: which storage module and bucket a managed
* document column writes into.
*
* An `image`/`upload` column is a projection of a files row, and the decision of
* *where* those bytes live is a property of the field declaration, not of the
* request. The registry records that intent per (table, column) — a storage
* module plus either a logical bucket key, a tag selector, or nothing at all
* (meaning the reserved default tag for the declared publicness).
*
* This module answers one question — "what does a write to this column bind
* to?" — and answers it loudly: an unregistered column raises rather than
* falling back to a server-global bucket, because a silent fallback is how the
* unmanaged lane produced objects no tenant owned.
*/

import { Logger } from '@pgpmjs/logger';
import { LRUCache } from 'lru-cache';

const log = new Logger('graphile-presigned-url:file-ref-registry');

const FIVE_MINUTES_MS = 1000 * 60 * 5;
const ONE_HOUR_MS = 1000 * 60 * 60;

/**
* A field's recorded storage intent.
*
* `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
* both may be absent — resolution then uses the reserved default tag for
* `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
* is resolved per written row, inside the tenant.
*/
export interface FileRefFieldBinding {
id: string;
storageModuleId: string;
bucketKey: string | null;
bucketTags: string[] | null;
isPublic: boolean | null;
enforceFk: boolean;
}

/**
* Resolve the registry row for a document column.
*
* Joined through metaschema rather than keyed by name, because the registry
* records field *ids*: the physical (schema, table, column) triple is what the
* GraphQL layer knows, and metaschema is the only thing that maps one to the
* other.
*/
const FILE_REF_FIELD_QUERY = `
SELECT
frf.id,
frf.storage_module_id,
frf.bucket_key,
frf.bucket_tags::text[] AS bucket_tags,
frf.is_public,
frf.enforce_fk
FROM metaschema_modules_public.file_ref_field frf
JOIN metaschema_public.field f ON f.id = frf.field_id
JOIN metaschema_public.table t ON t.id = frf.table_id
JOIN metaschema_public.schema s ON s.id = t.schema_id
WHERE frf.database_id = $1
AND s.schema_name = $2
AND t.name = $3
AND f.name = $4
LIMIT 1
`;

interface FileRefFieldRow {
id: string;
storage_module_id: string;
bucket_key: string | null;
bucket_tags: string[] | null;
is_public: boolean | null;
enforce_fk: boolean;
}

/**
* LRU cache of field bindings.
*
* A binding is schema, not data: it changes only when a database is
* re-provisioned, so it caches on the same terms as the storage module config
* next to it. Misses are never cached — an unregistered column is a hard error
* every time it is written, not a remembered "no".
*/
const bindingCache = new LRUCache<string, FileRefFieldBinding>({
max: 500,
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
updateAgeOnGet: true,
});

export class FileRefFieldNotRegisteredError extends Error {
constructor(
public readonly databaseId: string,
public readonly schemaName: string,
public readonly tableName: string,
public readonly columnName: string,
) {
super(
`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
`is not a registered file-reference field in database ${databaseId}. ` +
'A managed upload needs the declared storage module and bucket intent; ' +
'there is no server-global bucket to fall back to.',
);
this.name = 'FileRefFieldNotRegisteredError';
}
}

/**
* Look up the storage binding for a document column, or throw.
*
* The read runs on whichever client the caller passes. The registry is schema
* metadata rather than tenant rows, so callers resolve it in the system lane —
* the RLS that matters is on the files table the upload eventually writes.
*/
export async function getFileRefFieldBinding(
pgClient: { query: (opts: { text: string; values?: unknown[] }) => Promise<{ rows: unknown[] }> },
databaseId: string,
field: { schemaName: string; tableName: string; columnName: string },
): Promise<FileRefFieldBinding> {
const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
const cached = bindingCache.get(cacheKey);
if (cached) return cached;

const result = await pgClient.query({
text: FILE_REF_FIELD_QUERY,
values: [databaseId, field.schemaName, field.tableName, field.columnName],
});

if (result.rows.length === 0) {
throw new FileRefFieldNotRegisteredError(
databaseId,
field.schemaName,
field.tableName,
field.columnName,
);
}

const row = result.rows[0] as FileRefFieldRow;
const binding: FileRefFieldBinding = {
id: row.id,
storageModuleId: row.storage_module_id,
bucketKey: row.bucket_key,
bucketTags: row.bucket_tags,
isPublic: row.is_public,
enforceFk: row.enforce_fk,
};

bindingCache.set(cacheKey, binding);
log.debug(
`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
`${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`,
);

return binding;
}

/**
* Drop cached bindings. Used by tests and after a re-provision.
*/
export function clearFileRefFieldCache(): void {
bindingCache.clear();
}
16 changes: 15 additions & 1 deletion graphile/graphile-presigned-url-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,24 @@
* ```
*/

export type { ResolvedBucketCoordinate } from './default-bucket';
export { resolveDefaultBucket } from './default-bucket';
export { createDownloadUrlPlugin } from './download-url-field';
export type { FileRefFieldBinding } from './file-ref-registry';
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
export {
assertUploadAllowedByBucket,
buildFileProjection,
type FileProjection,
finalizeStagedUpload,
type ManagedUploadTarget,
resolveManagedUploadTarget,
} from './managed-upload';
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
export { createPresignedUrlPlugin,PresignedUrlPlugin } from './plugin';
export { PresignedUrlPreset } from './preset';
export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
export type {
BucketConfig,
Expand Down
Loading
Loading