graphile-presigned-url-plugin 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/download-url-field.js +3 -2
- package/esm/download-url-field.js +3 -2
- package/esm/plugin.js +31 -17
- package/esm/request-pg-client.d.ts +29 -0
- package/esm/request-pg-client.js +37 -0
- package/package.json +4 -4
- package/plugin.js +31 -17
- package/request-pg-client.d.ts +29 -0
- package/request-pg-client.js +40 -0
package/download-url-field.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
|
|
|
24
24
|
require("graphile-build");
|
|
25
25
|
const logger_1 = require("@pgpmjs/logger");
|
|
26
26
|
const grafast_1 = require("grafast");
|
|
27
|
+
const request_pg_client_1 = require("./request-pg-client");
|
|
27
28
|
const s3_signer_1 = require("./s3-signer");
|
|
28
29
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
29
30
|
const log = new logger_1.Logger('graphile-presigned-url:download-url');
|
|
@@ -115,7 +116,7 @@ function createDownloadUrlPlugin(options) {
|
|
|
115
116
|
let downloadUrlExpirySeconds = 3600;
|
|
116
117
|
try {
|
|
117
118
|
if (withPgClient && pgSettings) {
|
|
118
|
-
const databaseId = await withPgClient
|
|
119
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
119
120
|
const dbResult = await pgClient.query({
|
|
120
121
|
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
121
122
|
});
|
|
@@ -127,7 +128,7 @@ function createDownloadUrlPlugin(options) {
|
|
|
127
128
|
? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
|
|
128
129
|
: null;
|
|
129
130
|
const resolved = config && bucketId
|
|
130
|
-
? await withPgClient
|
|
131
|
+
? await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
131
132
|
// Look up the stored physical coordinate for scoped S3 resolution
|
|
132
133
|
const bucketResult = await pgClient.query({
|
|
133
134
|
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import 'graphile-build';
|
|
22
22
|
import { Logger } from '@pgpmjs/logger';
|
|
23
23
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
24
|
+
import { withRequestPgClient } from './request-pg-client';
|
|
24
25
|
import { generatePresignedGetUrl } from './s3-signer';
|
|
25
26
|
import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
26
27
|
const log = new Logger('graphile-presigned-url:download-url');
|
|
@@ -112,7 +113,7 @@ export function createDownloadUrlPlugin(options) {
|
|
|
112
113
|
let downloadUrlExpirySeconds = 3600;
|
|
113
114
|
try {
|
|
114
115
|
if (withPgClient && pgSettings) {
|
|
115
|
-
const databaseId = await withPgClient
|
|
116
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
116
117
|
const dbResult = await pgClient.query({
|
|
117
118
|
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
118
119
|
});
|
|
@@ -124,7 +125,7 @@ export function createDownloadUrlPlugin(options) {
|
|
|
124
125
|
? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
|
|
125
126
|
: null;
|
|
126
127
|
const resolved = config && bucketId
|
|
127
|
-
? await withPgClient
|
|
128
|
+
? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
128
129
|
// Look up the stored physical coordinate for scoped S3 resolution
|
|
129
130
|
const bucketResult = await pgClient.query({
|
|
130
131
|
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
package/esm/plugin.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import 'graphile-build';
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
|
+
import { withRequestPgClient } from './request-pg-client';
|
|
22
23
|
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
23
24
|
import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
24
25
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
@@ -116,10 +117,15 @@ function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
|
116
117
|
* value is the durable coordinate: route resolution and every later read use
|
|
117
118
|
* it verbatim; nothing is recomputed.
|
|
118
119
|
*
|
|
119
|
-
* The record write runs in the system lane (
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
120
|
+
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
121
|
+
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
122
|
+
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
123
|
+
* claim, because the buckets table's catalog-sync trigger calls
|
|
124
|
+
* `jwt_private.current_database_id()` and would otherwise raise
|
|
125
|
+
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
126
|
+
* write's transaction without switching off the privileged role.
|
|
127
|
+
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
128
|
+
* the recorded name without a DB round-trip.
|
|
123
129
|
*/
|
|
124
130
|
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
125
131
|
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
@@ -131,7 +137,9 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
|
|
|
131
137
|
}
|
|
132
138
|
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
133
139
|
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
134
|
-
|
|
140
|
+
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
141
|
+
// write runs under the resolved database claim (privileged role preserved).
|
|
142
|
+
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
135
143
|
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
136
144
|
SET physical_name = $1
|
|
137
145
|
WHERE id = $2 AND physical_name IS NULL`,
|
|
@@ -244,7 +252,10 @@ export function createPresignedUrlPlugin(options) {
|
|
|
244
252
|
pgSettings: $pgSettings,
|
|
245
253
|
});
|
|
246
254
|
return lambda($combined, async (vals) => {
|
|
247
|
-
|
|
255
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
256
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
257
|
+
// across every statement (see withRequestPgClient).
|
|
258
|
+
const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
248
259
|
if (!databaseId)
|
|
249
260
|
throw new Error('DATABASE_NOT_FOUND');
|
|
250
261
|
// Module registration is server config, not user data:
|
|
@@ -254,7 +265,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
254
265
|
if (!storageConfig)
|
|
255
266
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
256
267
|
// Bucket config read under the request role (RLS-gated visibility).
|
|
257
|
-
const bucket = await vals.withPgClient
|
|
268
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
258
269
|
if (!bucket)
|
|
259
270
|
throw new Error('BUCKET_NOT_FOUND');
|
|
260
271
|
// First provision mints + records the coordinate; afterwards the
|
|
@@ -264,13 +275,13 @@ export function createPresignedUrlPlugin(options) {
|
|
|
264
275
|
: bucket.physical_name;
|
|
265
276
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
266
277
|
// File row INSERT under the request role (RLS enforced).
|
|
267
|
-
return vals.withPgClient
|
|
278
|
+
return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
268
279
|
contentHash: vals.contentHash,
|
|
269
280
|
contentType: vals.contentType,
|
|
270
281
|
size: vals.size,
|
|
271
282
|
filename: vals.filename,
|
|
272
283
|
key: vals.customKey,
|
|
273
|
-
}))
|
|
284
|
+
}));
|
|
274
285
|
});
|
|
275
286
|
},
|
|
276
287
|
});
|
|
@@ -335,7 +346,10 @@ export function createPresignedUrlPlugin(options) {
|
|
|
335
346
|
pgSettings: $pgSettings,
|
|
336
347
|
});
|
|
337
348
|
return lambda($combined, async (vals) => {
|
|
338
|
-
|
|
349
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
350
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
351
|
+
// across every statement (see withRequestPgClient).
|
|
352
|
+
const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
339
353
|
if (!databaseId)
|
|
340
354
|
throw new Error('DATABASE_NOT_FOUND');
|
|
341
355
|
// Module registration is server config, not user data:
|
|
@@ -345,7 +359,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
345
359
|
if (!storageConfig)
|
|
346
360
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
347
361
|
// Bucket config read under the request role (RLS-gated visibility).
|
|
348
|
-
const bucket = await vals.withPgClient
|
|
362
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
349
363
|
if (!bucket)
|
|
350
364
|
throw new Error('BUCKET_NOT_FOUND');
|
|
351
365
|
// Enforce bulk upload limits
|
|
@@ -364,7 +378,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
364
378
|
: bucket.physical_name;
|
|
365
379
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
366
380
|
// File row INSERTs under the request role (RLS enforced).
|
|
367
|
-
return vals.withPgClient
|
|
381
|
+
return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
368
382
|
const results = [];
|
|
369
383
|
for (const file of filesArray) {
|
|
370
384
|
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
@@ -376,7 +390,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
376
390
|
}));
|
|
377
391
|
}
|
|
378
392
|
return { files: results };
|
|
379
|
-
})
|
|
393
|
+
});
|
|
380
394
|
});
|
|
381
395
|
},
|
|
382
396
|
});
|
|
@@ -421,7 +435,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
421
435
|
const pgSettings = graphqlContext.pgSettings;
|
|
422
436
|
if (withPgClient) {
|
|
423
437
|
try {
|
|
424
|
-
const databaseId = await withPgClient
|
|
438
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
425
439
|
// Module registration is server config, not user data:
|
|
426
440
|
// resolve it without the request role's pgSettings.
|
|
427
441
|
const allConfigs = databaseId
|
|
@@ -429,7 +443,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
429
443
|
: [];
|
|
430
444
|
const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
|
|
431
445
|
if (storageConfig) {
|
|
432
|
-
await withPgClient
|
|
446
|
+
await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
433
447
|
// Read the file row (RLS enforced)
|
|
434
448
|
const result = await pgClient.query({
|
|
435
449
|
text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
@@ -454,7 +468,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
454
468
|
const pgSettings = graphqlContext.pgSettings;
|
|
455
469
|
if (withPgClient) {
|
|
456
470
|
try {
|
|
457
|
-
const databaseId = await withPgClient
|
|
471
|
+
const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
458
472
|
// Module registration is server config, not user data:
|
|
459
473
|
// resolve it without the request role's pgSettings.
|
|
460
474
|
const allConfigs = databaseId
|
|
@@ -462,7 +476,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
462
476
|
: [];
|
|
463
477
|
const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
|
|
464
478
|
if (storageConfig)
|
|
465
|
-
await withPgClient
|
|
479
|
+
await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
|
|
466
480
|
// Check refcount: any other file with the same key in this bucket?
|
|
467
481
|
const refResult = await pgClient.query({
|
|
468
482
|
text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
export interface RequestPgClient {
|
|
20
|
+
query(opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values?: unknown[];
|
|
23
|
+
}): Promise<{
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
}>;
|
|
26
|
+
withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
|
|
29
|
+
export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
async function applyRequestSettings(tx, pgSettings) {
|
|
20
|
+
if (!pgSettings)
|
|
21
|
+
return;
|
|
22
|
+
const entries = Object.entries(pgSettings)
|
|
23
|
+
.filter(([, value]) => value != null)
|
|
24
|
+
.map(([key, value]) => [key, String(value)]);
|
|
25
|
+
if (entries.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
await tx.query({
|
|
28
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
29
|
+
values: [JSON.stringify(entries)],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function withRequestPgClient(withPgClient, pgSettings, cb) {
|
|
33
|
+
return withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
34
|
+
await applyRequestSettings(tx, pgSettings);
|
|
35
|
+
return cb(tx);
|
|
36
|
+
}));
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@aws-sdk/client-s3": "^3.1052.0",
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
|
-
"@pgpmjs/logger": "^2.
|
|
45
|
+
"@pgpmjs/logger": "^2.24.0",
|
|
46
46
|
"@pgsql/quotes": "^18.2.0",
|
|
47
47
|
"lru-cache": "^11.2.7"
|
|
48
48
|
},
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"postgraphile": "5.0.3"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@constructive-io/s3-utils": "^2.
|
|
59
|
+
"@constructive-io/s3-utils": "^2.30.0",
|
|
60
60
|
"@types/node": "^22.19.11",
|
|
61
61
|
"makage": "^0.3.0"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "d508453a32a2f1d70ff6916871046615226e6f33"
|
|
64
64
|
}
|
package/plugin.js
CHANGED
|
@@ -23,6 +23,7 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
23
|
require("graphile-build");
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
|
+
const request_pg_client_1 = require("./request-pg-client");
|
|
26
27
|
const s3_signer_1 = require("./s3-signer");
|
|
27
28
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
28
29
|
const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
@@ -120,10 +121,15 @@ function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
|
120
121
|
* value is the durable coordinate: route resolution and every later read use
|
|
121
122
|
* it verbatim; nothing is recomputed.
|
|
122
123
|
*
|
|
123
|
-
* The record write runs in the system lane (
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
124
|
+
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
125
|
+
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
126
|
+
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
127
|
+
* claim, because the buckets table's catalog-sync trigger calls
|
|
128
|
+
* `jwt_private.current_database_id()` and would otherwise raise
|
|
129
|
+
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
130
|
+
* write's transaction without switching off the privileged role.
|
|
131
|
+
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
132
|
+
* the recorded name without a DB round-trip.
|
|
127
133
|
*/
|
|
128
134
|
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
129
135
|
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
@@ -135,7 +141,9 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
|
|
|
135
141
|
}
|
|
136
142
|
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
137
143
|
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
138
|
-
|
|
144
|
+
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
145
|
+
// write runs under the resolved database claim (privileged role preserved).
|
|
146
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
139
147
|
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
140
148
|
SET physical_name = $1
|
|
141
149
|
WHERE id = $2 AND physical_name IS NULL`,
|
|
@@ -248,7 +256,10 @@ function createPresignedUrlPlugin(options) {
|
|
|
248
256
|
pgSettings: $pgSettings,
|
|
249
257
|
});
|
|
250
258
|
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
251
|
-
|
|
259
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
260
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
261
|
+
// across every statement (see withRequestPgClient).
|
|
262
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
252
263
|
if (!databaseId)
|
|
253
264
|
throw new Error('DATABASE_NOT_FOUND');
|
|
254
265
|
// Module registration is server config, not user data:
|
|
@@ -258,7 +269,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
258
269
|
if (!storageConfig)
|
|
259
270
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
260
271
|
// Bucket config read under the request role (RLS-gated visibility).
|
|
261
|
-
const bucket = await vals.withPgClient
|
|
272
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
262
273
|
if (!bucket)
|
|
263
274
|
throw new Error('BUCKET_NOT_FOUND');
|
|
264
275
|
// First provision mints + records the coordinate; afterwards the
|
|
@@ -268,13 +279,13 @@ function createPresignedUrlPlugin(options) {
|
|
|
268
279
|
: bucket.physical_name;
|
|
269
280
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
270
281
|
// File row INSERT under the request role (RLS enforced).
|
|
271
|
-
return vals.withPgClient
|
|
282
|
+
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
272
283
|
contentHash: vals.contentHash,
|
|
273
284
|
contentType: vals.contentType,
|
|
274
285
|
size: vals.size,
|
|
275
286
|
filename: vals.filename,
|
|
276
287
|
key: vals.customKey,
|
|
277
|
-
}))
|
|
288
|
+
}));
|
|
278
289
|
});
|
|
279
290
|
},
|
|
280
291
|
});
|
|
@@ -339,7 +350,10 @@ function createPresignedUrlPlugin(options) {
|
|
|
339
350
|
pgSettings: $pgSettings,
|
|
340
351
|
});
|
|
341
352
|
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
342
|
-
|
|
353
|
+
// Request-lane reads/writes run under the request role's pgSettings
|
|
354
|
+
// inside an explicit transaction so the jwt claims stay applied
|
|
355
|
+
// across every statement (see withRequestPgClient).
|
|
356
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
343
357
|
if (!databaseId)
|
|
344
358
|
throw new Error('DATABASE_NOT_FOUND');
|
|
345
359
|
// Module registration is server config, not user data:
|
|
@@ -349,7 +363,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
349
363
|
if (!storageConfig)
|
|
350
364
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
351
365
|
// Bucket config read under the request role (RLS-gated visibility).
|
|
352
|
-
const bucket = await vals.withPgClient
|
|
366
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
353
367
|
if (!bucket)
|
|
354
368
|
throw new Error('BUCKET_NOT_FOUND');
|
|
355
369
|
// Enforce bulk upload limits
|
|
@@ -368,7 +382,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
368
382
|
: bucket.physical_name;
|
|
369
383
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
370
384
|
// File row INSERTs under the request role (RLS enforced).
|
|
371
|
-
return vals.withPgClient
|
|
385
|
+
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
372
386
|
const results = [];
|
|
373
387
|
for (const file of filesArray) {
|
|
374
388
|
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
@@ -380,7 +394,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
380
394
|
}));
|
|
381
395
|
}
|
|
382
396
|
return { files: results };
|
|
383
|
-
})
|
|
397
|
+
});
|
|
384
398
|
});
|
|
385
399
|
},
|
|
386
400
|
});
|
|
@@ -425,7 +439,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
425
439
|
const pgSettings = graphqlContext.pgSettings;
|
|
426
440
|
if (withPgClient) {
|
|
427
441
|
try {
|
|
428
|
-
const databaseId = await withPgClient
|
|
442
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
429
443
|
// Module registration is server config, not user data:
|
|
430
444
|
// resolve it without the request role's pgSettings.
|
|
431
445
|
const allConfigs = databaseId
|
|
@@ -433,7 +447,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
433
447
|
: [];
|
|
434
448
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
|
|
435
449
|
if (storageConfig) {
|
|
436
|
-
await withPgClient
|
|
450
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
437
451
|
// Read the file row (RLS enforced)
|
|
438
452
|
const result = await pgClient.query({
|
|
439
453
|
text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
@@ -458,7 +472,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
458
472
|
const pgSettings = graphqlContext.pgSettings;
|
|
459
473
|
if (withPgClient) {
|
|
460
474
|
try {
|
|
461
|
-
const databaseId = await withPgClient
|
|
475
|
+
const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
|
|
462
476
|
// Module registration is server config, not user data:
|
|
463
477
|
// resolve it without the request role's pgSettings.
|
|
464
478
|
const allConfigs = databaseId
|
|
@@ -466,7 +480,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
466
480
|
: [];
|
|
467
481
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
|
|
468
482
|
if (storageConfig)
|
|
469
|
-
await withPgClient
|
|
483
|
+
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
|
|
470
484
|
// Check refcount: any other file with the same key in this bucket?
|
|
471
485
|
const refResult = await pgClient.query({
|
|
472
486
|
text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
3
|
+
*
|
|
4
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
5
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
6
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
7
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
8
|
+
* setting applied for one statement is already gone by the next. The request
|
|
9
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
10
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
11
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
12
|
+
*
|
|
13
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
14
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
15
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
16
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
17
|
+
* manual, multi-statement RLS work.
|
|
18
|
+
*/
|
|
19
|
+
export interface RequestPgClient {
|
|
20
|
+
query(opts: {
|
|
21
|
+
text: string;
|
|
22
|
+
values?: unknown[];
|
|
23
|
+
}): Promise<{
|
|
24
|
+
rows: Array<Record<string, unknown>>;
|
|
25
|
+
}>;
|
|
26
|
+
withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
|
|
29
|
+
export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Run a callback under the request's pgSettings inside ONE explicit transaction.
|
|
4
|
+
*
|
|
5
|
+
* The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
|
|
6
|
+
* transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
|
|
7
|
+
* more than one statement WITHOUT an explicit surrounding transaction, each
|
|
8
|
+
* statement runs in its own implicit (autocommit) transaction, so a LOCAL
|
|
9
|
+
* setting applied for one statement is already gone by the next. The request
|
|
10
|
+
* role's jwt claims (notably `jwt.claims.database_id`) then vanish between
|
|
11
|
+
* statements, and `jwt_private.current_database_id()` raises
|
|
12
|
+
* DATABASE_CLAIM_REQUIRED even though the request carried the claim.
|
|
13
|
+
*
|
|
14
|
+
* This helper acquires the client without settings (`withPgClient(null, ...)`),
|
|
15
|
+
* opens a single explicit transaction, and applies the request settings inside
|
|
16
|
+
* it — so every statement in `cb` observes the same role and jwt claims. It
|
|
17
|
+
* mirrors the `pg-query-context` pattern used elsewhere in the codebase for
|
|
18
|
+
* manual, multi-statement RLS work.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.withRequestPgClient = withRequestPgClient;
|
|
22
|
+
async function applyRequestSettings(tx, pgSettings) {
|
|
23
|
+
if (!pgSettings)
|
|
24
|
+
return;
|
|
25
|
+
const entries = Object.entries(pgSettings)
|
|
26
|
+
.filter(([, value]) => value != null)
|
|
27
|
+
.map(([key, value]) => [key, String(value)]);
|
|
28
|
+
if (entries.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
await tx.query({
|
|
31
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
32
|
+
values: [JSON.stringify(entries)],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function withRequestPgClient(withPgClient, pgSettings, cb) {
|
|
36
|
+
return withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
37
|
+
await applyRequestSettings(tx, pgSettings);
|
|
38
|
+
return cb(tx);
|
|
39
|
+
}));
|
|
40
|
+
}
|