Skip to content

feat(storage): optional databaseId on uploadFile selects the file row's database - #1975

Merged
pyramation merged 1 commit into
mainfrom
feat/upload-file-database-scope
Nov 9, 2026
Merged

pyramation merged 1 commit into
mainfrom
feat/upload-file-database-scope

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Tenant-lane uploadFile / uploadFiles gain an optional databaseId, matching the unprefixed-op convention (sdk-scope-syntax): the input carries the row's scope, identity stays on the JWT/GUC claims.

Why: a shared database-scope plane (one buckets/files pair hosted by the storage host's database, rows keyed by database_id) had no way to land a file in any database but the claim's. On the local stack, dashboard branding for a tenant site failed with BUCKET_NOT_FOUND because storage.localhost always runs with the platform database's claim.

const databaseId = input.databaseId ?? claimDatabaseId;    // omitted -> claim database (unchanged)
// explicit: the target's default bucket is read under the request role
SELECT key FROM <buckets> WHERE database_id = $1 AND $2 = ANY(tags)   // 'default' | 'default-public'
// no row visible -> DATABASE_ACCESS_DENIED (RLS/membership decided it)
  • The field is only emitted when the buckets codec has a database_id column (StorageRegistryEntry.hasDatabaseId), so platform-plane uploadPlatformFile is unchanged.
  • getBucketConfig(..., byDatabase) adds database_id = $n, so same-key buckets of different databases on one plane can't be confused. The cache key already included the database.
  • Storage modules still load from the claim database. Bucket lookup, physical-name resolution and the file insert all use the selected database, under the request role. There's no SECURITY DEFINER, role switch or BYPASSRLS.

Tests: upload-database-selector.integration.test.ts runs over HTTP with fixture session tokens resolved by the API's authenticate (the shared-db-scope-storage fixture is a shared plane with membership RLS):

  • a member of A uploads into A, and the files row has database_id = A;
  • the same member naming B gets DATABASE_ACCESS_DENIED and no row is written;
  • an anonymous caller gets DATABASE_ACCESS_DENIED.

Related: the constructive-db default buckets PR (shared-plane default / default-public per tenant, plus a cascade on database delete), and dashboard#318, which passes the site's databaseId.

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

…'s database

The tenant lane's upload carries the row's scope like every other unprefixed
operation. Omitted, it is the session's database claim; named, it picks that
database's bucket on a shared database-scope plane under the request role, so
the plane's membership RLS decides access and a non-member is refused with
DATABASE_ACCESS_DENIED. Identity stays on the claims.
@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 Nov 7, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

  • 🟡 byDatabase filter breaks claim-scoped default-bucket lookup — plugin.ts:299
🧹 Nitpicks (3) — 🟢 3 low
  • 🟢 Missing bucket reported as DATABASE_ACCESS_DENIED (plugin.ts:302) — When a caller supplies databaseId, any null bucket result throws DATABASE_ACCESS_DENIED (graphile/graphile-presigned-url-plugin/src/plugin.ts:302, and the bulk path at line 431), but the lookup also returns null for reasons that are not authorization: the named database has no bucket tagged default/default-public, or the caller supplied a bucketKey that simply does not exist in that database.
  • 🟢 Bucket cache serves foreign-tenant config without RLS (plugin.ts:92) — On the bucketKey path, the caller-supplied databaseId flows straight into getBucketConfig (graphile/graphile-presigned-url-plugin/src/plugin.ts:92-93), which returns a cache hit without querying the database (graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts:331-335); the cache key carries no requester identity.
  • 🟢 Explicit-database default-bucket query skips ambiguity guard (plugin.ts:100) — The explicit-database path resolves the default bucket with an ad-hoc SELECT key ... WHERE database_id = $1 AND $2 = ANY(tags) LIMIT 1 (graphile/graphile-presigned-url-plugin/src/plugin.ts:100-105), whereas the claim-scoped path delegates to resolve_default_bucket, whose documented invariant is that zero and multiple matches both raise inside SQL (graphile/graphile-presigned-url-plugin/src/default-bucket.ts:11-12).

This PR lets callers pass an explicit databaseId (UUID) to the single and bulk presigned upload mutations, resolving buckets against a shared storage table by database_id instead of only the JWT claim. The schema contract is extended via hasDatabaseId detection on the buckets codec, and a new shared-db-scope-storage fixture plus an integration test exercise allowed, denied, and omitted-database behaviors.

Files Change
graphile/graphile-presigned-url-plugin/src/plugin.ts Adds the databaseId input, an explicit-database bucket resolution branch, and DATABASE_ACCESS_DENIED handling for scoped lookups.
graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts Extends getBucketConfig with a byDatabase scope filter combining owner and database_id predicates.
graphile/graphile-storage-registry/src/pairing.ts Detects a database_id attribute on the buckets codec and exposes hasDatabaseId to the plugin.
graphql/server-test/__fixtures__/seed/shared-db-scope-storage/* New seed schema/test data for a shared storage table with RLS policies across multiple databases.
graphql/server-test/__tests__/upload-database-selector.integration.test.ts New integration tests covering allowed, denied, and omitted-database upload paths.

Reviewed commit: bd01587

@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.

Adds an optional databaseId input to the presigned upload mutations so storage buckets can be scoped per database, with a new shared-db-scope seed fixture and integration tests covering allowed, denied, and omitted-database paths.

Key findings

  • 🟡 byDatabase filter breaks claim-scoped default-bucket lookup — plugin.ts:299

resolveUploadBucket(
pgClient, storageConfig, databaseId,
vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true,
hasDatabaseId, vals.databaseId != 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 · medium

byDatabase filter breaks claim-scoped default-bucket lookup

resolveUploadBucket is called with byDatabase = hasDatabaseId at every call site (graphile/graphile-presigned-url-plugin/src/plugin.ts:299, also lines 117 and 428), so on any plane whose buckets table has a database_id column, getBucketConfig appends AND database_id = $2 even when the caller omitted databaseId and the lookup is claim-scoped. On a shared database-keyed plane the session claim is the storage host's database, while bucket rows carry per-tenant database_id values, so the resolved default key cannot match its own database_id filter and the mutation fails with BUCKET_NOT_FOUND where the pre-PR key-only (RLS-filtered) lookup succeeded. Existing deployments with database_id-bearing bucket rows and NULL or differently-scoped values hit the same regression.

📋 Prompt for AI Agents

In graphile/graphile-presigned-url-plugin/src/plugin.ts, at the three resolveUploadBucket(...) call sites (lines ~117, ~299, ~428), change the byDatabase argument from hasDatabaseId to hasDatabaseId && vals.databaseId != null so the AND database_id = $N filter in getBucketConfig (storage-module-cache.ts:345) applies only when the caller explicitly supplied a databaseId; the claim-scoped default path must keep the pre-PR unscoped bucket lookup for backward compatibility.

@pyramation
pyramation merged commit d17bdac into main Nov 9, 2026
20 checks passed
@pyramation
pyramation deleted the feat/upload-file-database-scope branch November 9, 2026 00:40
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