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 1a4a35cbae..9f50cb4958 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 530074ff87..9b811e6b07 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql @@ -1,59 +1,148 @@ -- Schema creation for simple-seed-storage test scenario --- Creates the app schema with storage tables (buckets, files) - --- 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; - --- 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; - --- ===================================================== --- STORAGE TABLES (mirroring what the storage module generator creates) --- ===================================================== - --- 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; +-- Creates storage schemas (buckets + files) for three tenants: +-- Alice (no RLS), Bob (moderate RLS), Mallory (strictest RLS) + +-- ===================================================== +-- Helper: create a storage schema with buckets + files tables +-- ===================================================== + +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; +$$; + +-- ===================================================== +-- ALICE (no RLS — wide open) +-- ===================================================== + +SELECT _test_create_storage_schema('simple-storage-public'); + +-- ===================================================== +-- BOB (moderate RLS) +-- Buckets: anonymous sees public only +-- Files: anonymous can SELECT public-bucket files + INSERT; no UPDATE/DELETE +-- ===================================================== + +SELECT _test_create_storage_schema('bob-storage-public'); + +ALTER TABLE "bob-storage-public".app_buckets ENABLE ROW LEVEL SECURITY; + +CREATE POLICY anon_read_public_buckets ON "bob-storage-public".app_buckets + FOR SELECT TO anonymous + USING (is_public = true); + +CREATE POLICY admin_all_buckets ON "bob-storage-public".app_buckets + FOR ALL TO administrator + USING (true) + WITH CHECK (true); + +ALTER TABLE "bob-storage-public".app_files ENABLE ROW LEVEL SECURITY; + +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 + ) + ); + +-- 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); + +-- 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 + +CREATE POLICY admin_all_files ON "bob-storage-public".app_files + FOR ALL TO administrator + USING (true) + WITH CHECK (true); + +-- ===================================================== +-- MALLORY (strictest RLS — anonymous can only SELECT) +-- ===================================================== + +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 + 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); + +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); + +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/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index 92c09deaad..a31be25604 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) @@ -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,253 @@ 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; + +-- 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 ( + '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) +-- ===================================================== + +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; + +-- ===================================================== +-- MALLORY METASCHEMA DATA (adversarial third tenant) +-- ===================================================== + +INSERT INTO metaschema_public.database (id, owner_id, name, hash) +VALUES ( + 'fa11fa11-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 + ('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 + ('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; + +-- ===================================================== +-- MALLORY SERVICES DATA +-- ===================================================== + +INSERT INTO services_public.apis (id, database_id, name, dbname, is_public, role_name, anon_role) +VALUES + ('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 + ('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; + +-- ===================================================== +-- 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 ( + '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', + ARRAY['*'] +) ON CONFLICT (id) DO NOTHING; + +-- ===================================================== +-- MALLORY BUCKET SEED DATA +-- ===================================================== + +INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public) +VALUES + ('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 ( + 'fa99fa99-0000-0000-0000-000000000001', + 'fa77fa77-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 ( + 'fa99fa99-0000-0000-0000-000000000002', + 'fa77fa77-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 ( + '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 6311fa8712..a9a6b52d1f 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -1,11 +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. No RLS — that will be tested in constructive-db. + * provisioning. + * + * 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 @@ -16,30 +33,49 @@ 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'; +// ========================================================================= +// Tenant constants +// ========================================================================= + +// Alice -- baseline tenant (no RLS) +const aliceDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +const aliceSchemas = ['simple-storage-public']; + +// 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 = 'fa11fa11-a2a2-4b3b-c4c4-d5d5d5d5d5d5'; +const mallorySchemas = ['mallory-storage-public']; +const malloryPublicFileId = 'fa99fa99-0000-0000-0000-000000000001'; +const malloryPublicBucketId = 'fa77fa77-0000-0000-0000-000000000001'; + const metaSchemas = [ 'services_public', 'metaschema_public', 'metaschema_modules_public', ]; -const schemas = ['simple-storage-public']; 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!) { @@ -53,7 +89,115 @@ const UPLOAD_APP_FILE = ` } `; -// --- Helpers --- +const APP_FILES = ` + query AppFiles { + appFiles { + nodes { + id + key + filename + mimeType + isPublic + bucketId + } + } + } +`; + +const APP_BUCKETS = ` + query AppBuckets { + appBuckets { + nodes { + id + key + type + isPublic + } + } + } +`; + +const INTROSPECT_UPLOAD_MUTATION = ` + query IntrospectUpload { + __type(name: "Mutation") { + fields { + name + } + } + } +`; + +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'); @@ -74,9 +218,65 @@ async function putToPresignedUrl( }); } -// --- Tests --- +/** + * Assert that a mutation was denied specifically by RLS (not by some other error). + * + * 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). + * + * 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 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') || + code === 'INTERNAL_SERVER_ERROR', + ).toBe(true); + return; + } + if (res.body.data) { + 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)}`, + ); +} + +/** 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; +} + +// ========================================================================= +// Tests -- single beforeAll, single server instance, all scenarios +// ========================================================================= -describe('Upload integration (file-centric upload mutations)', () => { +describe('Integration tests (uploads, tenant isolation, RLS)', () => { let request: supertest.Agent; let teardown: () => Promise; @@ -86,15 +286,46 @@ describe('Upload integration (file-centric upload mutations)', () => { }) => { return request .post('/graphql') - .set('X-Database-Id', servicesDatabaseId) + .set('X-Database-Id', aliceDatabaseId) + .set('X-Schemata', aliceSchemas.join(',')) + .send(payload); + }; + + 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); + }; + + 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, + schemas: [...aliceSchemas, ...bobSchemas, ...mallorySchemas], authRole: 'anonymous', server: { api: { @@ -112,110 +343,482 @@ describe('Upload integration (file-centric upload mutations)', () => { if (teardown) await teardown(); }); - describe('Public file upload via uploadAppFile mutation', () => { - const fileContent = 'Hello, public world!'; - const contentType = 'text/plain'; - const contentHash = sha256(fileContent); - let uploadUrl: string; + // ========================================================================== + // 1. Presigned URL uploads (Alice, schemata-header mode) + // ========================================================================== + + 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', + }, + }, + }); + + const data = expectSuccess(res); + + const payload = 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; + }); + + 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', () => { + 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', + }, + }, + }); + + const data = expectSuccess(res); + + const payload = 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; + }); + + 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 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', + }, + }, + }); + + const data = expectSuccess(res); + + const payload = data.uploadAppFile; + expect(payload.deduplicated).toBe(true); + expect(payload.uploadUrl).toBeNull(); + expect(payload.expiresAt).toBeNull(); + expect(payload.fileId).toBeTruthy(); + }); + }); + }); + + // ========================================================================== + // 2. Feature flag gating via database_settings / api_settings + // ========================================================================== + + describe('Feature flag gating via database_settings / api_settings', () => { + it('Alice: uploadAppFile exposed (presigned uploads enabled)', async () => { + const res = await postGraphQLViaApi(aliceDatabaseId, 'app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); + }); + + it('Bob primary API: uploadAppFile exposed (enabled by default)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); + }); + + it('Bob restricted API: uploadAppFile NOT exposed (api_settings override)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-restricted', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).not.toContain('uploadAppFile'); + }); - it('should return a presigned PUT URL via uploadAppFile', async () => { - const res = await postGraphQL({ + it('Mallory: uploadAppFile exposed (enabled by default)', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: INTROSPECT_UPLOAD_MUTATION, + }); + const data = expectSuccess(res); + const names = (data.__type?.fields ?? []).map( + (f: { name: string }) => f.name, + ); + expect(names).toContain('uploadAppFile'); + }); + }); + + // ========================================================================== + // 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); + + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: UPLOAD_APP_FILE, variables: { input: { bucketKey: 'public', contentHash, - contentType, + contentType: 'text/plain', size: Buffer.byteLength(fileContent), - filename: 'hello-public.txt', + filename: 'bob-file.txt', }, }, }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - - const payload = res.body.data.uploadAppFile; - expect(payload.uploadUrl).toBeTruthy(); + const data = expectSuccess(res); + const payload = data.uploadAppFile; expect(payload.fileId).toBeTruthy(); - expect(payload.key).toBe(contentHash); - expect(payload.deduplicated).toBe(false); - expect(payload.expiresAt).toBeTruthy(); + expect(payload.uploadUrl).toBeTruthy(); - uploadUrl = payload.uploadUrl; + const putRes = await putToPresignedUrl(payload.uploadUrl, fileContent, 'text/plain'); + 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); + it('Bob sees his own files', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + 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 }); + 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 }); + 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'); + }); + + it('Bob API does NOT leak Alice or Mallory files', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_FILES }); + 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'); + expect(names).not.toContain('mallory-private.txt'); + }); + + it('Mallory API does NOT leak Alice or Bob files', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { query: APP_FILES }); + 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'); + expect(names).not.toContain('bob-seeded-public.txt'); }); }); - describe('Private file upload via uploadAppFile mutation', () => { - const fileContent = 'Hello, private world!'; - const contentType = 'text/plain'; - const contentHash = sha256(fileContent); - let uploadUrl: string; + // ========================================================================== + // 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 }); + const data = expectSuccess(res); + const keys = data.appBuckets.nodes.map((b: { key: string }) => b.key); + expect(keys).toContain('public'); + expect(keys).toContain('private'); + }); - it('should return a presigned PUT URL via uploadAppFile', async () => { - const res = await postGraphQL({ - query: UPLOAD_APP_FILE, + it('Bob anonymous only sees public buckets (RLS hides private)', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { query: APP_BUCKETS }); + 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 }); + const data = expectSuccess(res); + const keys = data.appBuckets.nodes.map((b: { key: string }) => b.key); + expect(keys).toContain('public'); + expect(keys).toContain('private'); + }); + }); + + // ========================================================================== + // 5. File mutation attacks — metadata tampering + // ========================================================================== + + 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, + variables: { + input: { id: bobSeededPublicFileId, appFilePatch: { bucketId: bobPrivateBucketId } }, + }, + }); + expectRlsDenied(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, appFilePatch: { isPublic: false } }, + }, + }); + expectRlsDenied(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 } }, + }); + expectRlsDenied(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: { - bucketKey: 'private', - contentHash, - contentType, - size: Buffer.byteLength(fileContent), - filename: 'hello-private.txt', + appFile: { + bucketId: malloryPublicBucketId, + key: 'injected-hash', + contentHash: 'injected-hash', + mimeType: 'text/plain', + size: 100, + filename: 'injected-file.txt', + }, }, }, }); + expectRlsDenied(res, 'createAppFile'); + }); - 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(); + it('Mallory: anonymous cannot update a file', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: UPDATE_APP_FILE, + variables: { + input: { id: malloryPublicFileId, appFilePatch: { filename: 'hacked.txt' } }, + }, + }); + expectRlsDenied(res, 'updateAppFile'); + }); - uploadUrl = payload.uploadUrl; + it('Mallory: anonymous cannot delete a file', async () => { + const res = await postGraphQLViaApi(malloryDatabaseId, 'mallory-app', { + query: DELETE_APP_FILE, + variables: { input: { id: malloryPublicFileId } }, + }); + expectRlsDenied(res, 'deleteAppFile'); }); - it('should accept a PUT to the presigned URL', async () => { - const putRes = await putToPresignedUrl(uploadUrl, fileContent, contentType); - expect(putRes.ok).toBe(true); + it('Bob seeded public file still exists after all attack attempts', async () => { + 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); }); }); - 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); + // ========================================================================== + // 6. Bucket mutation attacks + // ========================================================================== - const res = await postGraphQL({ - query: UPLOAD_APP_FILE, + 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: { - bucketKey: 'public', - contentHash, - contentType: 'text/plain', - size: Buffer.byteLength(fileContent), - filename: 'hello-public-copy.txt', - }, + input: { appBucket: { key: 'evil-bucket', type: 'public', isPublic: true } }, }, }); + expectRlsDenied(res, 'createAppBucket'); + }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + 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, appBucketPatch: { isPublic: false } }, + }, + }); + expectRlsDenied(res, 'updateAppBucket'); + }); - const payload = res.body.data.uploadAppFile; - expect(payload.deduplicated).toBe(true); - expect(payload.uploadUrl).toBeNull(); - expect(payload.expiresAt).toBeNull(); - expect(payload.fileId).toBeTruthy(); + it('Bob: anonymous cannot delete a bucket', async () => { + const res = await postGraphQLViaApi(bobDatabaseId, 'bob-app', { + query: DELETE_APP_BUCKET, + variables: { input: { id: bobPublicBucketId } }, + }); + expectRlsDenied(res, 'deleteAppBucket'); + }); + + 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 } }, + }, + }); + expectRlsDenied(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 }); + const data = expectSuccess(res); + + const files: { bucketId: string }[] = 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 }); + 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 }); + const data = expectSuccess(res); + const names = data.appFiles.nodes.map((f: { filename: string }) => f.filename); + expect(names).toContain('bob-seeded-public.txt'); }); }); });