Repository navigation
feat: multi-user write protection, collaboration UI, and RLS audit suite #796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+42
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: find drizzle/migrations -maxdepth 2 -type f -print | sort
sed -n '35,55p' .github/workflows/rls-audit.yml
find drizzle -maxdepth 3 -type f \( -name '*journal*' -o -name '*.json' \) -print -exec sed -n '1,220p' {} \;
cat package.json
rg -n 'migrate|migration|0005_fix_chat_db_fk_normalization|0005_add_documents_rag' package.json bun.lock* drizzle tests-rls libRepository: QueueLab/QCX Length of output: 31419 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- migration line counts ---'
wc -l drizzle/migrations/*.sql lib/db/migrate.ts tests-rls/rls-matrix.test.ts
printf '%s\n' '--- canonical migrator ---'
cat -n lib/db/migrate.ts
printf '%s\n' '--- journal ---'
cat -n drizzle/migrations/meta/_journal.json
printf '%s\n' '--- migration headers and relevant statements ---'
for f in drizzle/migrations/*.sql; do
echo "### $f"
rg -n -i '^(--|create|alter|drop|insert|update|delete|grant|revoke|comment)|\b(policy|rls|constraint|index|function|trigger|role|auth\.uid|is_clerk|messages|chat_participants|prompt_generation|skyfi|document)' "$f" || true
done
printf '%s\n' '--- RLS test setup and schema references ---'
sed -n '1,130p' tests-rls/rls-matrix.test.ts
rg -n 'public\.(chats|messages|chat_participants|users)|documents|prompt_generation|skyfi|policy|rls' tests-rls/rls-matrix.test.tsRepository: QueueLab/QCX Length of output: 43031 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow ---'
cat -n .github/workflows/rls-audit.yml
printf '%s\n' '--- base migration ---'
cat -n drizzle/migrations/0000_sweet_metal_master.sql
printf '%s\n' '--- schema migration ---'
cat -n drizzle/migrations/0001_sync_schema_full.sql
printf '%s\n' '--- auth migration policy slices ---'
sed -n '145,270p' drizzle/migrations/0003_clerk_auth_sync.sql
printf '%s\n' '--- later migration SQL ---'
cat -n drizzle/migrations/0006_add_user_id_indexes.sql
cat -n drizzle/migrations/0007_create_prompt_generation_jobs.sql
cat -n drizzle/migrations/0008_allow_data_message_role.sql
cat -n drizzle/migrations/0009_add_skyfi_oauth_tokens.sql
cat -n drizzle/migrations/0010_add_skyfi_redirect_uri.sql
printf '%s\n' '--- migration command references ---'
rg -n -i 'EXECUTE_MIGRATIONS|bun (run )?db:migrate|migrationsFolder|CREATE INDEX CONCURRENTLY|0006_add_user_id_indexes' --glob '!bun.lock*' .Repository: QueueLab/QCX Length of output: 21279 Apply all journaled migrations before running the RLS matrix. The clean Use a CI migration step that follows 🧰 Tools🪛 Betterleaks (1.8.1)[high] 43-43: Detected a potential hardcoded password literal, which may expose account credentials. (generic-password) 🪛 zizmor (1.30.0)[warning] 1-53: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block (excessive-permissions) [warning] 10-53: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block (excessive-permissions) 🤖 Prompt for AI Agents |
||
|
|
||
| - name: Run RLS Authorization Matrix Suite | ||
| run: bun test tests-rls/rls-matrix.test.ts | ||
| env: | ||
| POSTGRES_URL: postgresql://postgres:postgrespassword@localhost:5432/postgres | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -777,7 +777,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { | |
| <UserMessage | ||
| content={messageContent} | ||
| chatId={chatId} | ||
| showShare={index === 0 && !isSharePage} | ||
| showShare={false} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Mobile users lose collaboration controls getUIStateFromAIState now disables the only inline ChatShare, leaving Chat.renderHeaderShare() as the sole remaining control. That portal targets a header hidden below the md breakpoint, while MobileIconsBar has no share target, so mobile users cannot invite or manage collaborators on any chat. Agent Prompt
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. New chats hide collaboration controls Chat checks sharing authorization once when a freshly generated ID is mounted, before saveChat creates the row, and caches the resulting denial in state keyed only by that unchanged ID. After the first response persists the chat, no authorization recheck occurs, so the new header control remains hidden until a remount; standalone sessions also skip the normal refresh path. Agent Prompt
|
||
| /> | ||
| ) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | ||
|
|
||
| /** | ||
| * Automated RLS Authorization Audit Suite | ||
| * | ||
| * Verifies Row-Level Security (RLS) enforcement across 4 primary tables: | ||
| * - chats | ||
| * - messages | ||
| * - chat_participants | ||
|
Comment on lines
+6
to
+9
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5. Fresh schemas ignore access policies The new matrix assumes policies protect users, chats, messages, and chat_participants, but repository migrations create those policies without ever enabling row-level security on the four tables. Fresh schemas therefore ignore every audited policy for any role with table privileges, so the suite's expected tenant isolation cannot hold even after its database setup is repaired. Agent Prompt
|
||
| * - users | ||
| * | ||
| * Across 4 distinct identities: | ||
| * 1. Owner: Creates and owns the chat | ||
| * 2. Collaborator: Added to chat_participants | ||
| * 3. Unrelated Authenticated User: Unrelated active user | ||
| * 4. Anonymous: No JWT claims set | ||
| * | ||
| * Checks SELECT, INSERT, UPDATE, DELETE for allowed and denied scenarios. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 7. Write regressions escape authorization tests rls-matrix.test.ts claims all four operations across all four tables, but users have no INSERT or DELETE cases, messages and participants have no UPDATE or DELETE cases, and the owner never performs chat DELETE. Broken write policies in any of those paths therefore remain invisible even when the database setup and denial assertions work. Agent Prompt
|
||
| */ | ||
|
|
||
| const postgresUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL; | ||
|
|
||
| describe('RLS Authorization Matrix Audit Suite', () => { | ||
| 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; | ||
|
Comment on lines
+79
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,130p' tests-rls/rls-matrix.test.ts
sed -n '1,90p' .github/workflows/rls-audit.yml
rg -n 'POSTGRES_URL|DATABASE_URL|isDbAvailable|ON_ERROR_STOP|psql' package.json bun.lockb bun.lock .github tests-rls drizzle.config.tsRepository: QueueLab/QCX Length of output: 8402 Make the RLS audit fail closed when database prerequisites fail.
GitHub Actions’ default Bash Fail closed by:
🤖 Prompt for AI Agents |
||
| } | ||
| }); | ||
|
|
||
| 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<void>) => { | ||
| 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(); | ||
|
Comment on lines
+201
to
+204
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Unauthorized inserts pass the audit Each denied INSERT places both the query and expect(...).toBe(0) inside a try, then treats every caught value as success. If an unauthorized INSERT returns a row, the failed assertion is caught just like an authorization error, so anonymous chat inserts and unauthorized message or participant inserts all pass the audit. Agent Prompt
|
||
| } | ||
| }); | ||
| }); | ||
|
|
||
| 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); | ||
|
Comment on lines
+263
to
+265
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,180p' drizzle/migrations/0004_fix_clerk_id_function.sql
sed -n '232,278p' tests-rls/rls-matrix.test.ts
rg -n 'messages.*POLICY|CREATE POLICY|WITH CHECK|CREATE TRIGGER|user_id.*messages|messages.*user_id' drizzle lib tests-rlsRepository: QueueLab/QCX Length of output: 15251 Authorization Bypass Reachability: External Prevent collaborators from spoofing The Add a forged- 🧰 Tools🪛 ast-grep (0.45.3)[error] 263-263: Avoid SQL injection (sql-injection-typescript) 🪛 OpenGrep (1.29.0)[ERROR] 264-264: SQL query built via string concatenation or template literal passed to query()/execute(). Use parameterized queries instead. (coderabbit.sql-injection.raw-query-concat-js) 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| 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`); | ||
|
Comment on lines
+299
to
+301
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 6. Self-enrollment bypass escapes the audit Chat Participants Table RLS Matrix labels unrelated inserts as denied, but its only negative actor is collaboratorId; unrelatedId is merely the target and is never run as the caller. The policy's OR public.is_clerk_user(user_id) accepts an unrelated caller inserting their own ID, after which participant-based chat and message policies grant access, so the advertised case is not audited. Agent Prompt
|
||
| expect(res.rows.length).toBe(0); | ||
| } catch (e) { | ||
| expect(e).toBeDefined(); | ||
| } | ||
|
Comment on lines
+299
to
+305
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,180p' drizzle/migrations/0004_fix_clerk_id_function.sql
sed -n '279,314p' tests-rls/rls-matrix.test.ts
rg -n 'chat_participants.*POLICY|CREATE POLICY|WITH CHECK|CREATE TRIGGER|is_clerk_user|chat_participants' drizzle lib tests-rlsRepository: QueueLab/QCX Length of output: 27605 Authorization Bypass Reachability: External Reject self-enrollment in The 🧰 Tools🪛 ast-grep (0.45.3)[error] 300-300: Avoid SQL injection (sql-injection-typescript) 🪛 OpenGrep (1.29.0)[ERROR] 301-301: SQL query built via string concatenation or template literal passed to query()/execute(). Use parameterized queries instead. (coderabbit.sql-injection.raw-query-concat-js) 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3. The authorization audit never runs
🐞 Bug☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools