diff --git a/graphql/server-test/__fixtures__/seed/auth-seed/schema.sql b/graphql/server-test/__fixtures__/seed/auth-seed/schema.sql new file mode 100644 index 000000000..57d657211 --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/auth-seed/schema.sql @@ -0,0 +1,141 @@ +-- Schema creation for auth-seed test scenario +-- Creates the auth-test schemas, users table, tokens table, and authentication functions + +-- Create schemas +CREATE SCHEMA IF NOT EXISTS "auth-test-public"; +CREATE SCHEMA IF NOT EXISTS "auth-test-private"; + +-- Grant schema usage +GRANT USAGE ON SCHEMA "auth-test-public" TO administrator, authenticated, anonymous; +GRANT USAGE ON SCHEMA "auth-test-private" TO administrator, authenticated, anonymous; + +-- Set default privileges for auth-test-public +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-public" + GRANT ALL ON TABLES TO administrator; +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-public" + GRANT USAGE ON SEQUENCES TO administrator, authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-public" + GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; + +-- Set default privileges for auth-test-private +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-private" + GRANT ALL ON TABLES TO administrator; +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-private" + GRANT USAGE ON SEQUENCES TO administrator, authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA "auth-test-private" + GRANT ALL ON FUNCTIONS TO administrator, authenticated, anonymous; + +-- Create users table +CREATE TABLE IF NOT EXISTS "auth-test-private".users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + role text NOT NULL DEFAULT 'authenticated', + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +-- Create tokens table +CREATE TABLE IF NOT EXISTS "auth-test-private".tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL REFERENCES "auth-test-private".users(id) ON DELETE CASCADE, + token text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL DEFAULT (now() + interval '1 day'), + created_at timestamptz DEFAULT now() +); + +-- Create timestamp triggers +DROP TRIGGER IF EXISTS timestamps_tg ON "auth-test-private".users; +CREATE TRIGGER timestamps_tg + BEFORE INSERT OR UPDATE + ON "auth-test-private".users + FOR EACH ROW + EXECUTE PROCEDURE stamps.timestamps(); + +-- Create indexes +CREATE INDEX IF NOT EXISTS tokens_user_id_idx ON "auth-test-private".tokens (user_id); +CREATE INDEX IF NOT EXISTS tokens_token_idx ON "auth-test-private".tokens (token); +CREATE INDEX IF NOT EXISTS tokens_expires_at_idx ON "auth-test-private".tokens (expires_at); + +-- Grant table permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON "auth-test-private".users TO administrator; +GRANT SELECT, INSERT, UPDATE, DELETE ON "auth-test-private".tokens TO administrator; +GRANT SELECT ON "auth-test-private".users TO authenticated, anonymous; +GRANT SELECT ON "auth-test-private".tokens TO authenticated, anonymous; + +-- Create a simple items table for testing authenticated queries +CREATE TABLE IF NOT EXISTS "auth-test-public".items ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + name text NOT NULL, + owner_id uuid REFERENCES "auth-test-private".users(id), + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +DROP TRIGGER IF EXISTS timestamps_tg ON "auth-test-public".items; +CREATE TRIGGER timestamps_tg + BEFORE INSERT OR UPDATE + ON "auth-test-public".items + FOR EACH ROW + EXECUTE PROCEDURE stamps.timestamps(); + +GRANT SELECT, INSERT, UPDATE, DELETE ON "auth-test-public".items TO administrator, authenticated; +GRANT SELECT ON "auth-test-public".items TO anonymous; + +-- Create the authenticate function +-- This function validates a bearer token and returns user info +CREATE OR REPLACE FUNCTION "auth-test-private".authenticate(token_value text) +RETURNS TABLE ( + token_id uuid, + user_id uuid, + role text, + exp bigint +) AS $$ +DECLARE + found_token "auth-test-private".tokens%ROWTYPE; + found_user "auth-test-private".users%ROWTYPE; +BEGIN + -- Find the token + SELECT * INTO found_token + FROM "auth-test-private".tokens t + WHERE t.token = token_value + AND t.expires_at > now(); + + IF NOT FOUND THEN + -- Return empty result for invalid/expired token + RETURN; + END IF; + + -- Find the user + SELECT * INTO found_user + FROM "auth-test-private".users u + WHERE u.id = found_token.user_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + -- Return the authentication result + RETURN QUERY SELECT + found_token.id, + found_user.id, + found_user.role, + EXTRACT(EPOCH FROM found_token.expires_at)::bigint; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create authenticate_strict function (same as authenticate for this test) +CREATE OR REPLACE FUNCTION "auth-test-private".authenticate_strict(token_value text) +RETURNS TABLE ( + token_id uuid, + user_id uuid, + role text, + exp bigint +) AS $$ +BEGIN + RETURN QUERY SELECT * FROM "auth-test-private".authenticate(token_value); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant execute on authenticate functions +GRANT EXECUTE ON FUNCTION "auth-test-private".authenticate(text) TO administrator, authenticated, anonymous; +GRANT EXECUTE ON FUNCTION "auth-test-private".authenticate_strict(text) TO administrator, authenticated, anonymous; diff --git a/graphql/server-test/__fixtures__/seed/auth-seed/setup.sql b/graphql/server-test/__fixtures__/seed/auth-seed/setup.sql new file mode 100644 index 000000000..cea1bd5b3 --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/auth-seed/setup.sql @@ -0,0 +1,302 @@ +-- Setup for auth-seed test scenario +-- Creates the required schemas, extensions, meta-schemas, and authentication functions + +-- Ensure uuid-ossp extension is available +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- Create uuid_nil function if not exists (returns the nil UUID) +CREATE OR REPLACE FUNCTION uuid_nil() RETURNS uuid AS $$ + SELECT '00000000-0000-0000-0000-000000000000'::uuid; +$$ LANGUAGE sql IMMUTABLE; + +-- Create required roles if they don't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'administrator') THEN + CREATE ROLE administrator; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anonymous') THEN + CREATE ROLE anonymous; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_user') THEN + CREATE ROLE app_user; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_admin') THEN + CREATE ROLE app_admin; + END IF; +END +$$; + +-- Create stamps schema for timestamp trigger if not exists +CREATE SCHEMA IF NOT EXISTS stamps; + +-- Create timestamps trigger function +CREATE OR REPLACE FUNCTION stamps.timestamps() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + NEW.created_at = COALESCE(NEW.created_at, now()); + END IF; + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create hostname domain if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'hostname') THEN + CREATE DOMAIN hostname AS text; + END IF; +END +$$; + +-- Create metaschema schemas +CREATE SCHEMA IF NOT EXISTS metaschema_public; +CREATE SCHEMA IF NOT EXISTS metaschema_modules_public; +CREATE SCHEMA IF NOT EXISTS services_public; + +-- Grant schema usage +GRANT USAGE ON SCHEMA metaschema_public TO administrator, authenticated, anonymous; +GRANT USAGE ON SCHEMA metaschema_modules_public TO administrator, authenticated, anonymous; +GRANT USAGE ON SCHEMA services_public TO administrator, authenticated, anonymous; + +-- Create object_category type if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid WHERE t.typname = 'object_category' AND n.nspname = 'metaschema_public') THEN + CREATE TYPE metaschema_public.object_category AS ENUM ('core', 'module', 'app'); + END IF; +END +$$; + +-- Create metaschema tables + +-- database table +CREATE TABLE IF NOT EXISTS metaschema_public.database ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id uuid, + schema_hash text, + schema_name text, + private_schema_name text, + name text, + label text, + hash uuid, + UNIQUE(schema_hash), + UNIQUE(schema_name), + UNIQUE(private_schema_name) +); + +-- schema table +CREATE TABLE IF NOT EXISTS metaschema_public.schema ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + name text NOT NULL, + schema_name text NOT NULL, + label text, + description text, + smart_tags jsonb, + category metaschema_public.object_category NOT NULL DEFAULT 'app', + module text NULL, + scope int NULL, + tags citext[] NOT NULL DEFAULT '{}', + is_public boolean NOT NULL DEFAULT TRUE, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + UNIQUE (database_id, name), + UNIQUE (schema_name) +); + +-- table table +CREATE TABLE IF NOT EXISTS metaschema_public.table ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + name text NOT NULL, + label text, + description text, + smart_tags jsonb, + category metaschema_public.object_category NOT NULL DEFAULT 'app', + module text NULL, + scope int NULL, + use_rls boolean NOT NULL DEFAULT FALSE, + timestamps boolean NOT NULL DEFAULT FALSE, + peoplestamps boolean NOT NULL DEFAULT FALSE, + plural_name text, + singular_name text, + tags citext[] NOT NULL DEFAULT '{}', + inherits_id uuid NULL, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT schema_fkey FOREIGN KEY (schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, + UNIQUE (database_id, name) +); + +-- field table +CREATE TABLE IF NOT EXISTS metaschema_public.field ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + table_id uuid NOT NULL, + name text NOT NULL, + label text, + description text, + smart_tags jsonb, + is_required boolean NOT NULL DEFAULT FALSE, + default_value text NULL DEFAULT NULL, + default_value_ast jsonb NULL DEFAULT NULL, + is_hidden boolean NOT NULL DEFAULT FALSE, + type citext NOT NULL, + field_order int NOT NULL DEFAULT 0, + regexp text DEFAULT NULL, + chk jsonb DEFAULT NULL, + chk_expr jsonb DEFAULT NULL, + min float DEFAULT NULL, + max float DEFAULT NULL, + tags citext[] NOT NULL DEFAULT '{}', + category metaschema_public.object_category NOT NULL DEFAULT 'app', + module text NULL, + scope int NULL, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT table_fkey FOREIGN KEY (table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + UNIQUE (table_id, name) +); + +-- primary_key_constraint table +CREATE TABLE IF NOT EXISTS metaschema_public.primary_key_constraint ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + table_id uuid NOT NULL, + name text NOT NULL, + type char(1) NOT NULL DEFAULT 'p', + field_ids uuid[] NOT NULL, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT table_fkey FOREIGN KEY (table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE +); + +-- check_constraint table +CREATE TABLE IF NOT EXISTS metaschema_public.check_constraint ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + table_id uuid NOT NULL, + name text NOT NULL, + type char(1) NOT NULL DEFAULT 'c', + field_ids uuid[] NOT NULL, + expr jsonb, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT table_fkey FOREIGN KEY (table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE +); + +-- services_public tables + +-- apis table +CREATE TABLE IF NOT EXISTS services_public.apis ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + name text NOT NULL, + dbname text NOT NULL DEFAULT current_database(), + role_name text NOT NULL DEFAULT 'authenticated', + anon_role text NOT NULL DEFAULT 'anonymous', + is_public boolean NOT NULL DEFAULT true, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + UNIQUE(database_id, name) +); + +COMMENT ON CONSTRAINT db_fkey ON services_public.apis IS E'@omit manyToMany'; +CREATE INDEX IF NOT EXISTS apis_database_id_idx ON services_public.apis (database_id); + +-- domains table +CREATE TABLE IF NOT EXISTS services_public.domains ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + api_id uuid, + site_id uuid, + subdomain hostname, + domain hostname, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT api_fkey FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE, + UNIQUE (subdomain, domain) +); + +COMMENT ON CONSTRAINT db_fkey ON services_public.domains IS E'@omit manyToMany'; +CREATE INDEX IF NOT EXISTS domains_database_id_idx ON services_public.domains (database_id); +COMMENT ON CONSTRAINT api_fkey ON services_public.domains IS E'@omit manyToMany'; +CREATE INDEX IF NOT EXISTS domains_api_id_idx ON services_public.domains (api_id); + +-- api_schemas table +CREATE TABLE IF NOT EXISTS services_public.api_schemas ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + api_id uuid NOT NULL, + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT schema_fkey FOREIGN KEY (schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, + CONSTRAINT api_fkey FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE, + UNIQUE(api_id, schema_id) +); + +-- api_extensions table (required by GraphQL ORM) +CREATE TABLE IF NOT EXISTS services_public.api_extensions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid, + api_id uuid, + schema_name text, + CONSTRAINT db_fkey2 FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT api_fkey2 FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE +); + +-- api_modules table (required by GraphQL ORM) +CREATE TABLE IF NOT EXISTS services_public.api_modules ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid, + api_id uuid, + name text, + data jsonb, + CONSTRAINT db_fkey3 FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT api_fkey3 FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE +); + +-- rls_module table +CREATE TABLE IF NOT EXISTS metaschema_modules_public.rls_module ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + api_id uuid NOT NULL DEFAULT uuid_nil(), + schema_id uuid NOT NULL DEFAULT uuid_nil(), + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), + tokens_table_id uuid NOT NULL DEFAULT uuid_nil(), + users_table_id uuid NOT NULL DEFAULT uuid_nil(), + authenticate text NOT NULL DEFAULT 'authenticate', + authenticate_strict text NOT NULL DEFAULT 'authenticate_strict', + "current_role" text NOT NULL DEFAULT 'current_user', + current_role_id text NOT NULL DEFAULT 'current_user_id', + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + CONSTRAINT api_fkey FOREIGN KEY (api_id) REFERENCES services_public.apis (id) ON DELETE CASCADE, + CONSTRAINT schema_fkey FOREIGN KEY (schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, + CONSTRAINT pschema_fkey FOREIGN KEY (private_schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, + CONSTRAINT tokens_table_fkey FOREIGN KEY (tokens_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT users_table_fkey FOREIGN KEY (users_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT api_id_uniq UNIQUE(api_id) +); + +COMMENT ON CONSTRAINT api_fkey ON metaschema_modules_public.rls_module IS E'@omit manyToMany'; +COMMENT ON CONSTRAINT schema_fkey ON metaschema_modules_public.rls_module IS E'@omit manyToMany'; +COMMENT ON CONSTRAINT pschema_fkey ON metaschema_modules_public.rls_module IS E'@omit manyToMany'; +COMMENT ON CONSTRAINT db_fkey ON metaschema_modules_public.rls_module IS E'@omit'; +COMMENT ON CONSTRAINT tokens_table_fkey ON metaschema_modules_public.rls_module IS E'@omit'; +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 ); + +-- 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; +GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.table TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.field TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.primary_key_constraint TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON metaschema_public.check_constraint TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.apis TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT, UPDATE, DELETE ON services_public.domains TO administrator, authenticated, anonymous; +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 metaschema_modules_public.rls_module TO administrator, authenticated, anonymous; diff --git a/graphql/server-test/__fixtures__/seed/auth-seed/test-data.sql b/graphql/server-test/__fixtures__/seed/auth-seed/test-data.sql new file mode 100644 index 000000000..e0859d7c9 --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/auth-seed/test-data.sql @@ -0,0 +1,126 @@ +-- Test data for auth-seed test scenario +-- Creates test users, tokens, and configures the RLS module + +-- Use replica mode to bypass triggers/constraints during seed +SET session_replication_role TO replica; + +-- Fixed UUIDs for test data +-- Using fixed UUIDs makes tests deterministic and easier to debug + +-- Database ID (same as simple-seed-services) +-- 80a2eaaf-f77e-4bfe-8506-df929ef1b8d9 + +-- Insert test database (matching simple-seed-services format) +INSERT INTO metaschema_public.database (id, owner_id, name, hash) +VALUES ( + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + NULL, + 'auth-test-db', + '425a0f10-0170-5760-85df-2a980c378224' +) ON CONFLICT (id) DO NOTHING; + +-- Insert schemas into metaschema +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, label, is_public) +VALUES + ('a1111111-1111-1111-1111-111111111111', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'auth-test-public', 'auth-test-public', 'Auth Test Public', true), + ('a2222222-2222-2222-2222-222222222222', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'auth-test-private', 'auth-test-private', 'Auth Test Private', false) +ON CONFLICT (id) DO NOTHING; + +-- Insert API +-- Note: dbname must use current_database() to match the dynamically created test database +INSERT INTO services_public.apis (id, database_id, name, dbname, role_name, anon_role, is_public) +VALUES ( + 'b1111111-1111-1111-1111-111111111111', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'auth-test-api', + current_database(), + 'authenticated', + 'anonymous', + true +) ON CONFLICT (id) DO NOTHING; + +-- Insert domain for the API +-- Note: URL parser sees "auth.test.constructive.io" as domain=constructive.io, subdomain=auth.test +INSERT INTO services_public.domains (id, database_id, api_id, subdomain, domain) +VALUES ( + 'c1111111-1111-1111-1111-111111111111', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'b1111111-1111-1111-1111-111111111111', + 'auth.test', + 'constructive.io' +) ON CONFLICT (id) DO NOTHING; + +-- Insert API schemas +INSERT INTO services_public.api_schemas (id, database_id, schema_id, api_id) +VALUES + ('d1111111-1111-1111-1111-111111111111', '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', 'a1111111-1111-1111-1111-111111111111', 'b1111111-1111-1111-1111-111111111111') +ON CONFLICT (id) DO NOTHING; + +-- Insert RLS module configuration +-- This links the API to the authenticate functions in auth-test-private schema +INSERT INTO metaschema_modules_public.rls_module ( + id, + database_id, + api_id, + schema_id, + private_schema_id, + authenticate, + authenticate_strict +) +VALUES ( + 'e1111111-1111-1111-1111-111111111111', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'b1111111-1111-1111-1111-111111111111', + 'a1111111-1111-1111-1111-111111111111', + 'a2222222-2222-2222-2222-222222222222', + 'authenticate', + 'authenticate_strict' +) ON CONFLICT (id) DO NOTHING; + +-- Insert test users +-- Note: UUIDs must use valid hex characters (0-9, a-f only) +INSERT INTO "auth-test-private".users (id, email, role) +VALUES + ('01111111-1111-1111-1111-111111111111', 'admin@test.com', 'administrator'), + ('02222222-2222-2222-2222-222222222222', 'user@test.com', 'authenticated'), + ('03333333-3333-3333-3333-333333333333', 'guest@test.com', 'anonymous') +ON CONFLICT (id) DO NOTHING; + +-- Insert test tokens +-- valid-admin-token: Valid token for admin user +INSERT INTO "auth-test-private".tokens (id, user_id, token, expires_at) +VALUES ( + '04111111-1111-1111-1111-111111111111', + '01111111-1111-1111-1111-111111111111', + 'valid-admin-token', + now() + interval '1 day' +) ON CONFLICT (id) DO NOTHING; + +-- valid-user-token: Valid token for regular user +INSERT INTO "auth-test-private".tokens (id, user_id, token, expires_at) +VALUES ( + '04222222-2222-2222-2222-222222222222', + '02222222-2222-2222-2222-222222222222', + 'valid-user-token', + now() + interval '1 day' +) ON CONFLICT (id) DO NOTHING; + +-- expired-token: Expired token +INSERT INTO "auth-test-private".tokens (id, user_id, token, expires_at) +VALUES ( + '04333333-3333-3333-3333-333333333333', + '02222222-2222-2222-2222-222222222222', + 'expired-token', + now() - interval '1 day' +) ON CONFLICT (id) DO NOTHING; + +-- Insert test items +INSERT INTO "auth-test-public".items (id, name, owner_id) +VALUES + ('05111111-1111-1111-1111-111111111111', 'Admin Item', '01111111-1111-1111-1111-111111111111'), + ('05222222-2222-2222-2222-222222222222', 'User Item', '02222222-2222-2222-2222-222222222222'), + ('05333333-3333-3333-3333-333333333333', 'Public Item', NULL) +ON CONFLICT (id) DO NOTHING; + +-- Reset replication role +SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__tests__/auth.integration.test.ts b/graphql/server-test/__tests__/auth.integration.test.ts new file mode 100644 index 000000000..41755a17f --- /dev/null +++ b/graphql/server-test/__tests__/auth.integration.test.ts @@ -0,0 +1,248 @@ +/** + * Bearer Token Authentication Integration Tests + * + * Tests the authentication middleware with a mocked RLS module and authenticate function. + * This verifies that bearer tokens are properly validated and the correct role is applied. + * + * Run tests: + * pnpm test -- --testPathPattern=auth.integration + */ + +import path from 'path'; +import { getConnections, seed } from '../src'; +import type { ServerInfo } from '../src/types'; +import type supertest from 'supertest'; + +jest.setTimeout(30000); + +const seedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); +const sql = (seedDir: string, file: string) => + path.join(seedRoot, seedDir, file); + +const schemas = ['auth-test-public']; +const servicesDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +const metaSchemas = [ + 'services_public', + 'metaschema_public', + 'metaschema_modules_public', +]; + +const seedFiles = [ + sql('auth-seed', 'setup.sql'), + sql('auth-seed', 'schema.sql'), + sql('auth-seed', 'test-data.sql'), +]; + +describe('Bearer Token Authentication', () => { + let server: ServerInfo; + let request: supertest.Agent; + let teardown: () => Promise; + + const postGraphQL = ( + payload: { query: string; variables?: Record }, + headers?: Record + ) => { + let req = request.post('/graphql'); + req = req.set('Host', 'auth.test.constructive.io'); + if (headers) { + for (const [header, value] of Object.entries(headers)) { + req = req.set(header, value); + } + } + return req.send(payload); + }; + + beforeAll(async () => { + ({ server, request, teardown } = await getConnections( + { + schemas, + authRole: 'anonymous', + server: { + api: { + enableServicesApi: true, + isPublic: true, + metaSchemas, + }, + }, + }, + [seed.sqlfile(seedFiles)] + )); + }); + + afterAll(async () => { + await teardown(); + }); + + describe('Unauthenticated requests', () => { + it('should allow queries without authentication', async () => { + const res = await postGraphQL({ + query: '{ items { nodes { id name } } }', + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.items.nodes).toBeInstanceOf(Array); + }); + + it('should use anonymous role when no token is provided', async () => { + const res = await postGraphQL({ + query: '{ __typename }', + }); + + expect(res.status).toBe(200); + expect(res.body.data.__typename).toBe('Query'); + }); + }); + + describe('Valid bearer token authentication', () => { + it('should authenticate with a valid admin token', async () => { + const res = await postGraphQL( + { + query: '{ items { nodes { id name } } }', + }, + { + Authorization: 'Bearer valid-admin-token', + } + ); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.items.nodes).toBeInstanceOf(Array); + }); + + it('should authenticate with a valid user token', async () => { + const res = await postGraphQL( + { + query: '{ items { nodes { id name } } }', + }, + { + Authorization: 'Bearer valid-user-token', + } + ); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.items.nodes).toBeInstanceOf(Array); + }); + + it('should query items with authentication', async () => { + const res = await postGraphQL( + { + query: `{ + items { + nodes { + id + name + ownerId + } + } + }`, + }, + { + Authorization: 'Bearer valid-user-token', + } + ); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.items.nodes).toHaveLength(3); + }); + }); + + describe('Invalid bearer token authentication', () => { + it('should reject an invalid token', async () => { + const res = await postGraphQL( + { + query: '{ items { nodes { id name } } }', + }, + { + Authorization: 'Bearer invalid-token-that-does-not-exist', + } + ); + + // Invalid tokens should return an UNAUTHENTICATED error + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].extensions.code).toBe('UNAUTHENTICATED'); + }); + + it('should reject an expired token', async () => { + const res = await postGraphQL( + { + query: '{ items { nodes { id name } } }', + }, + { + Authorization: 'Bearer expired-token', + } + ); + + // Expired tokens should return an UNAUTHENTICATED error + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].extensions.code).toBe('UNAUTHENTICATED'); + }); + + it('should handle malformed authorization header', async () => { + const res = await postGraphQL( + { + query: '{ __typename }', + }, + { + Authorization: 'NotBearer some-token', + } + ); + + expect(res.status).toBe(200); + expect(res.body.data.__typename).toBe('Query'); + }); + + it('should handle empty bearer token', async () => { + const res = await postGraphQL( + { + query: '{ __typename }', + }, + { + Authorization: 'Bearer ', + } + ); + + expect(res.status).toBe(200); + expect(res.body.data.__typename).toBe('Query'); + }); + }); + + describe('Mutations with authentication', () => { + it('should allow authenticated user to create an item', async () => { + const res = await postGraphQL( + { + query: `mutation($input: CreateItemInput!) { + createItem(input: $input) { + item { + id + name + } + } + }`, + variables: { + input: { + item: { + name: 'Test Item Created by Auth User', + }, + }, + }, + }, + { + Authorization: 'Bearer valid-user-token', + } + ); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.createItem.item.name).toBe('Test Item Created by Auth User'); + }); + }); +}); + +// Note: X-Api-Name header tests are skipped for now due to cache cleanup issues +// between test suites. The main authentication flow is tested above via domain lookup. +// TODO: Investigate cache cleanup between test suites to enable X-Api-Name tests diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 23d4f9777..13259910d 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; -import { ApiConfigResult, ApiError, ApiOptions, ApiStructure } from '../types'; +import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types'; import './types'; const log = new Logger('api'); @@ -20,6 +20,7 @@ const isDev = () => getNodeEnv() === 'development'; const DOMAIN_LOOKUP_SQL = ` SELECT + a.id as api_id, a.database_id, a.dbname, a.role_name, @@ -39,6 +40,7 @@ const DOMAIN_LOOKUP_SQL = ` const API_NAME_LOOKUP_SQL = ` SELECT + a.id as api_id, a.database_id, a.dbname, a.role_name, @@ -77,11 +79,23 @@ const API_LIST_SQL = ` LIMIT 100 `; +const RLS_MODULE_SQL = ` + SELECT + rm.authenticate, + rm.authenticate_strict, + ps.schema_name as private_schema_name + FROM metaschema_modules_public.rls_module rm + LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id + WHERE rm.api_id = $1 + LIMIT 1 +`; + // ============================================================================= // Types // ============================================================================= interface ApiRow { + api_id: string; database_id: string; dbname: string; role_name: string; @@ -90,6 +104,12 @@ interface ApiRow { schemas: string[]; } +interface RlsModuleRow { + authenticate: string | null; + authenticate_strict: string | null; + private_schema_name: string | null; +} + interface ApiListRow { id: string; database_id: string; @@ -164,12 +184,24 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { return baseKey; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions): ApiStructure => ({ +const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { + if (!row || !row.private_schema_name) return undefined; + return { + authenticate: row.authenticate ?? undefined, + authenticateStrict: row.authenticate_strict ?? undefined, + privateSchema: { + schemaName: row.private_schema_name, + }, + }; +}; + +const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({ dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', roleName: row.role_name || 'authenticated', schema: row.schemas || [], apiModules: [], + rlsModule: toRlsModule(rlsModuleRow ?? null), domains: [], databaseId: row.database_id, isPublic: row.is_public, @@ -208,13 +240,8 @@ const queryByDomain = async ( subdomain: string | null, isPublic: boolean ): Promise => { - try { - const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]); - return result.rows[0] ?? null; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return null; - throw err; - } + const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]); + return result.rows[0] ?? null; }; const queryByApiName = async ( @@ -223,23 +250,18 @@ const queryByApiName = async ( name: string, isPublic: boolean ): Promise => { - try { - const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]); - return result.rows[0] ?? null; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return null; - throw err; - } + const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]); + return result.rows[0] ?? null; }; const queryApiList = async (pool: Pool, isPublic: boolean): Promise => { - try { - const result = await pool.query(API_LIST_SQL, [isPublic]); - return result.rows; - } catch (err: unknown) { - if ((err as Error).message?.includes('does not exist')) return []; - throw err; - } + const result = await pool.query(API_LIST_SQL, [isPublic]); + return result.rows; +}; + +const queryRlsModule = async (pool: Pool, apiId: string): Promise => { + const result = await pool.query(RLS_MODULE_SQL, [apiId]); + return result.rows[0] ?? null; }; // ============================================================================= @@ -300,8 +322,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise => { const api = req.api; + log.info(`[auth] middleware called, api=${api ? 'present' : 'missing'}`); if (!api) { res.status(500).send('Missing API info'); return; @@ -29,21 +30,38 @@ export const createAuthenticateMiddleware = ( }); const rlsModule = api.rlsModule; + log.info( + `[auth] rlsModule=${rlsModule ? 'present' : 'missing'}, ` + + `authenticate=${rlsModule?.authenticate ?? 'none'}, ` + + `authenticateStrict=${rlsModule?.authenticateStrict ?? 'none'}, ` + + `privateSchema=${rlsModule?.privateSchema?.schemaName ?? 'none'}` + ); + if (!rlsModule) { - if (isDev()) log.debug('No RLS module configured, skipping auth'); + log.info('[auth] No RLS module configured, skipping auth'); return next(); } - const authFn = opts.server.strictAuth + const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate; + log.info( + `[auth] strictAuth=${opts.server?.strictAuth ?? false}, authFn=${authFn ?? 'none'}` + ); + if (authFn && rlsModule.privateSchema.schemaName) { const { authorization = '' } = req.headers; const [authType, authToken] = authorization.split(' '); let token: any = {}; + log.info( + `[auth] authorization header present=${!!authorization}, ` + + `authType=${authType ?? 'none'}, hasToken=${!!authToken}` + ); + if (authType?.toLowerCase() === 'bearer' && authToken) { + log.info('[auth] Processing bearer token authentication'); const context: Record = { 'jwt.claims.ip_address': req.clientIp, }; @@ -55,15 +73,21 @@ export const createAuthenticateMiddleware = ( context['jwt.claims.user_agent'] = req.get('User-Agent'); } + const authQuery = `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`; + log.info(`[auth] Executing auth query: ${authQuery}`); + try { const result = await pgQueryContext({ client: pool, context, - query: `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`, + query: authQuery, variables: [authToken], }); + log.info(`[auth] Query result: rowCount=${result?.rowCount}`); + if (result?.rowCount === 0) { + log.info('[auth] No rows returned, returning UNAUTHENTICATED'); res.status(200).json({ errors: [{ extensions: { code: 'UNAUTHENTICATED' } }], }); @@ -71,9 +95,9 @@ export const createAuthenticateMiddleware = ( } token = result.rows[0]; - if (isDev()) log.debug(`Auth success: role=${token.role}`); + log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`); } catch (e: any) { - log.error('Auth error:', e.message); + log.error('[auth] Auth error:', e.message); res.status(200).json({ errors: [ { @@ -86,9 +110,16 @@ export const createAuthenticateMiddleware = ( }); return; } + } else { + log.info('[auth] No bearer token provided, using anonymous auth'); } req.token = token; + } else { + log.info( + `[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` + + `privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}` + ); } next(); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index ff4b4fec1..df4dbb1f5 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -108,8 +108,9 @@ const createGraphileInstance = async ( }, grafast: { explain: process.env.NODE_ENV === 'development', - context: (ctx: unknown) => { - const req = (ctx as { node?: { req?: Request } } | undefined)?.node?.req; + context: (requestContext: Partial) => { + // In grafserv/express/v4, the request is available at requestContext.expressv4.req + const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req; const context: Record = {}; if (req) {