diff --git a/.github/workflows/rls-audit.yml b/.github/workflows/rls-audit.yml new file mode 100644 index 00000000..0b565a51 --- /dev/null +++ b/.github/workflows/rls-audit.yml @@ -0,0 +1,52 @@ +name: RLS Authorization Audit + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + rls-audit: + name: RLS Authorization Audit Suite + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgrespassword + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Apply Base Schema & Migrations + run: | + export PGPASSWORD=postgrespassword + psql -h localhost -U postgres -d postgres -f drizzle/migrations/0000_sweet_metal_master.sql + psql -h localhost -U postgres -d postgres -f drizzle/migrations/0003_clerk_auth_sync.sql + psql -h localhost -U postgres -d postgres -f drizzle/migrations/0004_fix_clerk_id_function.sql + psql -h localhost -U postgres -d postgres -f drizzle/migrations/0005_add_documents_rag.sql + + - name: Run RLS Authorization Matrix Suite + run: bun test tests-rls/rls-matrix.test.ts + env: + POSTGRES_URL: postgresql://postgres:postgrespassword@localhost:5432/postgres diff --git a/app/actions.tsx b/app/actions.tsx index 03beb14f..8fa5e51a 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -777,7 +777,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } diff --git a/lib/actions/chat.ts b/lib/actions/chat.ts index 5e6adf2c..0850fe8a 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -201,18 +201,9 @@ export async function saveChat(chat: OldChatType, userId: string): Promise { + let client: any = null; + let isDbAvailable = false; + + // Test UUIDs + const ownerId = '11111111-1111-4111-a111-111111111111'; + const collaboratorId = '22222222-2222-4222-a222-222222222222'; + const unrelatedId = '33333333-3333-4333-a333-333333333333'; + + const ownerClerkId = 'user_clerk_owner_111'; + const collaboratorClerkId = 'user_clerk_collaborator_222'; + const unrelatedClerkId = 'user_clerk_unrelated_333'; + + const chatId = '44444444-4444-4444-a444-444444444444'; + const messageId = '55555555-5555-4555-a555-555555555555'; + const ownerParticipantId = '66666666-6666-4666-a666-666666666666'; + const collaboratorParticipantId = '77777777-7777-4777-a777-777777777777'; + + beforeAll(async () => { + if (!postgresUrl) { + console.warn('POSTGRES_URL not set; skipping database-bound RLS tests'); + return; + } + + try { + const pgModule = await import('pg'); + const ClientClass = pgModule.Client || pgModule.default?.Client; + if (!ClientClass) return; + + client = new ClientClass({ connectionString: postgresUrl }); + await client.connect(); + isDbAvailable = true; + + // Seed identities & test data under superuser/app role + await client.query(` + INSERT INTO public.users (id, clerk_user_id, email, first_name, last_name) + VALUES + ('${ownerId}', '${ownerClerkId}', 'owner@test.com', 'Owner', 'User'), + ('${collaboratorId}', '${collaboratorClerkId}', 'collaborator@test.com', 'Collaborator', 'User'), + ('${unrelatedId}', '${unrelatedClerkId}', 'unrelated@test.com', 'Unrelated', 'User') + ON CONFLICT (id) DO UPDATE SET clerk_user_id = EXCLUDED.clerk_user_id; + + INSERT INTO public.chats (id, user_id, title, visibility) + VALUES ('${chatId}', '${ownerId}', 'Audit Test Chat', 'private') + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.chat_participants (id, chat_id, user_id, role) + VALUES + ('${ownerParticipantId}', '${chatId}', '${ownerId}', 'owner'), + ('${collaboratorParticipantId}', '${chatId}', '${collaboratorId}', 'collaborator') + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.messages (id, chat_id, user_id, role, content) + VALUES ('${messageId}', '${chatId}', '${ownerId}', 'user', 'Audit Message') + ON CONFLICT (id) DO NOTHING; + `); + } catch (err) { + console.warn('Database connection or seeding failed; skipping RLS DB assertions:', err); + if (client) { + await client.end().catch(() => {}); + client = null; + } + isDbAvailable = false; + } + }); + + afterAll(async () => { + if (client) { + try { + await client.query(` + DELETE FROM public.messages WHERE id = '${messageId}'; + DELETE FROM public.chat_participants WHERE chat_id = '${chatId}'; + DELETE FROM public.chats WHERE id = '${chatId}'; + DELETE FROM public.users WHERE id IN ('${ownerId}', '${collaboratorId}', '${unrelatedId}'); + `); + } catch (e) { + // Ignore cleanup errors + } + await client.end(); + } + }); + + const runAsUser = async (userId: string | null, callback: () => Promise) => { + if (!client || !isDbAvailable) return; + await client.query('BEGIN'); + try { + if (userId) { + await client.query(`SET LOCAL ROLE authenticated`); + await client.query(`SET LOCAL request.jwt.claims = '{"sub": "${userId}"}'`); + } else { + await client.query(`SET LOCAL ROLE anon`); + await client.query(`RESET request.jwt.claims`); + } + await callback(); + } finally { + await client.query('ROLLBACK'); + } + }; + + test('Suite structural setup', () => { + expect(ownerId).toBeDefined(); + expect(collaboratorId).toBeDefined(); + expect(unrelatedId).toBeDefined(); + }); + + describe('Users Table RLS Matrix', () => { + test('SELECT: users can select own profile, others cannot', async () => { + if (!isDbAvailable || !client) return; + + await runAsUser(ownerId, async () => { + const res = await client.query(`SELECT * FROM public.users WHERE id = '${ownerId}'`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(unrelatedId, async () => { + const res = await client.query(`SELECT * FROM public.users WHERE id = '${ownerId}'`); + expect(res.rows.length).toBe(0); + }); + + await runAsUser(null, async () => { + const res = await client.query(`SELECT * FROM public.users WHERE id = '${ownerId}'`); + expect(res.rows.length).toBe(0); + }); + }); + + test('UPDATE: users can update own profile, others cannot', async () => { + if (!isDbAvailable || !client) return; + + await runAsUser(ownerId, async () => { + const res = await client.query(`UPDATE public.users SET first_name = 'Updated' WHERE id = '${ownerId}' RETURNING id`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(unrelatedId, async () => { + const res = await client.query(`UPDATE public.users SET first_name = 'Hacked' WHERE id = '${ownerId}' RETURNING id`); + expect(res.rows.length).toBe(0); + }); + }); + }); + + describe('Chats Table RLS Matrix', () => { + test('SELECT: owner and collaborator can select chat, unrelated and anon cannot', async () => { + if (!isDbAvailable || !client) return; + + await runAsUser(ownerId, async () => { + const res = await client.query(`SELECT * FROM public.chats WHERE id = '${chatId}'`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(collaboratorId, async () => { + const res = await client.query(`SELECT * FROM public.chats WHERE id = '${chatId}'`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(unrelatedId, async () => { + const res = await client.query(`SELECT * FROM public.chats WHERE id = '${chatId}'`); + expect(res.rows.length).toBe(0); + }); + + await runAsUser(null, async () => { + const res = await client.query(`SELECT * FROM public.chats WHERE id = '${chatId}'`); + expect(res.rows.length).toBe(0); + }); + }); + + test('INSERT: authenticated users can insert own chat, anon cannot', async () => { + if (!isDbAvailable || !client) return; + + const newChatId = '88888888-8888-4888-a888-888888888888'; + + await runAsUser(ownerId, async () => { + const res = await client.query(`INSERT INTO public.chats (id, user_id, title) VALUES ('${newChatId}', '${ownerId}', 'New Chat') RETURNING id`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(null, async () => { + try { + const res = await client.query(`INSERT INTO public.chats (id, user_id, title) VALUES ('${newChatId}', '${ownerId}', 'Anon Chat') RETURNING id`); + expect(res.rows.length).toBe(0); + } catch (e) { + // Expected RLS violation error + expect(e).toBeDefined(); + } + }); + }); + + test('UPDATE/DELETE: owner can update/delete chat, collaborator/unrelated cannot', async () => { + if (!isDbAvailable || !client) return; + + // Collaborator attempt + await runAsUser(collaboratorId, async () => { + const res = await client.query(`UPDATE public.chats SET title = 'Collaborator Title' WHERE id = '${chatId}' RETURNING id`); + expect(res.rows.length).toBe(0); + }); + + // Unrelated attempt + await runAsUser(unrelatedId, async () => { + const res = await client.query(`DELETE FROM public.chats WHERE id = '${chatId}' RETURNING id`); + expect(res.rows.length).toBe(0); + }); + + // Owner attempt + await runAsUser(ownerId, async () => { + const res = await client.query(`UPDATE public.chats SET title = 'Owner Title' WHERE id = '${chatId}' RETURNING id`); + expect(res.rows.length).toBe(1); + }); + }); + }); + + describe('Messages Table RLS Matrix', () => { + test('SELECT: owner and collaborator can select messages, unrelated and anon cannot', async () => { + if (!isDbAvailable || !client) return; + + await runAsUser(ownerId, async () => { + const res = await client.query(`SELECT * FROM public.messages WHERE id = '${messageId}'`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(collaboratorId, async () => { + const res = await client.query(`SELECT * FROM public.messages WHERE id = '${messageId}'`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(unrelatedId, async () => { + const res = await client.query(`SELECT * FROM public.messages WHERE id = '${messageId}'`); + expect(res.rows.length).toBe(0); + }); + + await runAsUser(null, async () => { + const res = await client.query(`SELECT * FROM public.messages WHERE id = '${messageId}'`); + expect(res.rows.length).toBe(0); + }); + }); + + test('INSERT: owner and collaborator can insert messages to chat, unrelated/anon cannot', async () => { + if (!isDbAvailable || !client) return; + + const newMsgId1 = '99999999-9999-4999-a999-999999999991'; + const newMsgId2 = '99999999-9999-4999-a999-999999999992'; + + await runAsUser(collaboratorId, async () => { + const res = await client.query(`INSERT INTO public.messages (id, chat_id, user_id, role, content) VALUES ('${newMsgId1}', '${chatId}', '${collaboratorId}', 'user', 'Collab Msg') RETURNING id`); + expect(res.rows.length).toBe(1); + }); + + await runAsUser(unrelatedId, async () => { + try { + const res = await client.query(`INSERT INTO public.messages (id, chat_id, user_id, role, content) VALUES ('${newMsgId2}', '${chatId}', '${unrelatedId}', 'user', 'Unrelated Msg') RETURNING id`); + expect(res.rows.length).toBe(0); + } catch (e) { + expect(e).toBeDefined(); + } + }); + }); + }); + + describe('Chat Participants Table RLS Matrix', () => { + test('SELECT: participants can view chat participants, non-members cannot', async () => { + if (!isDbAvailable || !client) return; + + await runAsUser(collaboratorId, async () => { + const res = await client.query(`SELECT * FROM public.chat_participants WHERE chat_id = '${chatId}'`); + expect(res.rows.length).toBeGreaterThanOrEqual(1); + }); + + await runAsUser(unrelatedId, async () => { + const res = await client.query(`SELECT * FROM public.chat_participants WHERE chat_id = '${chatId}'`); + expect(res.rows.length).toBe(0); + }); + }); + + test('INSERT: owner can insert participant, collaborator/unrelated cannot', async () => { + if (!isDbAvailable || !client) return; + + const newPartId = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + + await runAsUser(collaboratorId, async () => { + try { + const res = await client.query(`INSERT INTO public.chat_participants (id, chat_id, user_id, role) VALUES ('${newPartId}', '${chatId}', '${unrelatedId}', 'collaborator') RETURNING id`); + expect(res.rows.length).toBe(0); + } catch (e) { + expect(e).toBeDefined(); + } + }); + + await runAsUser(ownerId, async () => { + const res = await client.query(`INSERT INTO public.chat_participants (id, chat_id, user_id, role) VALUES ('${newPartId}', '${chatId}', '${unrelatedId}', 'collaborator') RETURNING id`); + expect(res.rows.length).toBe(1); + }); + }); + }); +});