Skip to content

fix(presigned-url): resolve a visible file's bucket through the generated physical-name reader - #1916

Merged
pyramation merged 2 commits into
mainfrom
devin/1792192000-download-url-login-reader
Oct 17, 2026
Merged

pyramation merged 2 commits into
mainfrom
devin/1792192000-download-url-login-reader

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Regression from #1907. downloadUrl (and the sync S3 delete) moved from the system lane to the request lane, but they still read the bucket row directly:

SELECT key, physical_name FROM <buckets> WHERE id = $1

File RLS and bucket RLS are different on purpose. A reader can see a file (a public file in another entity, or a path share) without seeing the bucket row behind it. When that happens the lookup returns 0 rows, and downloadUrl comes back null. This fails constructive-db#4083 (entity-storage-security, scenarios A and B: 33/35 tests pass after the bump, 35/35 before it).

Both paths now call the storage module's generated reader, <private_schema>.<buckets_table>_physical_name(bucket_id), on the caller's own request lane (withRequestPgClient, with the request's claims):

// download-url-field.ts / plugin.ts delete path
physicalName = await readBucketPhysicalName(pgClient, config, bucketId);
// -> SELECT "<private>"."<buckets>_physical_name"($1) AS physical_name
  • The visible file row is still the authorization. The reader only maps that file's bucket_id to its provisioned physical name.
  • The reader is generated in constructive-db (storage_module.sql, pushed to the constructive-db#4083 branch). It is SECURITY DEFINER and returns only physical_name, and only when the bucket table's schema belongs to jwt_private.require_database_id(). EXECUTE is revoked from PUBLIC and granted to authenticated/anonymous.
  • The new code has no administrator lane, no BYPASSRLS, no fallback, and no bucket-RLS change. StorageModuleConfig.bucketPhysicalNameQualifiedName comes from the module's private_schema. If that's missing, readBucketPhysicalName throws STORAGE_BUCKET_READER_MISSING instead of returning null.

Coupling: this needs the constructive-db generator change. Publish this package, then bump it in constructive-db#4083.

Tests

  • New __tests__/bucket-physical-name.test.ts (3 tests): calls the generated function with a bound parameter, never touches the bucket table, returns null when no name is recorded, and throws when the reader is missing.
  • Plugin unit suites (non-RustFS): 90/90 pass.
  • constructive-db entity-storage-security.integration.test.ts, run against this build plus the regenerated generator: 35/35 pass. On the published bump without this fix it's 33/35, with downloadUrl null in scenarios A and B.

Link to Devin session: https://app.devin.ai/sessions/6bb9de193ee8459ab6fb71e03fbf4b42
Open in Devin Desktop: https://app.devin.ai/desktop/session/6bb9de193ee8459ab6fb71e03fbf4b42?variant=devin
Requested by: @pyramation

…h the generated reader

downloadUrl and the sync S3 delete read the bucket row directly on the
request lane, so a reader who sees a file through file RLS (public file in
another entity, path share) but not its bucket row got a null downloadUrl.
Both now call the storage module's generated <buckets>_physical_name(bucket_id)
reader (SECURITY DEFINER, scoped to the caller's claimed database), on the
caller's own request lane.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 1 medium

💬 Inline comments (1)

📍 Findings outside the diff (1) — 🟡 1 medium — defects on lines GitHub can't attach comments to

🟡 Medium — Storage fixtures lack the new _physical_name reader · graphql/server-test/fixtures/seed/simple-seed-storage/schema.sql

The plugin now resolves the bucket coordinate exclusively through a generated <buckets_table>_physical_name private-schema function (storage-module-cache.ts:122-124, :301), but the storage-module seed fixtures in this repo define only the physical_name column and the analogous <files>_record_file reader — no bucket physical-name reader exists in graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql or db-scope-storage/schema.sql. Any integration path that resolves a downloadUrl or triggers delete-time S3 cleanup against these fixtures fails with function ... does not exist, breaking coverage for exactly the RLS scenarios this change targets. If the real generator lives out-of-repo, this repo's fixture contract is silently out of sync with the plugin's new requirement.

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Doc comment detached from storedPhysicalName (storage-module-cache.ts:280) — The JSDoc block that documents the physical-name normalization contract now sits above the new readBucketPhysicalName doc block instead of above storedPhysicalName, which it actually describes (graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts:272-279 vs the function at line 307).

This PR replaces the plugin's direct read of bucket rows with a dedicated _physical_name reader function resolved from the module config, so physical bucket coordinates for download signing, delete cleanup, and upload recording all flow through one SQL reader instead of ad-hoc bucket queries.

Files Change
graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts, src/types.ts Adds the qualified reader-function plumbing (bucketPhysicalNameQualifiedName) and resolves storedPhysicalName via the new reader, with a clear error when the module lacks the reader.
graphile/graphile-presigned-url-plugin/src/download-url-field.ts, src/plugin.ts Refactors the download resolver and delete-path call sites to await the reader, propagating errors instead of falling back to null.
graphile/graphile-presigned-url-plugin/__tests__/*.test.ts Updates bucket-name, custom-key upload, dedup liveness, managed upload, and recorder tests to exercise the new coordinate resolution.
graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql Storage seed fixtures used by server tests (no fixture-side reader function added yet).

Reviewed commit: e0e43fc

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolves physical bucket names through a new SECURITY DEFINER reader in the presigned-URL plugin, with test coverage for buckets, uploads, dedup, and recording.

Key findings

Comment on lines 132 to 143
const resolved = config && bucketId
? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
const bucketResult = await pgClient.query({
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
values: [bucketId],
});
const row = bucketResult.rows[0] as { key: string; physical_name?: string | null } | undefined;
return row ? { config, physicalName: storedPhysicalName(row) } : null;
})
? {
config,
physicalName: await withRequestPgClient(
withPgClient,
pgSettings,
(pgClient) => readBucketPhysicalName(pgClient, config, bucketId),
),
}
: null;
// No resolvable bucket row, or no physical bucket was ever
// provisioned: no object can exist.
// No physical bucket was ever provisioned: no object can exist.
if (!resolved || resolved.physicalName === null) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Download resolver now throws where it previously returned null

The download resolver replaced a direct bucket-table read that degraded to null with readBucketPhysicalName, which throws STORAGE_BUCKET_READER_MISSING when the module records no private_schema (graphile/graphile-presigned-url-plugin/src/download-url-field.ts:132-143; throw at storage-module-cache.ts:297-299). The Grafast lambda has no error handling, so a missing reader function (legacy module predating the generated reader) or a null private_schema surfaces as a GraphQL field error instead of the previous downloadUrl: null. The delete path has the same regression in milder form: the reader throw is swallowed by the generic catch at plugin.ts:570-573 and logged as a transient S3 failure, silently skipping sync S3 cleanup. Legacy or reader-less storage modules go from working (or cleanly skipping) to hard errors and orphaned S3 objects.

📋 Prompt for AI Agents

In graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts around lines 292-305, change readBucketPhysicalName so that a null config.bucketPhysicalNameQualifiedName (currently throwing STORAGE_BUCKET_READER_MISSING at line 297-298) and a missing/unexecutable reader function (SQLSTATE 42883) fall back to a direct SELECT physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1 read under the caller's RLS, returning null when the row is absent — the pre-patch behavior of both download-url-field.ts:132-143 and plugin.ts:560. Optionally distinguish the reader-unavailable case in plugin.ts's catch (lines 570-573) with a distinct log so it is not misreported as a transient S3 failure.

@pyramation
pyramation merged commit 05db98c into main Oct 17, 2026
20 checks passed
@pyramation
pyramation deleted the devin/1792192000-download-url-login-reader branch October 17, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant