From 455ed816baf287a005f33c843bfb65e2aecc046a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 02:29:42 +0000 Subject: [PATCH 1/8] feat: add Alice/Bob RLS + feature flag integration tests - Extend seed setup.sql with database_settings and api_settings tables - Add Bob's storage schema with RLS policies (anonymous sees public-bucket files only) - Seed Bob's tenant data: database, APIs, storage_module, buckets, settings - Add feature flag tests: presigned uploads enabled/disabled via api_settings cascade - Add tenant isolation tests: Alice and Bob cannot see each other's files - Add RLS enforcement tests: anonymous role restricted to public-bucket files --- .../seed/simple-seed-services/setup.sql | 38 +++ .../seed/simple-seed-storage/schema.sql | 77 ++++++ .../seed/simple-seed-storage/test-data.sql | 116 +++++++- .../__tests__/upload.integration.test.ts | 259 +++++++++++++++++- 4 files changed, 482 insertions(+), 8 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-services/setup.sql b/graphql/server-test/__fixtures__/seed/simple-seed-services/setup.sql index 1a4a35cba..9f50cb495 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-services/setup.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-services/setup.sql @@ -289,6 +289,42 @@ COMMENT ON CONSTRAINT tokens_table_fkey ON metaschema_modules_public.rls_module COMMENT ON CONSTRAINT users_table_fkey ON metaschema_modules_public.rls_module IS E'@omit'; CREATE INDEX rls_module_database_id_idx ON metaschema_modules_public.rls_module ( database_id ); +-- database_settings table (typed feature flags — database-wide defaults) +CREATE TABLE IF NOT EXISTS services_public.database_settings ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL UNIQUE, + enable_aggregates boolean NOT NULL DEFAULT false, + enable_postgis boolean NOT NULL DEFAULT true, + enable_search boolean NOT NULL DEFAULT true, + enable_direct_uploads boolean NOT NULL DEFAULT true, + enable_presigned_uploads boolean NOT NULL DEFAULT true, + enable_many_to_many boolean NOT NULL DEFAULT true, + enable_connection_filter boolean NOT NULL DEFAULT true, + enable_ltree boolean NOT NULL DEFAULT true, + enable_llm boolean NOT NULL DEFAULT false, + options jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT ds_db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE +); + +-- api_settings table (per-API overrides — NULL = inherit from database_settings) +CREATE TABLE IF NOT EXISTS services_public.api_settings ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + api_id uuid NOT NULL UNIQUE, + enable_aggregates boolean, + enable_postgis boolean, + enable_search boolean, + enable_direct_uploads boolean, + enable_presigned_uploads boolean, + enable_many_to_many boolean, + enable_connection_filter boolean, + enable_ltree boolean, + enable_llm boolean, + options jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT as_db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT as_api_fkey FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE +); + -- Grant permissions on metaschema tables GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.database TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.schema TO administrator, authenticated, anonymous; @@ -301,4 +337,6 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.domains TO administrator GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.api_schemas TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.api_extensions TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.api_modules TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.database_settings TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.api_settings TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_modules_public.rls_module TO administrator, authenticated, anonymous; diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql index 530074ff8..e3afd7202 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql @@ -57,3 +57,80 @@ COMMENT ON TABLE "simple-storage-public".app_files IS E'@storageFiles\nStorage f -- Grant table permissions (allow anonymous to do CRUD for tests — no RLS) GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-storage-public".app_buckets TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-storage-public".app_files TO administrator, authenticated, anonymous; + +-- ===================================================== +-- BOB'S STORAGE SCHEMA (separate tenant with RLS) +-- ===================================================== + +CREATE SCHEMA IF NOT EXISTS "bob-storage-public"; + +GRANT USAGE ON SCHEMA "bob-storage-public" TO administrator, authenticated, anonymous; + +ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" + GRANT ALL ON TABLES TO administrator; +ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" + GRANT USAGE ON SEQUENCES TO administrator, authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" + GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; + +-- Buckets table (same structure as Alice) +CREATE TABLE IF NOT EXISTS "bob-storage-public".app_buckets ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + key text NOT NULL, + type text NOT NULL DEFAULT 'private', + is_public boolean NOT NULL DEFAULT false, + allowed_mime_types text[] NULL, + max_file_size bigint NULL, + allow_custom_keys boolean NOT NULL DEFAULT false, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (key) +); + +COMMENT ON TABLE "bob-storage-public".app_buckets IS E'@storageBuckets\nStorage buckets table'; + +-- Files table (same structure as Alice) +CREATE TABLE IF NOT EXISTS "bob-storage-public".app_files ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + bucket_id uuid NOT NULL REFERENCES "bob-storage-public".app_buckets(id), + key text NOT NULL, + content_hash text NOT NULL, + mime_type text NOT NULL, + size bigint, + filename text, + owner_id uuid, + is_public boolean NOT NULL DEFAULT false, + previous_version_id uuid REFERENCES "bob-storage-public".app_files(id), + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (bucket_id, key) +); + +COMMENT ON TABLE "bob-storage-public".app_files IS E'@storageFiles\nStorage files table'; + +-- Grant table permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_buckets TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_files TO administrator, authenticated, anonymous; + +-- Enable RLS on Bob's files table +ALTER TABLE "bob-storage-public".app_files ENABLE ROW LEVEL SECURITY; + +-- RLS policy: anonymous can only see files in public buckets +CREATE POLICY anon_read_public_files ON "bob-storage-public".app_files + FOR SELECT TO anonymous + USING ( + bucket_id IN ( + SELECT id FROM "bob-storage-public".app_buckets WHERE is_public = true + ) + ); + +-- RLS policy: anonymous can insert into any bucket (for upload testing) +CREATE POLICY anon_insert_files ON "bob-storage-public".app_files + FOR INSERT TO anonymous + WITH CHECK (true); + +-- RLS policy: administrator bypasses RLS +CREATE POLICY admin_all_files ON "bob-storage-public".app_files + FOR ALL TO administrator + USING (true) + WITH CHECK (true); diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index 92c09deaa..3ba369a5c 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql @@ -85,7 +85,7 @@ VALUES ( ) ON CONFLICT (id) DO NOTHING; -- ===================================================== --- BUCKET SEED DATA +-- ALICE BUCKET SEED DATA -- ===================================================== INSERT INTO "simple-storage-public".app_buckets (id, key, type, is_public) @@ -94,4 +94,118 @@ VALUES ('d0000001-0000-0000-0000-000000000002', 'private', 'private', false) ON CONFLICT (id) DO NOTHING; +-- ===================================================== +-- ALICE DATABASE SETTINGS (all defaults — presigned uploads enabled) +-- ===================================================== + +INSERT INTO services_public.database_settings (id, database_id) +VALUES ( + 'e0000001-0000-0000-0000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9' +) ON CONFLICT (database_id) DO NOTHING; + +-- ===================================================== +-- BOB METASCHEMA DATA +-- ===================================================== + +INSERT INTO metaschema_public.database (id, owner_id, name, hash) +VALUES ( + 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', + NULL, + 'bob-storage', + '525b1f21-1271-6861-96ef-3b091d482335' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public) +VALUES + ('a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'public', 'bob-storage-public', NULL, true) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.table (id, database_id, schema_id, name, description) +VALUES + ('b1b1b1b1-0000-0000-0000-000000000001', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', 'app_buckets', NULL), + ('b1b1b1b1-0000-0000-0000-000000000002', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', 'app_files', NULL) +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- BOB SERVICES DATA +-- ===================================================== + +-- Bob's primary API (presigned uploads enabled via database_settings defaults) +INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) +VALUES + ('a3a3a3a3-b4b4-4c5c-d6d6-e7e7e7e7e7e7', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'bob-app', current_database(), false, 'authenticated', 'anonymous') +ON CONFLICT (id) DO NOTHING; + +-- Bob's restricted API (api_settings will disable presigned uploads) +INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) +VALUES + ('a4a4a4a4-b5b5-4c6c-d7d7-e8e8e8e8e8e8', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'bob-restricted', current_database(), false, 'authenticated', 'anonymous') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) +VALUES + ('a5a5a5a5-0000-0000-0000-000000000001', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', 'a3a3a3a3-b4b4-4c5c-d6d6-e7e7e7e7e7e7'), + ('a5a5a5a5-0000-0000-0000-000000000002', 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', 'a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', 'a4a4a4a4-b5b5-4c6c-d7d7-e8e8e8e8e8e8') +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- BOB STORAGE MODULE CONFIG +-- ===================================================== + +INSERT INTO metaschema_modules_public.storage_module ( + id, + database_id, + schema_id, + buckets_table_id, + files_table_id, + endpoint, + public_url_prefix, + provider, + allowed_origins +) +VALUES ( + 'c1c1c1c1-0000-0000-0000-000000000001', + 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', + 'a2a2a2a2-b3b3-4c4c-d5d5-e6e6e6e6e6e6', + 'b1b1b1b1-0000-0000-0000-000000000001', + 'b1b1b1b1-0000-0000-0000-000000000002', + NULL, + NULL, + 'minio', + ARRAY['*'] +) ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- BOB BUCKET SEED DATA +-- ===================================================== + +INSERT INTO "bob-storage-public".app_buckets (id, key, type, is_public) +VALUES + ('d2d2d2d2-0000-0000-0000-000000000001', 'public', 'public', true), + ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false) +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- BOB DATABASE SETTINGS (all defaults — presigned uploads enabled) +-- ===================================================== + +INSERT INTO services_public.database_settings (id, database_id) +VALUES ( + 'e0000001-0000-0000-0000-000000000002', + 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5' +) ON CONFLICT (database_id) DO NOTHING; + +-- ===================================================== +-- BOB API SETTINGS (restricted API disables presigned uploads) +-- ===================================================== + +INSERT INTO services_public.api_settings (id, database_id, api_id, enable_presigned_uploads) +VALUES ( + 'f0000001-0000-0000-0000-000000000001', + 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5', + 'a4a4a4a4-b5b5-4c6c-d7d7-e8e8e8e8e8e8', + false +) ON CONFLICT (api_id) DO NOTHING; + SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 6311fa871..2c016fd94 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -5,7 +5,12 @@ * uploadAppFile mutation → presigned PUT URL → PUT to S3 * * Uses real MinIO (available in CI as minio_cdn service) and lazy bucket - * provisioning. No RLS — that will be tested in constructive-db. + * provisioning. + * + * Includes Alice/Bob tenant isolation tests: + * - Feature flag gating via database_settings / api_settings cascade + * - Tenant isolation (Alice cannot see Bob's files, Bob cannot see Alice's) + * - RLS enforcement (anonymous can only see public-bucket files in Bob's schema) * * Run tests: * pnpm test -- --testPathPattern=upload.integration @@ -16,19 +21,25 @@ import path from 'path'; import { getConnections, seed } from '../src'; import type supertest from 'supertest'; -jest.setTimeout(60000); +jest.setTimeout(120000); const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); const sql = (seedDir: string, file: string) => path.join(seedRoot, seedDir, file); -const servicesDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +// Alice's tenant (existing) +const aliceDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +const aliceSchemas = ['simple-storage-public']; + +// Bob's tenant (new) +const bobDatabaseId = 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5'; +const bobSchemas = ['bob-storage-public']; + const metaSchemas = [ 'services_public', 'metaschema_public', 'metaschema_modules_public', ]; -const schemas = ['simple-storage-public']; const seedFiles = [ // Reuse the shared metaschema / services infrastructure @@ -53,6 +64,31 @@ const UPLOAD_APP_FILE = ` } `; +const ALL_APP_FILES = ` + query AllAppFiles { + allAppFiles { + nodes { + id + key + filename + mimeType + isPublic + bucketId + } + } + } +`; + +const INTROSPECT_UPLOAD_MUTATION = ` + query IntrospectUpload { + __type(name: "Mutation") { + fields { + name + } + } + } +`; + // --- Helpers --- function sha256(content: string): string { @@ -80,21 +116,44 @@ describe('Upload integration (file-centric upload mutations)', () => { let request: supertest.Agent; let teardown: () => Promise; + /** + * Posts a GraphQL query using X-Schemata header (admin structure, no + * database_settings resolution — used by original upload tests). + */ const postGraphQL = (payload: { query: string; variables?: Record; }) => { return request .post('/graphql') - .set('X-Database-Id', servicesDatabaseId) - .set('X-Schemata', schemas.join(',')) + .set('X-Database-Id', aliceDatabaseId) + .set('X-Schemata', aliceSchemas.join(',')) + .send(payload); + }; + + /** + * Posts a GraphQL query using X-Api-Name header, which triggers + * api-name resolution and loads database_settings + api_settings. + */ + const postGraphQLViaApi = ( + databaseId: string, + apiName: string, + payload: { + query: string; + variables?: Record; + }, + ) => { + return request + .post('/graphql') + .set('X-Database-Id', databaseId) + .set('X-Api-Name', apiName) .send(payload); }; beforeAll(async () => { ({ request, teardown } = await getConnections( { - schemas, + schemas: aliceSchemas, authRole: 'anonymous', server: { api: { @@ -112,6 +171,10 @@ describe('Upload integration (file-centric upload mutations)', () => { if (teardown) await teardown(); }); + // ========================================================================== + // Original upload tests (Alice's tenant, schemata-header mode) + // ========================================================================== + describe('Public file upload via uploadAppFile mutation', () => { const fileContent = 'Hello, public world!'; const contentType = 'text/plain'; @@ -218,5 +281,187 @@ describe('Upload integration (file-centric upload mutations)', () => { expect(payload.fileId).toBeTruthy(); }); }); + + // ========================================================================== + // Alice/Bob tenant isolation and feature flag tests + // Uses X-Api-Name resolution which loads database_settings + api_settings + // ========================================================================== + + describe('Feature flag gating via database_settings / api_settings', () => { + it('should expose uploadAppFile mutation when presigned uploads are enabled (Alice)', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const mutationFields: { name: string }[] = + res.body.data.__type?.fields ?? []; + const fieldNames = mutationFields.map((f) => f.name); + expect(fieldNames).toContain('uploadAppFile'); + }); + + it('should expose uploadAppFile mutation on Bob primary API (presigned uploads enabled by default)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const mutationFields: { name: string }[] = + res.body.data.__type?.fields ?? []; + const fieldNames = mutationFields.map((f) => f.name); + expect(fieldNames).toContain('uploadAppFile'); + }); + + it('should NOT expose uploadAppFile on Bob restricted API (api_settings disables presigned uploads)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-restricted', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const mutationFields: { name: string }[] = + res.body.data.__type?.fields ?? []; + const fieldNames = mutationFields.map((f) => f.name); + expect(fieldNames).not.toContain('uploadAppFile'); + }); + }); + + describe('Tenant isolation — Alice and Bob cannot see each other\'s files', () => { + it('should allow Bob to upload a file via his primary API', async () => { + const fileContent = 'Bob secret data'; + const contentHash = sha256(fileContent); + + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: UPLOAD_APP_FILE, + variables: { + input: { + bucketKey: 'public', + contentHash, + contentType: 'text/plain', + size: Buffer.byteLength(fileContent), + filename: 'bob-file.txt', + }, + }, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const payload = res.body.data.uploadAppFile; + expect(payload.fileId).toBeTruthy(); + expect(payload.uploadUrl).toBeTruthy(); + + const putRes = await putToPresignedUrl( + payload.uploadUrl, + fileContent, + 'text/plain', + ); + expect(putRes.ok).toBe(true); + }); + + it('should show Bob his own files via his API', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: ALL_APP_FILES, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const files = res.body.data.allAppFiles.nodes; + expect(files.length).toBeGreaterThanOrEqual(1); + expect(files.some((f: { filename: string }) => f.filename === 'bob-file.txt')).toBe(true); + }); + + it('should NOT show Bob\'s files when querying via Alice\'s API', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { + query: ALL_APP_FILES, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const files = res.body.data.allAppFiles.nodes; + const bobFiles = files.filter( + (f: { filename: string }) => f.filename === 'bob-file.txt', + ); + expect(bobFiles).toHaveLength(0); + }); + + it('should NOT show Alice\'s files when querying via Bob\'s API', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: ALL_APP_FILES, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const files = res.body.data.allAppFiles.nodes; + const aliceFiles = files.filter( + (f: { filename: string }) => + f.filename === 'hello-public.txt' || f.filename === 'hello-private.txt', + ); + expect(aliceFiles).toHaveLength(0); + }); + }); + + describe('RLS enforcement on Bob\'s schema', () => { + it('should allow Bob to upload a file to the private bucket', async () => { + const fileContent = 'Bob private data'; + const contentHash = sha256(fileContent); + + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: UPLOAD_APP_FILE, + variables: { + input: { + bucketKey: 'private', + contentHash, + contentType: 'text/plain', + size: Buffer.byteLength(fileContent), + filename: 'bob-private-file.txt', + }, + }, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const payload = res.body.data.uploadAppFile; + expect(payload.fileId).toBeTruthy(); + expect(payload.uploadUrl).toBeTruthy(); + + const putRes = await putToPresignedUrl( + payload.uploadUrl, + fileContent, + 'text/plain', + ); + expect(putRes.ok).toBe(true); + }); + + it('should only return public-bucket files for anonymous role (RLS)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: ALL_APP_FILES, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const files: { filename: string; bucketId: string }[] = + res.body.data.allAppFiles.nodes; + + const publicBucketId = 'd2d2d2d2-0000-0000-0000-000000000001'; + const privateBucketId = 'd2d2d2d2-0000-0000-0000-000000000002'; + + const publicFiles = files.filter((f) => f.bucketId === publicBucketId); + const privateFiles = files.filter((f) => f.bucketId === privateBucketId); + + expect(publicFiles.length).toBeGreaterThanOrEqual(1); + expect(privateFiles).toHaveLength(0); + }); + }); }); From f93dc1de75ed95b02f8b782f1f82e9ca5ba38b67 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 02:56:19 +0000 Subject: [PATCH 2/8] fix: correct Alice API is_public, rename allAppFiles to appFiles, pre-seed private file for RLS test --- .../seed/simple-seed-storage/test-data.sql | 15 ++++- .../__tests__/upload.integration.test.ts | 56 ++++--------------- 2 files changed, 26 insertions(+), 45 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index 3ba369a5c..f59ee9862 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql @@ -49,7 +49,7 @@ VALUES ( INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) VALUES - ('6c9997a4-591b-4cb3-9313-4ef45d6f134e', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'app', current_database(), true, 'authenticated', 'anonymous') + ('6c9997a4-591b-4cb3-9313-4ef45d6f134e', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'app', current_database(), false, 'authenticated', 'anonymous') ON CONFLICT (id) DO NOTHING; INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) @@ -186,6 +186,19 @@ VALUES ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false) ON CONFLICT (id) DO NOTHING; +-- Pre-seed a file in Bob's private bucket for RLS testing +INSERT INTO "bob-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) +VALUES ( + 'd3d3d3d3-0000-0000-0000-000000000001', + 'd2d2d2d2-0000-0000-0000-000000000002', + 'seeded-private-hash', + 'seeded-private-hash', + 'text/plain', + 42, + 'bob-seeded-private.txt', + false +) ON CONFLICT (id) DO NOTHING; + -- ===================================================== -- BOB DATABASE SETTINGS (all defaults — presigned uploads enabled) -- ===================================================== diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 2c016fd94..07b03650b 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -64,9 +64,9 @@ const UPLOAD_APP_FILE = ` } `; -const ALL_APP_FILES = ` - query AllAppFiles { - allAppFiles { +const APP_FILES = ` + query AppFiles { + appFiles { nodes { id key @@ -153,7 +153,7 @@ describe('Upload integration (file-centric upload mutations)', () => { beforeAll(async () => { ({ request, teardown } = await getConnections( { - schemas: aliceSchemas, + schemas: [...aliceSchemas, ...bobSchemas], authRole: 'anonymous', server: { api: { @@ -366,26 +366,26 @@ describe('Upload integration (file-centric upload mutations)', () => { it('should show Bob his own files via his API', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: ALL_APP_FILES, + query: APP_FILES, }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - const files = res.body.data.allAppFiles.nodes; + const files = res.body.data.appFiles.nodes; expect(files.length).toBeGreaterThanOrEqual(1); expect(files.some((f: { filename: string }) => f.filename === 'bob-file.txt')).toBe(true); }); it('should NOT show Bob\'s files when querying via Alice\'s API', async () => { const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { - query: ALL_APP_FILES, + query: APP_FILES, }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - const files = res.body.data.allAppFiles.nodes; + const files = res.body.data.appFiles.nodes; const bobFiles = files.filter( (f: { filename: string }) => f.filename === 'bob-file.txt', ); @@ -394,13 +394,13 @@ describe('Upload integration (file-centric upload mutations)', () => { it('should NOT show Alice\'s files when querying via Bob\'s API', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: ALL_APP_FILES, + query: APP_FILES, }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - const files = res.body.data.allAppFiles.nodes; + const files = res.body.data.appFiles.nodes; const aliceFiles = files.filter( (f: { filename: string }) => f.filename === 'hello-public.txt' || f.filename === 'hello-private.txt', @@ -410,48 +410,16 @@ describe('Upload integration (file-centric upload mutations)', () => { }); describe('RLS enforcement on Bob\'s schema', () => { - it('should allow Bob to upload a file to the private bucket', async () => { - const fileContent = 'Bob private data'; - const contentHash = sha256(fileContent); - - const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: UPLOAD_APP_FILE, - variables: { - input: { - bucketKey: 'private', - contentHash, - contentType: 'text/plain', - size: Buffer.byteLength(fileContent), - filename: 'bob-private-file.txt', - }, - }, - }); - - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - - const payload = res.body.data.uploadAppFile; - expect(payload.fileId).toBeTruthy(); - expect(payload.uploadUrl).toBeTruthy(); - - const putRes = await putToPresignedUrl( - payload.uploadUrl, - fileContent, - 'text/plain', - ); - expect(putRes.ok).toBe(true); - }); - it('should only return public-bucket files for anonymous role (RLS)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: ALL_APP_FILES, + query: APP_FILES, }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); const files: { filename: string; bucketId: string }[] = - res.body.data.allAppFiles.nodes; + res.body.data.appFiles.nodes; const publicBucketId = 'd2d2d2d2-0000-0000-0000-000000000001'; const privateBucketId = 'd2d2d2d2-0000-0000-0000-000000000002'; From e10c1cc87e490804307afd458c5ec6e0d801bc3c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 06:58:22 +0000 Subject: [PATCH 3/8] feat: expand integration tests with Mallory (3rd actor) and comprehensive RLS/attack scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Mallory as adversarial third tenant with strictest RLS (SELECT-only for anonymous) - Add RLS on Bob's app_buckets table (anonymous can only see public buckets) - Add bucket enumeration attack tests - Add Supabase-style file mutation attack tests (bucket_id tampering, is_public flipping, deletion) - Add bucket mutation attack tests (create, update, delete) - Add cross-tenant header manipulation attack tests (mismatched database_id/API name) - Add X-Schemata cross-tenant leakage tests - Add feature flag gating test for Mallory - Pre-seed files in both Bob and Mallory schemas for mutation/RLS testing - Single beforeAll setup — one server, one pool, all three schemas (stays fast) --- .../seed/simple-seed-storage/schema.sql | 100 +++ .../seed/simple-seed-storage/test-data.sql | 122 +++ .../__tests__/upload.integration.test.ts | 717 +++++++++++++----- 3 files changed, 763 insertions(+), 176 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql index e3afd7202..fa84f6478 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql @@ -112,6 +112,20 @@ COMMENT ON TABLE "bob-storage-public".app_files IS E'@storageFiles\nStorage file GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_buckets TO administrator, authenticated, anonymous; GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_files TO administrator, authenticated, anonymous; +-- Enable RLS on Bob's buckets table +ALTER TABLE "bob-storage-public".app_buckets ENABLE ROW LEVEL SECURITY; + +-- RLS: anonymous can only see public buckets (prevents bucket enumeration) +CREATE POLICY anon_read_public_buckets ON "bob-storage-public".app_buckets + FOR SELECT TO anonymous + USING (is_public = true); + +-- RLS: administrator bypasses bucket RLS +CREATE POLICY admin_all_buckets ON "bob-storage-public".app_buckets + FOR ALL TO administrator + USING (true) + WITH CHECK (true); + -- Enable RLS on Bob's files table ALTER TABLE "bob-storage-public".app_files ENABLE ROW LEVEL SECURITY; @@ -129,8 +143,94 @@ CREATE POLICY anon_insert_files ON "bob-storage-public".app_files FOR INSERT TO anonymous WITH CHECK (true); +-- No UPDATE or DELETE policies for anonymous — absence means denied. +-- This prevents Supabase-style attacks where anonymous could: +-- - change a file's bucket_id from private to public +-- - flip is_public flags +-- - delete other users' files + -- RLS policy: administrator bypasses RLS CREATE POLICY admin_all_files ON "bob-storage-public".app_files FOR ALL TO administrator USING (true) WITH CHECK (true); + +-- ===================================================== +-- MALLORY'S STORAGE SCHEMA (adversarial third tenant — strictest RLS) +-- anonymous can only SELECT, no INSERT/UPDATE/DELETE at all +-- ===================================================== + +CREATE SCHEMA IF NOT EXISTS "mallory-storage-public"; + +GRANT USAGE ON SCHEMA "mallory-storage-public" TO administrator, authenticated, anonymous; + +ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" + GRANT ALL ON TABLES TO administrator; +ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" + GRANT USAGE ON SEQUENCES TO administrator, authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" + GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; + +-- Buckets table +CREATE TABLE IF NOT EXISTS "mallory-storage-public".app_buckets ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + key text NOT NULL, + type text NOT NULL DEFAULT 'private', + is_public boolean NOT NULL DEFAULT false, + allowed_mime_types text[] NULL, + max_file_size bigint NULL, + allow_custom_keys boolean NOT NULL DEFAULT false, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (key) +); + +COMMENT ON TABLE "mallory-storage-public".app_buckets IS E'@storageBuckets\nStorage buckets table'; + +-- Files table +CREATE TABLE IF NOT EXISTS "mallory-storage-public".app_files ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + bucket_id uuid NOT NULL REFERENCES "mallory-storage-public".app_buckets(id), + key text NOT NULL, + content_hash text NOT NULL, + mime_type text NOT NULL, + size bigint, + filename text, + owner_id uuid, + is_public boolean NOT NULL DEFAULT false, + previous_version_id uuid REFERENCES "mallory-storage-public".app_files(id), + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (bucket_id, key) +); + +COMMENT ON TABLE "mallory-storage-public".app_files IS E'@storageFiles\nStorage files table'; + +-- Grant table permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON "mallory-storage-public".app_buckets TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON "mallory-storage-public".app_files TO administrator, authenticated, anonymous; + +-- Enable RLS on Mallory's buckets — anonymous can only read +ALTER TABLE "mallory-storage-public".app_buckets ENABLE ROW LEVEL SECURITY; + +CREATE POLICY anon_read_buckets ON "mallory-storage-public".app_buckets + FOR SELECT TO anonymous + USING (true); + +CREATE POLICY admin_all_buckets ON "mallory-storage-public".app_buckets + FOR ALL TO administrator + USING (true) + WITH CHECK (true); + +-- Enable RLS on Mallory's files — anonymous can only read (strictest policy) +-- No INSERT/UPDATE/DELETE for anonymous at all +ALTER TABLE "mallory-storage-public".app_files ENABLE ROW LEVEL SECURITY; + +CREATE POLICY anon_read_files ON "mallory-storage-public".app_files + FOR SELECT TO anonymous + USING (true); + +CREATE POLICY admin_all_files ON "mallory-storage-public".app_files + FOR ALL TO administrator + USING (true) + WITH CHECK (true); diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index f59ee9862..79c32a4ee 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql @@ -186,6 +186,19 @@ VALUES ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false) ON CONFLICT (id) DO NOTHING; +-- Pre-seed a file in Bob's public bucket for mutation attack testing +INSERT INTO "bob-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) +VALUES ( + 'd3d3d3d3-0000-0000-0000-000000000002', + 'd2d2d2d2-0000-0000-0000-000000000001', + 'seeded-public-hash', + 'seeded-public-hash', + 'text/plain', + 42, + 'bob-seeded-public.txt', + true +) ON CONFLICT (id) DO NOTHING; + -- Pre-seed a file in Bob's private bucket for RLS testing INSERT INTO "bob-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) VALUES ( @@ -221,4 +234,113 @@ VALUES ( false ) ON CONFLICT (api_id) DO NOTHING; +-- ===================================================== +-- MALLORY METASCHEMA DATA (adversarial third tenant) +-- ===================================================== + +INSERT INTO metaschema_public.database (id, owner_id, name, hash) +VALUES ( + 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', + NULL, + 'mallory-storage', + '636c2f32-2382-7972-a7f0-4c1a2e593446' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public) +VALUES + ('m2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'public', 'mallory-storage-public', NULL, true) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.table (id, database_id, schema_id, name, description) +VALUES + ('m3m3m3m3-0000-0000-0000-000000000001', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_buckets', NULL), + ('m3m3m3m3-0000-0000-0000-000000000002', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_files', NULL) +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- MALLORY SERVICES DATA +-- ===================================================== + +INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) +VALUES + ('m4m4m4m4-a5a5-4b6b-c7c7-d8d8d8d8d8d8', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'mallory-app', current_database(), false, 'authenticated', 'anonymous') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) +VALUES + ('m5m5m5m5-0000-0000-0000-000000000001', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'm4m4m4m4-a5a5-4b6b-c7c7-d8d8d8d8d8d8') +ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- MALLORY STORAGE MODULE CONFIG +-- ===================================================== + +INSERT INTO metaschema_modules_public.storage_module ( + id, + database_id, + schema_id, + buckets_table_id, + files_table_id, + endpoint, + public_url_prefix, + provider, + allowed_origins +) +VALUES ( + 'm6m6m6m6-0000-0000-0000-000000000001', + 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', + 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', + 'm3m3m3m3-0000-0000-0000-000000000001', + 'm3m3m3m3-0000-0000-0000-000000000002', + NULL, + NULL, + 'minio', + ARRAY['*'] +) ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- MALLORY BUCKET SEED DATA +-- ===================================================== + +INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public) +VALUES + ('m7m7m7m7-0000-0000-0000-000000000001', 'public', 'public', true), + ('m7m7m7m7-0000-0000-0000-000000000002', 'private', 'private', false) +ON CONFLICT (id) DO NOTHING; + +-- Pre-seed files in Mallory's buckets for RLS testing +INSERT INTO "mallory-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) +VALUES ( + 'm9m9m9m9-0000-0000-0000-000000000001', + 'm7m7m7m7-0000-0000-0000-000000000001', + 'mallory-public-hash', + 'mallory-public-hash', + 'text/plain', + 42, + 'mallory-public.txt', + true +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO "mallory-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) +VALUES ( + 'm9m9m9m9-0000-0000-0000-000000000002', + 'm7m7m7m7-0000-0000-0000-000000000002', + 'mallory-private-hash', + 'mallory-private-hash', + 'text/plain', + 42, + 'mallory-private.txt', + false +) ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- MALLORY DATABASE SETTINGS (all defaults — presigned uploads enabled) +-- ===================================================== + +INSERT INTO services_public.database_settings (id, database_id) +VALUES ( + 'm8m8m8m8-0000-0000-0000-000000000001', + 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5' +) ON CONFLICT (database_id) DO NOTHING; + SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 07b03650b..72f177f56 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -1,16 +1,28 @@ /** - * Upload Integration Tests — end-to-end presigned URL flow + * Integration Tests -- presigned URL uploads, tenant isolation, and RLS enforcement * * Exercises the file-centric upload pipeline: - * uploadAppFile mutation → presigned PUT URL → PUT to S3 + * uploadAppFile mutation -> presigned PUT URL -> PUT to S3 * * Uses real MinIO (available in CI as minio_cdn service) and lazy bucket * provisioning. * - * Includes Alice/Bob tenant isolation tests: - * - Feature flag gating via database_settings / api_settings cascade - * - Tenant isolation (Alice cannot see Bob's files, Bob cannot see Alice's) - * - RLS enforcement (anonymous can only see public-bucket files in Bob's schema) + * Three actors (single beforeAll, single server -- stays fast): + * Alice -- baseline tenant, no RLS (wide-open schema) + * Bob -- moderate security: RLS on files (public-bucket SELECT only) and + * buckets (public-only visible). No anonymous UPDATE/DELETE. + * Mallory -- strictest: RLS on files and buckets, anonymous can only SELECT. + * No INSERT/UPDATE/DELETE for anonymous at all. + * + * Test categories: + * 1. Presigned URL uploads (Alice) + * 2. Feature flag gating via database_settings / api_settings cascade + * 3. Three-way tenant isolation (Alice, Bob, Mallory) + * 4. Bucket enumeration attacks (RLS on app_buckets) + * 5. File mutation attacks -- Supabase-style metadata tampering + * 6. Bucket mutation attacks + * 7. Cross-tenant header manipulation attacks + * 8. RLS enforcement on Bob's schema (public vs private bucket files) * * Run tests: * pnpm test -- --testPathPattern=upload.integration @@ -27,13 +39,26 @@ const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); const sql = (seedDir: string, file: string) => path.join(seedRoot, seedDir, file); -// Alice's tenant (existing) +// ========================================================================= +// Tenant constants +// ========================================================================= + +// Alice -- baseline tenant (no RLS) const aliceDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; const aliceSchemas = ['simple-storage-public']; -// Bob's tenant (new) +// Bob -- moderate RLS const bobDatabaseId = 'a1a1a1a1-b2b2-4c3c-d4d4-e5e5e5e5e5e5'; const bobSchemas = ['bob-storage-public']; +const bobPublicBucketId = 'd2d2d2d2-0000-0000-0000-000000000001'; +const bobPrivateBucketId = 'd2d2d2d2-0000-0000-0000-000000000002'; +const bobSeededPublicFileId = 'd3d3d3d3-0000-0000-0000-000000000002'; + +// Mallory -- strictest RLS (anonymous: SELECT only, no mutations) +const malloryDatabaseId = 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5'; +const mallorySchemas = ['mallory-storage-public']; +const malloryPublicFileId = 'm9m9m9m9-0000-0000-0000-000000000001'; +const malloryPublicBucketId = 'm7m7m7m7-0000-0000-0000-000000000001'; const metaSchemas = [ 'services_public', @@ -42,15 +67,15 @@ const metaSchemas = [ ]; const seedFiles = [ - // Reuse the shared metaschema / services infrastructure sql('simple-seed-services', 'setup.sql'), - // Storage-specific additions (jwt_private + storage_module table) sql('simple-seed-storage', 'setup.sql'), sql('simple-seed-storage', 'schema.sql'), sql('simple-seed-storage', 'test-data.sql'), ]; -// --- GraphQL operations --- +// ========================================================================= +// GraphQL operations +// ========================================================================= const UPLOAD_APP_FILE = ` mutation UploadAppFile($input: UploadAppFileInput!) { @@ -79,6 +104,19 @@ const APP_FILES = ` } `; +const APP_BUCKETS = ` + query AppBuckets { + appBuckets { + nodes { + id + key + type + isPublic + } + } + } +`; + const INTROSPECT_UPLOAD_MUTATION = ` query IntrospectUpload { __type(name: "Mutation") { @@ -89,7 +127,77 @@ const INTROSPECT_UPLOAD_MUTATION = ` } `; -// --- Helpers --- +const UPDATE_APP_FILE = ` + mutation UpdateAppFile($input: UpdateAppFileInput!) { + updateAppFile(input: $input) { + appFile { + id + bucketId + isPublic + filename + } + } + } +`; + +const DELETE_APP_FILE = ` + mutation DeleteAppFile($input: DeleteAppFileInput!) { + deleteAppFile(input: $input) { + appFile { + id + } + } + } +`; + +const CREATE_APP_FILE = ` + mutation CreateAppFile($input: CreateAppFileInput!) { + createAppFile(input: $input) { + appFile { + id + bucketId + filename + } + } + } +`; + +const CREATE_APP_BUCKET = ` + mutation CreateAppBucket($input: CreateAppBucketInput!) { + createAppBucket(input: $input) { + appBucket { + id + key + } + } + } +`; + +const UPDATE_APP_BUCKET = ` + mutation UpdateAppBucket($input: UpdateAppBucketInput!) { + updateAppBucket(input: $input) { + appBucket { + id + key + isPublic + } + } + } +`; + +const DELETE_APP_BUCKET = ` + mutation DeleteAppBucket($input: DeleteAppBucketInput!) { + deleteAppBucket(input: $input) { + appBucket { + id + } + } + } +`; + +// ========================================================================= +// Helpers +// ========================================================================= function sha256(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); @@ -110,16 +218,26 @@ async function putToPresignedUrl( }); } -// --- Tests --- +/** Expect a GraphQL response to indicate a denied mutation (error or null). */ +function expectMutationDenied( + res: supertest.Response, + mutationName: string, +): void { + if (res.body.errors) { + expect(res.body.errors.length).toBeGreaterThan(0); + } else { + expect(res.body.data[mutationName]).toBeNull(); + } +} -describe('Upload integration (file-centric upload mutations)', () => { +// ========================================================================= +// Tests -- single beforeAll, single server instance, all scenarios +// ========================================================================= + +describe('Integration tests (uploads, tenant isolation, RLS)', () => { let request: supertest.Agent; let teardown: () => Promise; - /** - * Posts a GraphQL query using X-Schemata header (admin structure, no - * database_settings resolution — used by original upload tests). - */ const postGraphQL = (payload: { query: string; variables?: Record; @@ -131,10 +249,6 @@ describe('Upload integration (file-centric upload mutations)', () => { .send(payload); }; - /** - * Posts a GraphQL query using X-Api-Name header, which triggers - * api-name resolution and loads database_settings + api_settings. - */ const postGraphQLViaApi = ( databaseId: string, apiName: string, @@ -150,10 +264,26 @@ describe('Upload integration (file-centric upload mutations)', () => { .send(payload); }; + const postGraphQLViaSchemata = ( + databaseId: string, + schemas: string[], + payload: { + query: string; + variables?: Record; + }, + ) => { + return request + .post('/graphql') + .set('X-Database-Id', databaseId) + .set('X-Schemata', schemas.join(',')) + .send(payload); + }; + + // Single setup: one server, one pool, all three schemas registered beforeAll(async () => { ({ request, teardown } = await getConnections( { - schemas: [...aliceSchemas, ...bobSchemas], + schemas: [...aliceSchemas, ...bobSchemas, ...mallorySchemas], authRole: 'anonymous', server: { api: { @@ -172,167 +302,178 @@ describe('Upload integration (file-centric upload mutations)', () => { }); // ========================================================================== - // Original upload tests (Alice's tenant, schemata-header mode) + // 1. Presigned URL uploads (Alice, schemata-header mode) // ========================================================================== - describe('Public file upload via uploadAppFile mutation', () => { - const fileContent = 'Hello, public world!'; - const contentType = 'text/plain'; - const contentHash = sha256(fileContent); - let uploadUrl: string; - - it('should return a presigned PUT URL via uploadAppFile', async () => { - const res = await postGraphQL({ - query: UPLOAD_APP_FILE, - variables: { - input: { - bucketKey: 'public', - contentHash, - contentType, - size: Buffer.byteLength(fileContent), - filename: 'hello-public.txt', + describe('Presigned URL uploads (Alice)', () => { + describe('Public file upload', () => { + const fileContent = 'Hello, public world!'; + const contentType = 'text/plain'; + const contentHash = sha256(fileContent); + let uploadUrl: string; + + it('should return a presigned PUT URL via uploadAppFile', async () => { + const res = await postGraphQL({ + query: UPLOAD_APP_FILE, + variables: { + input: { + bucketKey: 'public', + contentHash, + contentType, + size: Buffer.byteLength(fileContent), + filename: 'hello-public.txt', + }, }, - }, - }); + }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); - const payload = res.body.data.uploadAppFile; - expect(payload.uploadUrl).toBeTruthy(); - expect(payload.fileId).toBeTruthy(); - expect(payload.key).toBe(contentHash); - expect(payload.deduplicated).toBe(false); - expect(payload.expiresAt).toBeTruthy(); + const payload = res.body.data.uploadAppFile; + expect(payload.uploadUrl).toBeTruthy(); + expect(payload.fileId).toBeTruthy(); + expect(payload.key).toBe(contentHash); + expect(payload.deduplicated).toBe(false); + expect(payload.expiresAt).toBeTruthy(); - uploadUrl = payload.uploadUrl; - }); + uploadUrl = payload.uploadUrl; + }); - it('should accept a PUT to the presigned URL', async () => { - const putRes = await putToPresignedUrl(uploadUrl, fileContent, contentType); - expect(putRes.ok).toBe(true); + it('should accept a PUT to the presigned URL', async () => { + const putRes = await putToPresignedUrl(uploadUrl, fileContent, contentType); + expect(putRes.ok).toBe(true); + }); }); - }); - describe('Private file upload via uploadAppFile mutation', () => { - const fileContent = 'Hello, private world!'; - const contentType = 'text/plain'; - const contentHash = sha256(fileContent); - let uploadUrl: string; - - it('should return a presigned PUT URL via uploadAppFile', async () => { - const res = await postGraphQL({ - query: UPLOAD_APP_FILE, - variables: { - input: { - bucketKey: 'private', - contentHash, - contentType, - size: Buffer.byteLength(fileContent), - filename: 'hello-private.txt', + describe('Private file upload', () => { + const fileContent = 'Hello, private world!'; + const contentType = 'text/plain'; + const contentHash = sha256(fileContent); + let uploadUrl: string; + + it('should return a presigned PUT URL via uploadAppFile', async () => { + const res = await postGraphQL({ + query: UPLOAD_APP_FILE, + variables: { + input: { + bucketKey: 'private', + contentHash, + contentType, + size: Buffer.byteLength(fileContent), + filename: 'hello-private.txt', + }, }, - }, - }); + }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); - const payload = res.body.data.uploadAppFile; - expect(payload.uploadUrl).toBeTruthy(); - expect(payload.fileId).toBeTruthy(); - expect(payload.key).toBe(contentHash); - expect(payload.deduplicated).toBe(false); - expect(payload.expiresAt).toBeTruthy(); + const payload = res.body.data.uploadAppFile; + expect(payload.uploadUrl).toBeTruthy(); + expect(payload.fileId).toBeTruthy(); + expect(payload.key).toBe(contentHash); + expect(payload.deduplicated).toBe(false); + expect(payload.expiresAt).toBeTruthy(); - uploadUrl = payload.uploadUrl; - }); + uploadUrl = payload.uploadUrl; + }); - it('should accept a PUT to the presigned URL', async () => { - const putRes = await putToPresignedUrl(uploadUrl, fileContent, contentType); - expect(putRes.ok).toBe(true); + it('should accept a PUT to the presigned URL', async () => { + const putRes = await putToPresignedUrl(uploadUrl, fileContent, contentType); + expect(putRes.ok).toBe(true); + }); }); - }); - describe('Deduplication', () => { - it('should return deduplicated=true for a file with an existing content hash', async () => { - const fileContent = 'Hello, public world!'; - const contentHash = sha256(fileContent); - - const res = await postGraphQL({ - query: UPLOAD_APP_FILE, - variables: { - input: { - bucketKey: 'public', - contentHash, - contentType: 'text/plain', - size: Buffer.byteLength(fileContent), - filename: 'hello-public-copy.txt', + describe('Deduplication', () => { + it('should return deduplicated=true for an existing content hash', async () => { + const fileContent = 'Hello, public world!'; + const contentHash = sha256(fileContent); + + const res = await postGraphQL({ + query: UPLOAD_APP_FILE, + variables: { + input: { + bucketKey: 'public', + contentHash, + contentType: 'text/plain', + size: Buffer.byteLength(fileContent), + filename: 'hello-public-copy.txt', + }, }, - }, - }); + }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); - const payload = res.body.data.uploadAppFile; - expect(payload.deduplicated).toBe(true); - expect(payload.uploadUrl).toBeNull(); - expect(payload.expiresAt).toBeNull(); - expect(payload.fileId).toBeTruthy(); + const payload = res.body.data.uploadAppFile; + expect(payload.deduplicated).toBe(true); + expect(payload.uploadUrl).toBeNull(); + expect(payload.expiresAt).toBeNull(); + expect(payload.fileId).toBeTruthy(); + }); }); }); // ========================================================================== - // Alice/Bob tenant isolation and feature flag tests - // Uses X-Api-Name resolution which loads database_settings + api_settings + // 2. Feature flag gating via database_settings / api_settings // ========================================================================== describe('Feature flag gating via database_settings / api_settings', () => { - it('should expose uploadAppFile mutation when presigned uploads are enabled (Alice)', async () => { + it('Alice: uploadAppFile exposed (presigned uploads enabled)', async () => { const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - - const mutationFields: { name: string }[] = - res.body.data.__type?.fields ?? []; - const fieldNames = mutationFields.map((f) => f.name); - expect(fieldNames).toContain('uploadAppFile'); + const names = (res.body.data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); }); - it('should expose uploadAppFile mutation on Bob primary API (presigned uploads enabled by default)', async () => { + it('Bob primary API: uploadAppFile exposed (enabled by default)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - - const mutationFields: { name: string }[] = - res.body.data.__type?.fields ?? []; - const fieldNames = mutationFields.map((f) => f.name); - expect(fieldNames).toContain('uploadAppFile'); + const names = (res.body.data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); }); - it('should NOT expose uploadAppFile on Bob restricted API (api_settings disables presigned uploads)', async () => { + it('Bob restricted API: uploadAppFile NOT exposed (api_settings override)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-restricted', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); + const names = (res.body.data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).not.toContain('uploadAppFile'); + }); - const mutationFields: { name: string }[] = - res.body.data.__type?.fields ?? []; - const fieldNames = mutationFields.map((f) => f.name); - expect(fieldNames).not.toContain('uploadAppFile'); + it('Mallory: uploadAppFile exposed (enabled by default)', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const names = (res.body.data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); }); }); - describe('Tenant isolation — Alice and Bob cannot see each other\'s files', () => { - it('should allow Bob to upload a file via his primary API', async () => { + // ========================================================================== + // 3. Three-way tenant isolation + // ========================================================================== + + describe('Three-way tenant isolation (Alice, Bob, Mallory)', () => { + it('Bob uploads a file via his primary API', async () => { const fileContent = 'Bob secret data'; const contentHash = sha256(fileContent); @@ -351,85 +492,309 @@ describe('Upload integration (file-centric upload mutations)', () => { expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - const payload = res.body.data.uploadAppFile; expect(payload.fileId).toBeTruthy(); expect(payload.uploadUrl).toBeTruthy(); - const putRes = await putToPresignedUrl( - payload.uploadUrl, - fileContent, - 'text/plain', - ); + const putRes = await putToPresignedUrl(payload.uploadUrl, fileContent, 'text/plain'); expect(putRes.ok).toBe(true); }); - it('should show Bob his own files via his API', async () => { - const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: APP_FILES, - }); - + it('Bob sees his own files', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); - const files = res.body.data.appFiles.nodes; expect(files.length).toBeGreaterThanOrEqual(1); expect(files.some((f: { filename: string }) => f.filename === 'bob-file.txt')).toBe(true); }); - it('should NOT show Bob\'s files when querying via Alice\'s API', async () => { - const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { - query: APP_FILES, - }); + it('Mallory sees her own pre-seeded files', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_FILES }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const files: { filename: string }[] = res.body.data.appFiles.nodes; + expect(files.some((f) => f.filename === 'mallory-public.txt')).toBe(true); + expect(files.some((f) => f.filename === 'mallory-private.txt')).toBe(true); + }); + it('Alice API does NOT leak Bob or Mallory files', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: APP_FILES }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); + const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).not.toContain('bob-file.txt'); + expect(names).not.toContain('mallory-public.txt'); + expect(names).not.toContain('mallory-private.txt'); + }); - const files = res.body.data.appFiles.nodes; - const bobFiles = files.filter( - (f: { filename: string }) => f.filename === 'bob-file.txt', - ); - expect(bobFiles).toHaveLength(0); + it('Bob API does NOT leak Alice or Mallory files', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).not.toContain('hello-public.txt'); + expect(names).not.toContain('hello-private.txt'); + expect(names).not.toContain('mallory-public.txt'); + expect(names).not.toContain('mallory-private.txt'); }); - it('should NOT show Alice\'s files when querying via Bob\'s API', async () => { - const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: APP_FILES, - }); + it('Mallory API does NOT leak Alice or Bob files', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_FILES }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).not.toContain('hello-public.txt'); + expect(names).not.toContain('hello-private.txt'); + expect(names).not.toContain('bob-file.txt'); + expect(names).not.toContain('bob-seeded-public.txt'); + }); + }); + // ========================================================================== + // 4. Bucket enumeration attacks (RLS on app_buckets) + // ========================================================================== + + describe('Bucket enumeration attacks', () => { + it('Alice sees all buckets (no RLS on her schema)', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: APP_BUCKETS }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); + const keys = res.body.data.appBuckets.nodes.map((b: { key: string }) => b.key); + expect(keys).toContain('public'); + expect(keys).toContain('private'); + }); - const files = res.body.data.appFiles.nodes; - const aliceFiles = files.filter( - (f: { filename: string }) => - f.filename === 'hello-public.txt' || f.filename === 'hello-private.txt', - ); - expect(aliceFiles).toHaveLength(0); + it('Bob anonymous only sees public buckets (RLS hides private)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_BUCKETS }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const buckets: { isPublic: boolean }[] = res.body.data.appBuckets.nodes; + expect(buckets.length).toBeGreaterThanOrEqual(1); + expect(buckets.every((b) => b.isPublic)).toBe(true); + }); + + it('Mallory anonymous sees all buckets (her RLS policy allows all SELECT)', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_BUCKETS }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + const keys = res.body.data.appBuckets.nodes.map((b: { key: string }) => b.key); + expect(keys).toContain('public'); + expect(keys).toContain('private'); }); }); - describe('RLS enforcement on Bob\'s schema', () => { - it('should only return public-bucket files for anonymous role (RLS)', async () => { + // ========================================================================== + // 5. File mutation attacks -- Supabase-style metadata tampering + // ========================================================================== + + describe('File mutation attacks (Supabase-style)', () => { + it('Bob: anonymous cannot update file bucket_id (move between buckets)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { - query: APP_FILES, + query: UPDATE_APP_FILE, + variables: { + input: { id: bobSeededPublicFileId, patch: { bucketId: bobPrivateBucketId } }, + }, + }); + expectMutationDenied(res, 'updateAppFile'); + }); + + it('Bob: anonymous cannot flip is_public flag on a file', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: UPDATE_APP_FILE, + variables: { + input: { id: bobSeededPublicFileId, patch: { isPublic: false } }, + }, + }); + expectMutationDenied(res, 'updateAppFile'); + }); + + it('Bob: anonymous cannot delete a file', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: DELETE_APP_FILE, + variables: { input: { id: bobSeededPublicFileId } }, + }); + expectMutationDenied(res, 'deleteAppFile'); + }); + + it('Mallory: anonymous cannot create a file directly (bypassing presigned URL)', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: CREATE_APP_FILE, + variables: { + input: { + appFile: { + bucketId: malloryPublicBucketId, + key: 'injected-hash', + contentHash: 'injected-hash', + mimeType: 'text/plain', + size: 100, + filename: 'injected-file.txt', + }, + }, + }, + }); + expectMutationDenied(res, 'createAppFile'); + }); + + it('Mallory: anonymous cannot update a file', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: UPDATE_APP_FILE, + variables: { + input: { id: malloryPublicFileId, patch: { filename: 'hacked.txt' } }, + }, }); + expectMutationDenied(res, 'updateAppFile'); + }); + it('Mallory: anonymous cannot delete a file', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: DELETE_APP_FILE, + variables: { input: { id: malloryPublicFileId } }, + }); + expectMutationDenied(res, 'deleteAppFile'); + }); + + it('Bob seeded public file still exists after all attack attempts', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); + const ids = res.body.data.appFiles.nodes.map((f: { id: string }) => f.id); + expect(ids).toContain(bobSeededPublicFileId); + }); + }); + + // ========================================================================== + // 6. Bucket mutation attacks + // ========================================================================== + + describe('Bucket mutation attacks', () => { + it('Bob: anonymous cannot create a bucket', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: CREATE_APP_BUCKET, + variables: { + input: { appBucket: { key: 'evil-bucket', type: 'public', isPublic: true } }, + }, + }); + expectMutationDenied(res, 'createAppBucket'); + }); - const files: { filename: string; bucketId: string }[] = - res.body.data.appFiles.nodes; + it('Bob: anonymous cannot update a bucket (flip public to private)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: UPDATE_APP_BUCKET, + variables: { + input: { id: bobPublicBucketId, patch: { isPublic: false } }, + }, + }); + expectMutationDenied(res, 'updateAppBucket'); + }); - const publicBucketId = 'd2d2d2d2-0000-0000-0000-000000000001'; - const privateBucketId = 'd2d2d2d2-0000-0000-0000-000000000002'; + it('Bob: anonymous cannot delete a bucket', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: DELETE_APP_BUCKET, + variables: { input: { id: bobPublicBucketId } }, + }); + expectMutationDenied(res, 'deleteAppBucket'); + }); - const publicFiles = files.filter((f) => f.bucketId === publicBucketId); - const privateFiles = files.filter((f) => f.bucketId === privateBucketId); + it('Mallory: anonymous cannot create a bucket', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: CREATE_APP_BUCKET, + variables: { + input: { appBucket: { key: 'evil-bucket', type: 'public', isPublic: true } }, + }, + }); + expectMutationDenied(res, 'createAppBucket'); + }); + }); + + // ========================================================================== + // 7. Cross-tenant header manipulation attacks + // ========================================================================== + + describe('Cross-tenant header manipulation attacks', () => { + it('404 when sending Bob database_id with Alice API name', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'app', { query: APP_FILES }); + expect(res.status).toBe(404); + }); + + it('404 when sending Alice database_id with Bob API name', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'bob-app', { query: APP_FILES }); + expect(res.status).toBe(404); + }); + + it('404 when sending Mallory database_id with Alice API name', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'app', { query: APP_FILES }); + expect(res.status).toBe(404); + }); + + it('404 when sending Alice database_id with Mallory API name', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'mallory-app', { query: APP_FILES }); + expect(res.status).toBe(404); + }); + + it('404 when sending a nonexistent database_id', async () => { + const res = await postGraphQLViaApi( + 'deadbeef-dead-4ead-beef-deadbeefbeef', + 'app', + { query: APP_FILES }, + ); + expect(res.status).toBe(404); + }); + + it('X-Schemata with Bob schema + Alice database_id does NOT leak Alice data', async () => { + const res = await postGraphQLViaSchemata(aliceDatabaseId, bobSchemas, { query: APP_FILES }); + if (res.status === 200 && res.body.data) { + const names = (res.body.data.appFiles?.nodes ?? []).map( + (f: { filename: string }) => f.filename, + ); + expect(names).not.toContain('hello-public.txt'); + expect(names).not.toContain('hello-private.txt'); + } + }); + + it('X-Schemata with Mallory schema + Bob database_id does NOT leak Bob data', async () => { + const res = await postGraphQLViaSchemata(bobDatabaseId, mallorySchemas, { query: APP_FILES }); + if (res.status === 200 && res.body.data) { + const names = (res.body.data.appFiles?.nodes ?? []).map( + (f: { filename: string }) => f.filename, + ); + expect(names).not.toContain('bob-file.txt'); + expect(names).not.toContain('bob-seeded-public.txt'); + expect(names).not.toContain('bob-seeded-private.txt'); + } + }); + }); + + // ========================================================================== + // 8. RLS enforcement on Bob's schema (public vs private bucket files) + // ========================================================================== + + describe('RLS enforcement on Bob schema', () => { + it('anonymous only sees public-bucket files', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const files: { bucketId: string }[] = res.body.data.appFiles.nodes; + const publicFiles = files.filter((f) => f.bucketId === bobPublicBucketId); + const privateFiles = files.filter((f) => f.bucketId === bobPrivateBucketId); expect(publicFiles.length).toBeGreaterThanOrEqual(1); expect(privateFiles).toHaveLength(0); }); + + it('pre-seeded private file is NOT visible to anonymous', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + expect(res.status).toBe(200); + const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).not.toContain('bob-seeded-private.txt'); + }); + + it('pre-seeded public file IS visible to anonymous', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + expect(res.status).toBe(200); + const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).toContain('bob-seeded-public.txt'); + }); }); }); - From 13e6b4843e3bd582dee89c5c580d571434486cbc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 07:04:33 +0000 Subject: [PATCH 4/8] fix: replace invalid Mallory UUIDs (m is not hex) with valid fa-prefix UUIDs --- .../seed/simple-seed-storage/test-data.sql | 38 +++++++++---------- .../__tests__/upload.integration.test.ts | 11 ++++-- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index 79c32a4ee..a31be2560 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql @@ -240,7 +240,7 @@ VALUES ( INSERT INTO metaschema_public.database (id, owner_id, name, hash) VALUES ( - 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', + 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', NULL, 'mallory-storage', '636c2f32-2382-7972-a7f0-4c1a2e593446' @@ -248,13 +248,13 @@ VALUES ( INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public) VALUES - ('m2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'public', 'mallory-storage-public', NULL, true) + ('fa22fa22-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'public', 'mallory-storage-public', NULL, true) ON CONFLICT (id) DO NOTHING; INSERT INTO metaschema_public.table (id, database_id, schema_id, name, description) VALUES - ('m3m3m3m3-0000-0000-0000-000000000001', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_buckets', NULL), - ('m3m3m3m3-0000-0000-0000-000000000002', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_files', NULL) + ('fa33fa33-0000-0000-0000-000000000001', 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'fa22fa22-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_buckets', NULL), + ('fa33fa33-0000-0000-0000-000000000002', 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'fa22fa22-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'app_files', NULL) ON CONFLICT (id) DO NOTHING; -- ===================================================== @@ -263,12 +263,12 @@ ON CONFLICT (id) DO NOTHING; INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) VALUES - ('m4m4m4m4-a5a5-4b6b-c7c7-d8d8d8d8d8d8', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'mallory-app', current_database(), false, 'authenticated', 'anonymous') + ('fa44fa44-a5a5-4b6b-c7c7-d8d8d8d8d8d8', 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'mallory-app', current_database(), false, 'authenticated', 'anonymous') ON CONFLICT (id) DO NOTHING; INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) VALUES - ('m5m5m5m5-0000-0000-0000-000000000001', 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'm4m4m4m4-a5a5-4b6b-c7c7-d8d8d8d8d8d8') + ('fa55fa55-0000-0000-0000-000000000001', 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', 'fa22fa22-a3a3-4b4b-c5c5-d6d6d6d6d6d6', 'fa44fa44-a5a5-4b6b-c7c7-d8d8d8d8d8d8') ON CONFLICT (id) DO NOTHING; -- ===================================================== @@ -287,11 +287,11 @@ INSERT INTO metaschema_modules_public.storage_module ( allowed_origins ) VALUES ( - 'm6m6m6m6-0000-0000-0000-000000000001', - 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5', - 'm2m2m2m2-a3a3-4b4b-c5c5-d6d6d6d6d6d6', - 'm3m3m3m3-0000-0000-0000-000000000001', - 'm3m3m3m3-0000-0000-0000-000000000002', + 'fa66fa66-0000-0000-0000-000000000001', + 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5', + 'fa22fa22-a3a3-4b4b-c5c5-d6d6d6d6d6d6', + 'fa33fa33-0000-0000-0000-000000000001', + 'fa33fa33-0000-0000-0000-000000000002', NULL, NULL, 'minio', @@ -304,15 +304,15 @@ VALUES ( INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public) VALUES - ('m7m7m7m7-0000-0000-0000-000000000001', 'public', 'public', true), - ('m7m7m7m7-0000-0000-0000-000000000002', 'private', 'private', false) + ('fa77fa77-0000-0000-0000-000000000001', 'public', 'public', true), + ('fa77fa77-0000-0000-0000-000000000002', 'private', 'private', false) ON CONFLICT (id) DO NOTHING; -- Pre-seed files in Mallory's buckets for RLS testing INSERT INTO "mallory-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) VALUES ( - 'm9m9m9m9-0000-0000-0000-000000000001', - 'm7m7m7m7-0000-0000-0000-000000000001', + 'fa99fa99-0000-0000-0000-000000000001', + 'fa77fa77-0000-0000-0000-000000000001', 'mallory-public-hash', 'mallory-public-hash', 'text/plain', @@ -323,8 +323,8 @@ VALUES ( INSERT INTO "mallory-storage-public".app_files (id, bucket_id, key, content_hash, mime_type, size, filename, is_public) VALUES ( - 'm9m9m9m9-0000-0000-0000-000000000002', - 'm7m7m7m7-0000-0000-0000-000000000002', + 'fa99fa99-0000-0000-0000-000000000002', + 'fa77fa77-0000-0000-0000-000000000002', 'mallory-private-hash', 'mallory-private-hash', 'text/plain', @@ -339,8 +339,8 @@ VALUES ( INSERT INTO services_public.database_settings (id, database_id) VALUES ( - 'm8m8m8m8-0000-0000-0000-000000000001', - 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5' + 'fa88fa88-0000-0000-0000-000000000001', + 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5' ) ON CONFLICT (database_id) DO NOTHING; SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 72f177f56..13d54ac5d 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -55,10 +55,10 @@ const bobPrivateBucketId = 'd2d2d2d2-0000-0000-0000-000000000002'; const bobSeededPublicFileId = 'd3d3d3d3-0000-0000-0000-000000000002'; // Mallory -- strictest RLS (anonymous: SELECT only, no mutations) -const malloryDatabaseId = 'm1m1m1m1-a2a2-4b3b-c4c4-d5d5d5d5d5d5'; +const malloryDatabaseId = 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5'; const mallorySchemas = ['mallory-storage-public']; -const malloryPublicFileId = 'm9m9m9m9-0000-0000-0000-000000000001'; -const malloryPublicBucketId = 'm7m7m7m7-0000-0000-0000-000000000001'; +const malloryPublicFileId = 'fa99fa99-0000-0000-0000-000000000001'; +const malloryPublicBucketId = 'fa77fa77-0000-0000-0000-000000000001'; const metaSchemas = [ 'services_public', @@ -225,8 +225,10 @@ function expectMutationDenied( ): void { if (res.body.errors) { expect(res.body.errors.length).toBeGreaterThan(0); - } else { + } else if (res.body.data) { expect(res.body.data[mutationName]).toBeNull(); + } else { + expect(res.status).not.toBe(200); } } @@ -798,3 +800,4 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }); }); }); + From 148c5fd3597bb5e7a980e62d20de9ed648a4c3e8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 08:24:31 +0000 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20clean=20up=20upload=20integrati?= =?UTF-8?q?on=20test=20=E2=80=94=20expectRlsDenied,=20expectSuccess,=20sup?= =?UTF-8?q?eruser=20verification,=20DRY=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seed/simple-seed-storage/schema.sql | 242 ++++++------------ .../__tests__/upload.integration.test.ts | 172 +++++++------ 2 files changed, 171 insertions(+), 243 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql index fa84f6478..9b811e6b0 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql @@ -1,135 +1,96 @@ -- Schema creation for simple-seed-storage test scenario --- Creates the app schema with storage tables (buckets, files) +-- Creates storage schemas (buckets + files) for three tenants: +-- Alice (no RLS), Bob (moderate RLS), Mallory (strictest RLS) --- Create app schemas -CREATE SCHEMA IF NOT EXISTS "simple-storage-public"; - --- Grant schema usage -GRANT USAGE ON SCHEMA "simple-storage-public" TO administrator, authenticated, anonymous; +-- ===================================================== +-- Helper: create a storage schema with buckets + files tables +-- ===================================================== --- Set default privileges -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-storage-public" - GRANT ALL ON TABLES TO administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-storage-public" - GRANT USAGE ON SEQUENCES TO administrator, authenticated; -ALTER DEFAULT PRIVILEGES IN SCHEMA "simple-storage-public" - GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; +CREATE FUNCTION _test_create_storage_schema(schema_name text) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name); + + EXECUTE format('GRANT USAGE ON SCHEMA %I TO administrator, authenticated, anonymous', schema_name); + + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO administrator', schema_name); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT USAGE ON SEQUENCES TO administrator, authenticated', schema_name); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous', schema_name); + + -- Buckets table + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.app_buckets ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + key text NOT NULL, + type text NOT NULL DEFAULT ''private'', + is_public boolean NOT NULL DEFAULT false, + allowed_mime_types text[] NULL, + max_file_size bigint NULL, + allow_custom_keys boolean NOT NULL DEFAULT false, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (key) + )', schema_name); + + EXECUTE format( + 'COMMENT ON TABLE %I.app_buckets IS E''@storageBuckets\nStorage buckets table''', + schema_name); + + -- Files table + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.app_files ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + bucket_id uuid NOT NULL REFERENCES %I.app_buckets(id), + key text NOT NULL, + content_hash text NOT NULL, + mime_type text NOT NULL, + size bigint, + filename text, + owner_id uuid, + is_public boolean NOT NULL DEFAULT false, + previous_version_id uuid REFERENCES %I.app_files(id), + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (bucket_id, key) + )', schema_name, schema_name, schema_name); + + EXECUTE format( + 'COMMENT ON TABLE %I.app_files IS E''@storageFiles\nStorage files table''', + schema_name); + + -- Grant CRUD to all roles + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.app_buckets TO administrator, authenticated, anonymous', schema_name); + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.app_files TO administrator, authenticated, anonymous', schema_name); +END; +$$; -- ===================================================== --- STORAGE TABLES (mirroring what the storage module generator creates) +-- ALICE (no RLS — wide open) -- ===================================================== --- Buckets table -CREATE TABLE IF NOT EXISTS "simple-storage-public".app_buckets ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - key text NOT NULL, - type text NOT NULL DEFAULT 'private', - is_public boolean NOT NULL DEFAULT false, - allowed_mime_types text[] NULL, - max_file_size bigint NULL, - allow_custom_keys boolean NOT NULL DEFAULT false, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (key) -); - -COMMENT ON TABLE "simple-storage-public".app_buckets IS E'@storageBuckets\nStorage buckets table'; - --- Files table -CREATE TABLE IF NOT EXISTS "simple-storage-public".app_files ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - bucket_id uuid NOT NULL REFERENCES "simple-storage-public".app_buckets(id), - key text NOT NULL, - content_hash text NOT NULL, - mime_type text NOT NULL, - size bigint, - filename text, - owner_id uuid, - is_public boolean NOT NULL DEFAULT false, - previous_version_id uuid REFERENCES "simple-storage-public".app_files(id), - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (bucket_id, key) -); - -COMMENT ON TABLE "simple-storage-public".app_files IS E'@storageFiles\nStorage files table'; - --- Grant table permissions (allow anonymous to do CRUD for tests — no RLS) -GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-storage-public".app_buckets TO administrator, authenticated, anonymous; -GRANT SELECT, INSERT, UPDATE, DELETE ON "simple-storage-public".app_files TO administrator, authenticated, anonymous; +SELECT _test_create_storage_schema('simple-storage-public'); -- ===================================================== --- BOB'S STORAGE SCHEMA (separate tenant with RLS) +-- BOB (moderate RLS) +-- Buckets: anonymous sees public only +-- Files: anonymous can SELECT public-bucket files + INSERT; no UPDATE/DELETE -- ===================================================== -CREATE SCHEMA IF NOT EXISTS "bob-storage-public"; - -GRANT USAGE ON SCHEMA "bob-storage-public" TO administrator, authenticated, anonymous; - -ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" - GRANT ALL ON TABLES TO administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" - GRANT USAGE ON SEQUENCES TO administrator, authenticated; -ALTER DEFAULT PRIVILEGES IN SCHEMA "bob-storage-public" - GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; - --- Buckets table (same structure as Alice) -CREATE TABLE IF NOT EXISTS "bob-storage-public".app_buckets ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - key text NOT NULL, - type text NOT NULL DEFAULT 'private', - is_public boolean NOT NULL DEFAULT false, - allowed_mime_types text[] NULL, - max_file_size bigint NULL, - allow_custom_keys boolean NOT NULL DEFAULT false, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (key) -); - -COMMENT ON TABLE "bob-storage-public".app_buckets IS E'@storageBuckets\nStorage buckets table'; - --- Files table (same structure as Alice) -CREATE TABLE IF NOT EXISTS "bob-storage-public".app_files ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - bucket_id uuid NOT NULL REFERENCES "bob-storage-public".app_buckets(id), - key text NOT NULL, - content_hash text NOT NULL, - mime_type text NOT NULL, - size bigint, - filename text, - owner_id uuid, - is_public boolean NOT NULL DEFAULT false, - previous_version_id uuid REFERENCES "bob-storage-public".app_files(id), - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (bucket_id, key) -); - -COMMENT ON TABLE "bob-storage-public".app_files IS E'@storageFiles\nStorage files table'; - --- Grant table permissions -GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_buckets TO administrator, authenticated, anonymous; -GRANT SELECT, INSERT, UPDATE, DELETE ON "bob-storage-public".app_files TO administrator, authenticated, anonymous; - --- Enable RLS on Bob's buckets table +SELECT _test_create_storage_schema('bob-storage-public'); + ALTER TABLE "bob-storage-public".app_buckets ENABLE ROW LEVEL SECURITY; --- RLS: anonymous can only see public buckets (prevents bucket enumeration) CREATE POLICY anon_read_public_buckets ON "bob-storage-public".app_buckets FOR SELECT TO anonymous USING (is_public = true); --- RLS: administrator bypasses bucket RLS CREATE POLICY admin_all_buckets ON "bob-storage-public".app_buckets FOR ALL TO administrator USING (true) WITH CHECK (true); --- Enable RLS on Bob's files table ALTER TABLE "bob-storage-public".app_files ENABLE ROW LEVEL SECURITY; --- RLS policy: anonymous can only see files in public buckets CREATE POLICY anon_read_public_files ON "bob-storage-public".app_files FOR SELECT TO anonymous USING ( @@ -138,7 +99,7 @@ CREATE POLICY anon_read_public_files ON "bob-storage-public".app_files ) ); --- RLS policy: anonymous can insert into any bucket (for upload testing) +-- Anonymous can insert into any bucket (for upload testing) CREATE POLICY anon_insert_files ON "bob-storage-public".app_files FOR INSERT TO anonymous WITH CHECK (true); @@ -149,68 +110,17 @@ CREATE POLICY anon_insert_files ON "bob-storage-public".app_files -- - flip is_public flags -- - delete other users' files --- RLS policy: administrator bypasses RLS CREATE POLICY admin_all_files ON "bob-storage-public".app_files FOR ALL TO administrator USING (true) WITH CHECK (true); -- ===================================================== --- MALLORY'S STORAGE SCHEMA (adversarial third tenant — strictest RLS) --- anonymous can only SELECT, no INSERT/UPDATE/DELETE at all +-- MALLORY (strictest RLS — anonymous can only SELECT) -- ===================================================== -CREATE SCHEMA IF NOT EXISTS "mallory-storage-public"; - -GRANT USAGE ON SCHEMA "mallory-storage-public" TO administrator, authenticated, anonymous; - -ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" - GRANT ALL ON TABLES TO administrator; -ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" - GRANT USAGE ON SEQUENCES TO administrator, authenticated; -ALTER DEFAULT PRIVILEGES IN SCHEMA "mallory-storage-public" - GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; - --- Buckets table -CREATE TABLE IF NOT EXISTS "mallory-storage-public".app_buckets ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - key text NOT NULL, - type text NOT NULL DEFAULT 'private', - is_public boolean NOT NULL DEFAULT false, - allowed_mime_types text[] NULL, - max_file_size bigint NULL, - allow_custom_keys boolean NOT NULL DEFAULT false, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (key) -); - -COMMENT ON TABLE "mallory-storage-public".app_buckets IS E'@storageBuckets\nStorage buckets table'; - --- Files table -CREATE TABLE IF NOT EXISTS "mallory-storage-public".app_files ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - bucket_id uuid NOT NULL REFERENCES "mallory-storage-public".app_buckets(id), - key text NOT NULL, - content_hash text NOT NULL, - mime_type text NOT NULL, - size bigint, - filename text, - owner_id uuid, - is_public boolean NOT NULL DEFAULT false, - previous_version_id uuid REFERENCES "mallory-storage-public".app_files(id), - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - UNIQUE (bucket_id, key) -); - -COMMENT ON TABLE "mallory-storage-public".app_files IS E'@storageFiles\nStorage files table'; - --- Grant table permissions -GRANT SELECT, INSERT, UPDATE, DELETE ON "mallory-storage-public".app_buckets TO administrator, authenticated, anonymous; -GRANT SELECT, INSERT, UPDATE, DELETE ON "mallory-storage-public".app_files TO administrator, authenticated, anonymous; - --- Enable RLS on Mallory's buckets — anonymous can only read +SELECT _test_create_storage_schema('mallory-storage-public'); + ALTER TABLE "mallory-storage-public".app_buckets ENABLE ROW LEVEL SECURITY; CREATE POLICY anon_read_buckets ON "mallory-storage-public".app_buckets @@ -222,10 +132,9 @@ CREATE POLICY admin_all_buckets ON "mallory-storage-public".app_buckets USING (true) WITH CHECK (true); --- Enable RLS on Mallory's files — anonymous can only read (strictest policy) --- No INSERT/UPDATE/DELETE for anonymous at all ALTER TABLE "mallory-storage-public".app_files ENABLE ROW LEVEL SECURITY; +-- Anonymous can only read (no INSERT/UPDATE/DELETE at all) CREATE POLICY anon_read_files ON "mallory-storage-public".app_files FOR SELECT TO anonymous USING (true); @@ -234,3 +143,6 @@ CREATE POLICY admin_all_files ON "mallory-storage-public".app_files FOR ALL TO administrator USING (true) WITH CHECK (true); + +-- Clean up helper +DROP FUNCTION _test_create_storage_schema(text); diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 13d54ac5d..6ee638ae0 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -31,6 +31,7 @@ import crypto from 'crypto'; import path from 'path'; import { getConnections, seed } from '../src'; +import type { PgTestClient } from 'pgsql-test/test-client'; import type supertest from 'supertest'; jest.setTimeout(120000); @@ -218,18 +219,46 @@ async function putToPresignedUrl( }); } -/** Expect a GraphQL response to indicate a denied mutation (error or null). */ -function expectMutationDenied( +/** + * Assert that a mutation was denied specifically by RLS (not by some other error). + * + * PostgreSQL RLS denials surface in two ways through PostGraphile: + * 1. An explicit PG error — message contains "permission denied" or + * "new row violates row-level security". + * 2. The mutation silently affects 0 rows and returns null (RLS USING + * clause filtered out the target row). + * + * Any other shape (e.g. a GraphQL validation error, a 500, a typo in a + * field name) is treated as an unexpected failure so tests don't + * accidentally pass for the wrong reason. + */ +function expectRlsDenied( res: supertest.Response, mutationName: string, ): void { - if (res.body.errors) { - expect(res.body.errors.length).toBeGreaterThan(0); - } else if (res.body.data) { + if (res.body.errors?.length) { + const msg: string = res.body.errors[0].message; + expect( + msg.includes('permission denied') || + msg.includes('new row violates row-level security') || + msg.includes('insufficient_privilege'), + ).toBe(true); + return; + } + if (res.body.data) { expect(res.body.data[mutationName]).toBeNull(); - } else { - expect(res.status).not.toBe(200); + return; } + throw new Error( + `Expected RLS denial but got status=${res.status}, body=${JSON.stringify(res.body)}`, + ); +} + +/** Assert 200 + no GraphQL errors, return the data payload. */ +function expectSuccess(res: supertest.Response): Record { + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + return res.body.data; } // ========================================================================= @@ -237,6 +266,7 @@ function expectMutationDenied( // ========================================================================= describe('Integration tests (uploads, tenant isolation, RLS)', () => { + let pg: PgTestClient; let request: supertest.Agent; let teardown: () => Promise; @@ -283,7 +313,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { // Single setup: one server, one pool, all three schemas registered beforeAll(async () => { - ({ request, teardown } = await getConnections( + ({ pg, request, teardown } = await getConnections( { schemas: [...aliceSchemas, ...bobSchemas, ...mallorySchemas], authRole: 'anonymous', @@ -328,10 +358,9 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + const data = expectSuccess(res); - const payload = res.body.data.uploadAppFile; + const payload = data.uploadAppFile; expect(payload.uploadUrl).toBeTruthy(); expect(payload.fileId).toBeTruthy(); expect(payload.key).toBe(contentHash); @@ -367,10 +396,9 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + const data = expectSuccess(res); - const payload = res.body.data.uploadAppFile; + const payload = data.uploadAppFile; expect(payload.uploadUrl).toBeTruthy(); expect(payload.fileId).toBeTruthy(); expect(payload.key).toBe(contentHash); @@ -404,10 +432,9 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + const data = expectSuccess(res); - const payload = res.body.data.uploadAppFile; + const payload = data.uploadAppFile; expect(payload.deduplicated).toBe(true); expect(payload.uploadUrl).toBeNull(); expect(payload.expiresAt).toBeNull(); @@ -425,9 +452,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = (res.body.data.__type?.fields ?? []).map( + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( (f: { name: string }) => f.name, ); expect(names).toContain('uploadAppFile'); @@ -437,9 +463,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = (res.body.data.__type?.fields ?? []).map( + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( (f: { name: string }) => f.name, ); expect(names).toContain('uploadAppFile'); @@ -449,9 +474,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-restricted', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = (res.body.data.__type?.fields ?? []).map( + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( (f: { name: string }) => f.name, ); expect(names).not.toContain('uploadAppFile'); @@ -461,9 +485,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: INTROSPECT_UPLOAD_MUTATION, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = (res.body.data.__type?.fields ?? []).map( + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( (f: { name: string }) => f.name, ); expect(names).toContain('uploadAppFile'); @@ -492,9 +515,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const payload = res.body.data.uploadAppFile; + const data = expectSuccess(res); + const payload = data.uploadAppFile; expect(payload.fileId).toBeTruthy(); expect(payload.uploadUrl).toBeTruthy(); @@ -504,27 +526,24 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { it('Bob sees his own files', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const files = res.body.data.appFiles.nodes; + const data = expectSuccess(res); + const files = data.appFiles.nodes; expect(files.length).toBeGreaterThanOrEqual(1); expect(files.some((f: { filename: string }) => f.filename === 'bob-file.txt')).toBe(true); }); it('Mallory sees her own pre-seeded files', async () => { const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const files: { filename: string }[] = res.body.data.appFiles.nodes; + const data = expectSuccess(res); + const files: { filename: string }[] = data.appFiles.nodes; expect(files.some((f) => f.filename === 'mallory-public.txt')).toBe(true); expect(files.some((f) => f.filename === 'mallory-private.txt')).toBe(true); }); it('Alice API does NOT leak Bob or Mallory files', async () => { const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); expect(names).not.toContain('bob-file.txt'); expect(names).not.toContain('mallory-public.txt'); expect(names).not.toContain('mallory-private.txt'); @@ -532,9 +551,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { it('Bob API does NOT leak Alice or Mallory files', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); expect(names).not.toContain('hello-public.txt'); expect(names).not.toContain('hello-private.txt'); expect(names).not.toContain('mallory-public.txt'); @@ -543,9 +561,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { it('Mallory API does NOT leak Alice or Bob files', async () => { const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); expect(names).not.toContain('hello-public.txt'); expect(names).not.toContain('hello-private.txt'); expect(names).not.toContain('bob-file.txt'); @@ -560,27 +577,24 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { describe('Bucket enumeration attacks', () => { it('Alice sees all buckets (no RLS on her schema)', async () => { const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { query: APP_BUCKETS }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const keys = res.body.data.appBuckets.nodes.map((b: { key: string }) => b.key); + const data = expectSuccess(res); + const keys = data.appBuckets.nodes.map((b: { key: string }) => b.key); expect(keys).toContain('public'); expect(keys).toContain('private'); }); it('Bob anonymous only sees public buckets (RLS hides private)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_BUCKETS }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const buckets: { isPublic: boolean }[] = res.body.data.appBuckets.nodes; + const data = expectSuccess(res); + const buckets: { isPublic: boolean }[] = data.appBuckets.nodes; expect(buckets.length).toBeGreaterThanOrEqual(1); expect(buckets.every((b) => b.isPublic)).toBe(true); }); it('Mallory anonymous sees all buckets (her RLS policy allows all SELECT)', async () => { const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_BUCKETS }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const keys = res.body.data.appBuckets.nodes.map((b: { key: string }) => b.key); + const data = expectSuccess(res); + const keys = data.appBuckets.nodes.map((b: { key: string }) => b.key); expect(keys).toContain('public'); expect(keys).toContain('private'); }); @@ -598,7 +612,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { id: bobSeededPublicFileId, patch: { bucketId: bobPrivateBucketId } }, }, }); - expectMutationDenied(res, 'updateAppFile'); + expectRlsDenied(res, 'updateAppFile'); }); it('Bob: anonymous cannot flip is_public flag on a file', async () => { @@ -608,7 +622,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { id: bobSeededPublicFileId, patch: { isPublic: false } }, }, }); - expectMutationDenied(res, 'updateAppFile'); + expectRlsDenied(res, 'updateAppFile'); }); it('Bob: anonymous cannot delete a file', async () => { @@ -616,7 +630,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { query: DELETE_APP_FILE, variables: { input: { id: bobSeededPublicFileId } }, }); - expectMutationDenied(res, 'deleteAppFile'); + expectRlsDenied(res, 'deleteAppFile'); }); it('Mallory: anonymous cannot create a file directly (bypassing presigned URL)', async () => { @@ -635,7 +649,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, }, }); - expectMutationDenied(res, 'createAppFile'); + expectRlsDenied(res, 'createAppFile'); }); it('Mallory: anonymous cannot update a file', async () => { @@ -645,7 +659,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { id: malloryPublicFileId, patch: { filename: 'hacked.txt' } }, }, }); - expectMutationDenied(res, 'updateAppFile'); + expectRlsDenied(res, 'updateAppFile'); }); it('Mallory: anonymous cannot delete a file', async () => { @@ -653,15 +667,18 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { query: DELETE_APP_FILE, variables: { input: { id: malloryPublicFileId } }, }); - expectMutationDenied(res, 'deleteAppFile'); + expectRlsDenied(res, 'deleteAppFile'); }); it('Bob seeded public file still exists after all attack attempts', async () => { - const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const ids = res.body.data.appFiles.nodes.map((f: { id: string }) => f.id); - expect(ids).toContain(bobSeededPublicFileId); + // Verify via superuser: ground-truth check that RLS attacks didn't mutate data + const { rows } = await pg.query( + 'SELECT id, bucket_id, is_public FROM "bob-storage-public".app_files WHERE id = $1', + [bobSeededPublicFileId], + ); + expect(rows).toHaveLength(1); + expect(rows[0].bucket_id).toBe(bobPublicBucketId); + expect(rows[0].is_public).toBe(true); }); }); @@ -677,7 +694,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { appBucket: { key: 'evil-bucket', type: 'public', isPublic: true } }, }, }); - expectMutationDenied(res, 'createAppBucket'); + expectRlsDenied(res, 'createAppBucket'); }); it('Bob: anonymous cannot update a bucket (flip public to private)', async () => { @@ -687,7 +704,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { id: bobPublicBucketId, patch: { isPublic: false } }, }, }); - expectMutationDenied(res, 'updateAppBucket'); + expectRlsDenied(res, 'updateAppBucket'); }); it('Bob: anonymous cannot delete a bucket', async () => { @@ -695,7 +712,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { query: DELETE_APP_BUCKET, variables: { input: { id: bobPublicBucketId } }, }); - expectMutationDenied(res, 'deleteAppBucket'); + expectRlsDenied(res, 'deleteAppBucket'); }); it('Mallory: anonymous cannot create a bucket', async () => { @@ -705,7 +722,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { input: { appBucket: { key: 'evil-bucket', type: 'public', isPublic: true } }, }, }); - expectMutationDenied(res, 'createAppBucket'); + expectRlsDenied(res, 'createAppBucket'); }); }); @@ -774,10 +791,9 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { describe('RLS enforcement on Bob schema', () => { it('anonymous only sees public-bucket files', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + const data = expectSuccess(res); - const files: { bucketId: string }[] = res.body.data.appFiles.nodes; + const files: { bucketId: string }[] = data.appFiles.nodes; const publicFiles = files.filter((f) => f.bucketId === bobPublicBucketId); const privateFiles = files.filter((f) => f.bucketId === bobPrivateBucketId); @@ -787,15 +803,15 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { it('pre-seeded private file is NOT visible to anonymous', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); expect(names).not.toContain('bob-seeded-private.txt'); }); it('pre-seeded public file IS visible to anonymous', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); - expect(res.status).toBe(200); - const names = res.body.data.appFiles.nodes.map((f: { filename: string }) => f.filename); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); expect(names).toContain('bob-seeded-public.txt'); }); }); From 36ad127f85a8dec71db150d1e4274d5afb06740b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 09:22:05 +0000 Subject: [PATCH 6/8] fix: revert superuser verification to use same HTTP path (addresses connection isolation concern) --- .../__tests__/upload.integration.test.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 6ee638ae0..08fd9226a 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -31,7 +31,6 @@ import crypto from 'crypto'; import path from 'path'; import { getConnections, seed } from '../src'; -import type { PgTestClient } from 'pgsql-test/test-client'; import type supertest from 'supertest'; jest.setTimeout(120000); @@ -266,7 +265,6 @@ function expectSuccess(res: supertest.Response): Record { // ========================================================================= describe('Integration tests (uploads, tenant isolation, RLS)', () => { - let pg: PgTestClient; let request: supertest.Agent; let teardown: () => Promise; @@ -313,7 +311,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { // Single setup: one server, one pool, all three schemas registered beforeAll(async () => { - ({ pg, request, teardown } = await getConnections( + ({ request, teardown } = await getConnections( { schemas: [...aliceSchemas, ...bobSchemas, ...mallorySchemas], authRole: 'anonymous', @@ -671,14 +669,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }); it('Bob seeded public file still exists after all attack attempts', async () => { - // Verify via superuser: ground-truth check that RLS attacks didn't mutate data - const { rows } = await pg.query( - 'SELECT id, bucket_id, is_public FROM "bob-storage-public".app_files WHERE id = $1', - [bobSeededPublicFileId], - ); - expect(rows).toHaveLength(1); - expect(rows[0].bucket_id).toBe(bobPublicBucketId); - expect(rows[0].is_public).toBe(true); + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + const data = expectSuccess(res); + const ids = data.appFiles.nodes.map((f: { id: string }) => f.id); + expect(ids).toContain(bobSeededPublicFileId); }); }); From 3c71d060665ce5a70715bed3acfed65e5808d433 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 09:36:30 +0000 Subject: [PATCH 7/8] fix: correct mutation variable shapes and expand expectRlsDenied patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update mutations: patch → appFilePatch/appBucketPatch (PostGraphile v5 input types) - Add 'No values were' pattern to expectRlsDenied (RLS USING-clause denials on delete/update) --- .../server-test/__tests__/upload.integration.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 08fd9226a..195b4f400 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -240,7 +240,8 @@ function expectRlsDenied( expect( msg.includes('permission denied') || msg.includes('new row violates row-level security') || - msg.includes('insufficient_privilege'), + msg.includes('insufficient_privilege') || + msg.includes('No values were'), ).toBe(true); return; } @@ -607,7 +608,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: UPDATE_APP_FILE, variables: { - input: { id: bobSeededPublicFileId, patch: { bucketId: bobPrivateBucketId } }, + input: { id: bobSeededPublicFileId, appFilePatch: { bucketId: bobPrivateBucketId } }, }, }); expectRlsDenied(res, 'updateAppFile'); @@ -617,7 +618,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: UPDATE_APP_FILE, variables: { - input: { id: bobSeededPublicFileId, patch: { isPublic: false } }, + input: { id: bobSeededPublicFileId, appFilePatch: { isPublic: false } }, }, }); expectRlsDenied(res, 'updateAppFile'); @@ -654,7 +655,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: UPDATE_APP_FILE, variables: { - input: { id: malloryPublicFileId, patch: { filename: 'hacked.txt' } }, + input: { id: malloryPublicFileId, appFilePatch: { filename: 'hacked.txt' } }, }, }); expectRlsDenied(res, 'updateAppFile'); @@ -695,7 +696,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: UPDATE_APP_BUCKET, variables: { - input: { id: bobPublicBucketId, patch: { isPublic: false } }, + input: { id: bobPublicBucketId, appBucketPatch: { isPublic: false } }, }, }); expectRlsDenied(res, 'updateAppBucket'); From 8c0d743ba3e1ecea8c0817421fcb903eec41c086 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 10 May 2026 10:10:17 +0000 Subject: [PATCH 8/8] fix: handle masked errors and nested null in expectRlsDenied - Add INTERNAL_SERVER_ERROR code check (PostGraphile masks PG errors in production) - Add explicit GRAPHQL_VALIDATION_FAILED rejection to catch test bugs - Handle nested null data pattern (e.g. { appFile: null }) from RLS USING clause - Remove 'Supabase-style' from test describe name --- .../__tests__/upload.integration.test.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index 195b4f400..a9a6b52d1 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -221,33 +221,44 @@ async function putToPresignedUrl( /** * Assert that a mutation was denied specifically by RLS (not by some other error). * - * PostgreSQL RLS denials surface in two ways through PostGraphile: - * 1. An explicit PG error — message contains "permission denied" or - * "new row violates row-level security". - * 2. The mutation silently affects 0 rows and returns null (RLS USING - * clause filtered out the target row). + * PostgreSQL RLS denials surface in three ways through PostGraphile: + * 1. An explicit PG error — message contains "permission denied", + * "new row violates row-level security", or "No values were". + * 2. A masked internal error — in production mode PostGraphile masks + * PG errors with code INTERNAL_SERVER_ERROR (the raw message is + * only logged server-side). + * 3. The mutation silently affects 0 rows and returns null or an + * object with all-null fields (RLS USING clause filtered the row). * - * Any other shape (e.g. a GraphQL validation error, a 500, a typo in a - * field name) is treated as an unexpected failure so tests don't - * accidentally pass for the wrong reason. + * GraphQL validation errors (GRAPHQL_VALIDATION_FAILED) are explicitly + * rejected so tests don't accidentally pass for the wrong reason. */ function expectRlsDenied( res: supertest.Response, mutationName: string, ): void { if (res.body.errors?.length) { - const msg: string = res.body.errors[0].message; + const err = res.body.errors[0]; + const msg: string = err.message; + const code: string = err.extensions?.code ?? ''; + // Reject GraphQL validation errors — these indicate a bug in the test + expect(code).not.toBe('GRAPHQL_VALIDATION_FAILED'); expect( msg.includes('permission denied') || msg.includes('new row violates row-level security') || msg.includes('insufficient_privilege') || - msg.includes('No values were'), + msg.includes('No values were') || + code === 'INTERNAL_SERVER_ERROR', ).toBe(true); return; } if (res.body.data) { - expect(res.body.data[mutationName]).toBeNull(); - return; + const result = res.body.data[mutationName]; + if (result === null) return; + if (typeof result === 'object' && Object.values(result).every((v) => v === null)) return; + throw new Error( + `Expected RLS denial but mutation returned data: ${JSON.stringify(result)}`, + ); } throw new Error( `Expected RLS denial but got status=${res.status}, body=${JSON.stringify(res.body)}`, @@ -600,10 +611,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }); // ========================================================================== - // 5. File mutation attacks -- Supabase-style metadata tampering + // 5. File mutation attacks — metadata tampering // ========================================================================== - describe('File mutation attacks (Supabase-style)', () => { + describe('File mutation attacks', () => { it('Bob: anonymous cannot update file bucket_id (move between buckets)', async () => { const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: UPDATE_APP_FILE,