Skip to content

Fix webhook org-scope precedence#1763

Open
riderx wants to merge 6 commits intomainfrom
riderx/fix-webhook-org-scope-ci
Open

Fix webhook org-scope precedence#1763
riderx wants to merge 6 commits intomainfrom
riderx/fix-webhook-org-scope-ci

Conversation

@riderx
Copy link
Member

@riderx riderx commented Mar 9, 2026

Summary (AI generated)

  • Added migration 20260308235013_webhook-org-scope-api-key-org-check.sql to enforce API-key-first authorization on webhook and webhook_delivery RLS paths, including org-scoped checks and safe update validation.
  • Added regression coverage in tests/hashed-apikey-rls.test.ts for webhook read precedence and unauthorized webhook_deliveries.org_id updates when both user auth and an out-of-scope key are present.

Motivation (AI generated)

  • API-key-limited org scope should be authoritative so an authenticated user cannot bypass it when a capgkey header is supplied.

Business Impact (AI generated)

  • Prevents cross-organization webhook data access and unauthorized webhook delivery mutations with mismatched user/key identity, reducing the risk of webhook data leakage.

Test Plan (AI generated)

  • bun lint:backend
  • bun test tests/hashed-apikey-rls.test.ts (targeted regression)

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced webhook security by implementing organization-scoped and API-key-scoped access control policies, enforcing permissions across all webhook and webhook delivery operations
  • Tests

    • Added comprehensive test suite validating API-key-based organization scope enforcement and access restrictions for webhooks and webhook deliveries

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

This pull request adds row-level security (RLS) policies to the webhooks and webhook_deliveries tables that enforce organization and API-key-scoped access control. It includes comprehensive test coverage to verify the policies correctly restrict access based on API key authorization and org scope.

Changes

Cohort / File(s) Summary
RLS Policy Migration
supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql
Implements RLS policies for webhooks and webhook_deliveries tables with org and API-key-scoped access control. Policies enforce permission checks via check_min_rights for SELECT, INSERT, UPDATE, and DELETE operations, with identity determined by API key header presence or auth context.
Access Control Test Suite
tests/hashed-apikey-rls.test.ts
Adds execWithAuthAndCapgkey helper function for authenticated SQL execution with API key context, and introduces test suite verifying that API-key-scoped webhook access respects org boundaries and blocks unauthorized org scope changes.

Sequence Diagram

sequenceDiagram
    actor Client as Client/API Caller
    participant RLS as RLS Policy<br/>(webhooks/deliveries)
    participant GetKey as get_apikey_header()
    participant GetIdent as get_identity_org_allowed()
    participant CheckRights as check_min_rights()
    participant DB as Database

    Client->>RLS: Query with API key header or auth context
    RLS->>GetKey: Retrieve API key from header
    alt API Key Present
        GetKey-->>RLS: API key found
        RLS->>GetIdent: Validate identity for org scope
        GetIdent-->>RLS: Identity or null
    else No API Key
        GetKey-->>RLS: null
        RLS->>GetIdent: Use auth.uid()
        GetIdent-->>RLS: User identity
    end
    RLS->>CheckRights: Verify admin rights for identity & org
    CheckRights-->>RLS: Permission granted/denied
    alt Permission Granted
        RLS->>DB: Allow query execution
        DB-->>Client: Return results
    else Permission Denied
        RLS->>Client: Block query
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hoppy webhooks now secured tight,
API keys scoped just right,
Org boundaries bounce and flex,
No sneaky peeks through the next,
RLS policies guard the way! 🔐

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description includes Summary, Motivation, and Business Impact sections with generated content, but lacks explicit Test Plan steps and Checklist completion details required by the template. Clarify the actual Test Plan steps taken and explicitly complete the Checklist section with specific box checkmarks for code style, testing, and manual verification.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix webhook org-scope precedence' directly summarizes the main change, which is enforcing API-key-scoped org precedence in webhook access controls.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/fix-webhook-org-scope-ci

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 SQLFluff (4.0.4)
supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql

User Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects:
ansi, athena, bigquery, clickhouse, databricks, db2, doris, duckdb, exasol, flink, greenplum, hive, impala, mariadb, materialize, mysql, oracle, postgres, redshift, snowflake, soql, sparksql, sqlite, starrocks, teradata, trino, tsql, vertica


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2bd8ba605

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +22 to +24
WHEN public.get_apikey_header() IS NOT NULL
THEN public.get_identity_org_allowed(
'{all,write,upload}'::public.key_mode[],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce API-key precedence with a key-only identity path

This branch is intended to make capgkey authoritative when both auth methods are present, but it still calls get_identity_org_allowed(...), which immediately returns auth.uid() whenever a JWT exists (see supabase/schemas/prod.sql around the early IF auth_uid IS NOT NULL THEN RETURN auth_uid). In mixed-auth requests, org-limited API key scope is therefore still bypassed by the user session, so the hardening in this migration does not actually prevent the cross-org access scenario it targets.

Useful? React with 👍 / 👎.

CASE
WHEN public.get_apikey_header() IS NOT NULL
THEN public.get_identity_org_allowed(
'{all,write,upload}'::public.key_mode[],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore read mode for webhook_delivery SELECT key checks

This SELECT policy requires 'read' rights but only allows API key modes '{all,write,upload}', so read-only keys can no longer resolve an identity and are denied even for in-scope org rows. Previous webhook-delivery policies accepted read mode, so this change regresses existing read-only integrations that query delivery records with read-scoped keys.

Useful? React with 👍 / 👎.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 9, 2026

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql (1)

21-28: Use (SELECT auth.uid()) in each fallback branch.

The ELSE auth.uid() branch here, and the repeated branches below, misses the repo’s required RLS pattern. Please switch those fallbacks to ELSE (SELECT auth.uid()).

♻️ Minimal fix
-            ELSE auth.uid()
+            ELSE (SELECT auth.uid())

As per coding guidelines, "Call auth.uid() only once in RLS policies using a subquery pattern instead of calling it multiple times".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql`
around lines 21 - 28, The CASE fallback branches use auth.uid() directly which
violates the RLS pattern; update each ELSE branch (the one returning auth.uid()
in the CASE blocks around public.get_apikey_header() /
public.get_identity_org_allowed(...) and any repeated similar CASEs) to return
the UID via a subquery: replace ELSE auth.uid() with ELSE (SELECT auth.uid()) so
the policy calls auth.uid() exactly once per the repo convention; ensure you
update every identical fallback occurrence in this SQL snippet.
tests/hashed-apikey-rls.test.ts (1)

573-614: Seed this suite per test so it can use it.concurrent().

These cases share one API key, one webhook, and one delivery row from beforeAll, and Line 637 mutates that shared resource. That keeps the suite order-dependent and blocks the repo’s required parallel-test pattern. Move setup/cleanup into per-test fixtures and switch the cases to it.concurrent().

As per coding guidelines, "Design all tests for parallel execution; use it.concurrent() instead of it() to maximize parallelism; create dedicated seed data for tests that modify shared resources to avoid conflicts".

Also applies to: 616-650

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hashed-apikey-rls.test.ts` around lines 573 - 614, The tests currently
share mutable seeded state created in beforeAll/afterAll (limitedKey, webhookId,
deliveryId via createHashedApiKey, pool.query inserts, and deleteApiKey) which
prevents safe use of it.concurrent(); change the suite to create and tear down
those resources per test (use beforeEach/afterEach or seed inside each test) so
each test gets its own limitedKey, webhook row and webhook_delivery row, and
then convert the relevant tests to it.concurrent() so they can run in parallel
without mutating shared state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/hashed-apikey-rls.test.ts`:
- Line 568: The describe block title "Webhook and webhook_delivery RLS with
API-key org scope precedence" must be lowercased to satisfy
test/prefer-lowercase-title; update the string passed to the describe call (the
describe that currently begins with "Webhook and webhook_delivery RLS with
API-key org scope precedence") to an all-lowercase version (e.g., "webhook and
webhook_delivery rls with api-key org scope precedence") so the suite name
conforms to the lowercase rule.
- Around line 635-649: The test uses limitedKey (scoped to 0000...0000) so the
UPDATE is rejected before Postgres reaches the WITH CHECK; modify the test to
create or reuse a second API key scoped to ORG_ID_RLS (e.g., limitedKeyOrg or
limitedKeyForRls) and call execWithAuthAndCapgkey with that key to attempt to
update the row's org_id to ORG_ID_2, then assert rowCount is 0 and org_id
remains ORG_ID_RLS; keep the existing variables deliveryId, ORG_ID_RLS and
ORG_ID_2 and only change which key is used for the failing UPDATE so the WITH
CHECK path in the migration is exercised.

---

Nitpick comments:
In `@supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql`:
- Around line 21-28: The CASE fallback branches use auth.uid() directly which
violates the RLS pattern; update each ELSE branch (the one returning auth.uid()
in the CASE blocks around public.get_apikey_header() /
public.get_identity_org_allowed(...) and any repeated similar CASEs) to return
the UID via a subquery: replace ELSE auth.uid() with ELSE (SELECT auth.uid()) so
the policy calls auth.uid() exactly once per the repo convention; ensure you
update every identical fallback occurrence in this SQL snippet.

In `@tests/hashed-apikey-rls.test.ts`:
- Around line 573-614: The tests currently share mutable seeded state created in
beforeAll/afterAll (limitedKey, webhookId, deliveryId via createHashedApiKey,
pool.query inserts, and deleteApiKey) which prevents safe use of
it.concurrent(); change the suite to create and tear down those resources per
test (use beforeEach/afterEach or seed inside each test) so each test gets its
own limitedKey, webhook row and webhook_delivery row, and then convert the
relevant tests to it.concurrent() so they can run in parallel without mutating
shared state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7585ba63-1f1c-4a3d-a646-61ade945f7c0

📥 Commits

Reviewing files that changed from the base of the PR and between 2af3a08 and 88a0b02.

📒 Files selected for processing (2)
  • supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql
  • tests/hashed-apikey-rls.test.ts

})
})

describe('Webhook and webhook_delivery RLS with API-key org scope precedence', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Lowercase this describe title.

test/prefer-lowercase-title will fail on the current suite name.

🧰 Tools
🪛 ESLint

[error] 568-568: describes should begin with lowercase

(test/prefer-lowercase-title)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hashed-apikey-rls.test.ts` at line 568, The describe block title
"Webhook and webhook_delivery RLS with API-key org scope precedence" must be
lowercased to satisfy test/prefer-lowercase-title; update the string passed to
the describe call (the describe that currently begins with "Webhook and
webhook_delivery RLS with API-key org scope precedence") to an all-lowercase
version (e.g., "webhook and webhook_delivery rls with api-key org scope
precedence") so the suite name conforms to the lowercase rule.

Comment on lines +635 to +649
it('prevents webhook_delivery org_id changes when update payload org_id is unauthorized', async () => {
const updatedRows = await execWithAuthAndCapgkey(
'UPDATE public.webhook_deliveries SET org_id = $1 WHERE id = $2',
USER_ID_RLS,
limitedKey.key,
[ORG_ID_2, deliveryId],
)

const { rows } = await pool.query(
'SELECT org_id FROM public.webhook_deliveries WHERE id = $1',
[deliveryId],
)

expect(updatedRows.rowCount).toBe(0)
expect(rows[0].org_id).toBe(ORG_ID_RLS)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This regression never reaches the new update WITH CHECK.

Because limitedKey is scoped to 00000000-0000-0000-0000-000000000000 while the row still belongs to ORG_ID_RLS, Postgres rejects the statement on the existing row first. So the WITH CHECK block in supabase/migrations/20260308235013_webhook-org-scope-api-key-org-check.sql at Lines 180-195 is not exercised here, and a bug there would still pass this test.

Use a second key limited to ORG_ID_RLS for this case, then try to move the row to ORG_ID_2.

🧪 Minimal coverage fix
 describe('Webhook and webhook_delivery RLS with API-key org scope precedence', () => {
   let limitedKey: { id: number, key: string, key_hash: string }
+  let scopedKey: { id: number, key: string, key_hash: string }
   let webhookId: string
   let deliveryId: string

   beforeAll(async () => {
     limitedKey = await createHashedApiKey('rls-webhook-org-scope-key', 'all')
+    scopedKey = await createHashedApiKey('rls-webhook-org-scope-update-key', 'all')
     await pool.query(
       `UPDATE public.apikeys SET limited_to_orgs = $1 WHERE id = $2`,
       [['00000000-0000-0000-0000-000000000000'], limitedKey.id],
     )
+    await pool.query(
+      `UPDATE public.apikeys SET limited_to_orgs = $1 WHERE id = $2`,
+      [[ORG_ID_RLS], scopedKey.id],
+    )
     ...
   })

   afterAll(async () => {
     ...
     await deleteApiKey(limitedKey.id)
+    await deleteApiKey(scopedKey.id)
   })

   it('prevents webhook_delivery org_id changes when update payload org_id is unauthorized', async () => {
     const updatedRows = await execWithAuthAndCapgkey(
       'UPDATE public.webhook_deliveries SET org_id = $1 WHERE id = $2',
       USER_ID_RLS,
-      limitedKey.key,
+      scopedKey.key,
       [ORG_ID_2, deliveryId],
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hashed-apikey-rls.test.ts` around lines 635 - 649, The test uses
limitedKey (scoped to 0000...0000) so the UPDATE is rejected before Postgres
reaches the WITH CHECK; modify the test to create or reuse a second API key
scoped to ORG_ID_RLS (e.g., limitedKeyOrg or limitedKeyForRls) and call
execWithAuthAndCapgkey with that key to attempt to update the row's org_id to
ORG_ID_2, then assert rowCount is 0 and org_id remains ORG_ID_RLS; keep the
existing variables deliveryId, ORG_ID_RLS and ORG_ID_2 and only change which key
is used for the failing UPDATE so the WITH CHECK path in the migration is
exercised.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant