Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions supabase/migrations/20260511061700_harden_webhook_rls_apikey_scope.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
-- =============================================================================
-- Harden webhook RLS API-key identity resolution.
--
-- Route-level webhook handlers already reject app-scoped API keys and enforce
-- the org required-expiration API-key policy before managing org-level webhooks.
-- Direct PostgREST access to webhooks/webhook_deliveries must fail closed with
-- the same constraints without changing non-webhook callers of the shared helper.
-- =============================================================================

CREATE OR REPLACE FUNCTION "public"."get_identity_org_allowed_apikey_only" (
"keymode" "public"."key_mode" [],
"org_id" uuid
) RETURNS uuid
LANGUAGE "plpgsql"
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
api_key_text text;
api_key record;
BEGIN
SELECT "public"."get_apikey_header"() into api_key_text;

-- No api key found in headers, return
IF api_key_text IS NULL THEN
PERFORM public.pg_log('deny: IDENTITY_ORG_NO_AUTH', jsonb_build_object('org_id', org_id));

Check failure on line 26 in supabase/migrations/20260511061700_harden_webhook_rls_apikey_scope.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 10 times.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4Wa3cojq8LwMf3yxCc&open=AZ4Wa3cojq8LwMf3yxCc&pullRequest=2194
RETURN NULL;
END IF;

-- Use find_apikey_by_value to support both plain and hashed keys
SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key;

-- Check if key was found (api_key.id will be NULL if no match) and mode matches
IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN
-- Check if key is expired
IF public.is_apikey_expired(api_key.expires_at) THEN
PERFORM public.pg_log('deny: IDENTITY_ORG_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id));

Check failure on line 37 in supabase/migrations/20260511061700_harden_webhook_rls_apikey_scope.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 4 times.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4Wd8twTLrgK21Y_mG3&open=AZ4Wd8twTLrgK21Y_mG3&pullRequest=2194
RETURN NULL;
END IF;

-- Check org restrictions
IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN
IF NOT (org_id = ANY(api_key.limited_to_orgs)) THEN
PERFORM public.pg_log('deny: IDENTITY_ORG_UNALLOWED', jsonb_build_object('org_id', org_id));
RETURN NULL;
END IF;
END IF;

RETURN api_key.user_id;
END IF;

PERFORM public.pg_log('deny: IDENTITY_ORG_NO_MATCH', jsonb_build_object('org_id', org_id));
RETURN NULL;
END;
$$;

ALTER FUNCTION "public"."get_identity_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) OWNER TO "postgres";

CREATE OR REPLACE FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" (
"keymode" "public"."key_mode" [],
"org_id" uuid
) RETURNS uuid
LANGUAGE "plpgsql"
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
api_key_text text;
api_key record;
v_require_apikey_expiration boolean := false;
BEGIN
SELECT "public"."get_apikey_header"() into api_key_text;

IF api_key_text IS NULL THEN
PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_NO_AUTH', jsonb_build_object('org_id', org_id));
RETURN NULL;
END IF;

SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key;

IF api_key.id IS NOT NULL AND api_key.mode = ANY(keymode) THEN
-- Webhooks are organization-level resources. App-scoped API keys must not
-- satisfy direct table policies even when their owner is an org admin.
IF COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 THEN
PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_APP_SCOPED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id));
RETURN NULL;
END IF;

IF public.is_apikey_expired(api_key.expires_at) THEN
PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id));
RETURN NULL;
END IF;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

SELECT o.require_apikey_expiration
INTO v_require_apikey_expiration
FROM public.orgs o
WHERE o.id = get_identity_webhook_org_allowed_apikey_only.org_id;

IF COALESCE(v_require_apikey_expiration, false) AND api_key.expires_at IS NULL THEN
PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_EXPIRATION_REQUIRED', jsonb_build_object('key_id', api_key.id, 'org_id', org_id));
RETURN NULL;
END IF;

IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN
IF NOT (get_identity_webhook_org_allowed_apikey_only.org_id = ANY(api_key.limited_to_orgs)) THEN
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_UNALLOWED', jsonb_build_object('org_id', org_id));
RETURN NULL;
END IF;
END IF;

RETURN api_key.user_id;
END IF;

PERFORM public.pg_log('deny: WEBHOOK_IDENTITY_ORG_NO_MATCH', jsonb_build_object('org_id', org_id));
RETURN NULL;
END;
$$;

ALTER FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) OWNER TO "postgres";
REVOKE ALL ON FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) TO "anon";
GRANT EXECUTE ON FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) TO "authenticated";
GRANT EXECUTE ON FUNCTION "public"."get_identity_webhook_org_allowed_apikey_only" ("keymode" "public"."key_mode" [], "org_id" uuid) TO "service_role";

CREATE OR REPLACE FUNCTION "public"."check_webhook_min_rights" (
"min_right" "public"."user_min_right",
"keymode" "public"."key_mode" [],
"org_id" uuid,
"app_id" character varying,
"channel_id" bigint
) RETURNS boolean
LANGUAGE "plpgsql"
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_apikey text;
v_user_id uuid;
BEGIN
SELECT public.get_apikey_header() INTO v_apikey;

IF v_apikey IS NOT NULL THEN
SELECT public.get_identity_webhook_org_allowed_apikey_only(
check_webhook_min_rights.keymode,
check_webhook_min_rights.org_id
) INTO v_user_id;

IF v_user_id IS NULL THEN
RETURN false;
END IF;
ELSE
SELECT auth.uid() INTO v_user_id;
END IF;

RETURN public.check_min_rights(
min_right,
v_user_id,
org_id,
app_id,
channel_id
);
END;
$$;

ALTER FUNCTION "public"."check_webhook_min_rights" ("min_right" "public"."user_min_right", "keymode" "public"."key_mode" [], "org_id" uuid, "app_id" character varying, "channel_id" bigint) OWNER TO "postgres";
REVOKE ALL ON FUNCTION "public"."check_webhook_min_rights" ("min_right" "public"."user_min_right", "keymode" "public"."key_mode" [], "org_id" uuid, "app_id" character varying, "channel_id" bigint) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION "public"."check_webhook_min_rights" ("min_right" "public"."user_min_right", "keymode" "public"."key_mode" [], "org_id" uuid, "app_id" character varying, "channel_id" bigint) TO "anon";
GRANT EXECUTE ON FUNCTION "public"."check_webhook_min_rights" ("min_right" "public"."user_min_right", "keymode" "public"."key_mode" [], "org_id" uuid, "app_id" character varying, "channel_id" bigint) TO "authenticated";
GRANT EXECUTE ON FUNCTION "public"."check_webhook_min_rights" ("min_right" "public"."user_min_right", "keymode" "public"."key_mode" [], "org_id" uuid, "app_id" character varying, "channel_id" bigint) TO "service_role";

DROP POLICY IF EXISTS "Allow admin to select webhooks" ON public.webhooks;
DROP POLICY IF EXISTS "Allow admin to insert webhooks" ON public.webhooks;
DROP POLICY IF EXISTS "Allow admin to update webhooks" ON public.webhooks;
DROP POLICY IF EXISTS "Allow admin to delete webhooks" ON public.webhooks;

CREATE POLICY "Allow admin to select webhooks"
ON public.webhooks
FOR SELECT
TO authenticated, anon
USING (
public.check_webhook_min_rights(
'admin'::public.user_min_right,

Check failure on line 182 in supabase/migrations/20260511061700_harden_webhook_rls_apikey_scope.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 7 times.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4Wd8twTLrgK21Y_mG2&open=AZ4Wd8twTLrgK21Y_mG2&pullRequest=2194
'{all,write,upload}'::public.key_mode [],

Check failure on line 183 in supabase/migrations/20260511061700_harden_webhook_rls_apikey_scope.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 7 times.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4Wd8twTLrgK21Y_mG1&open=AZ4Wd8twTLrgK21Y_mG1&pullRequest=2194
org_id,
NULL::character varying,
NULL::bigint
)
);

CREATE POLICY "Allow admin to insert webhooks"
ON public.webhooks
FOR INSERT
TO authenticated, anon
WITH CHECK (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);

CREATE POLICY "Allow admin to update webhooks"
ON public.webhooks
FOR UPDATE
TO authenticated, anon
USING (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
)
WITH CHECK (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);

CREATE POLICY "Allow admin to delete webhooks"
ON public.webhooks
FOR DELETE
TO authenticated, anon
USING (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);

DROP POLICY IF EXISTS "Allow org members to select webhook_deliveries" ON public.webhook_deliveries;
DROP POLICY IF EXISTS "Allow admin to insert webhook_deliveries" ON public.webhook_deliveries;
DROP POLICY IF EXISTS "Allow admin to update webhook_deliveries" ON public.webhook_deliveries;

CREATE POLICY "Allow org members to select webhook_deliveries"
ON public.webhook_deliveries
FOR SELECT
TO authenticated, anon
USING (
public.check_webhook_min_rights(
'read'::public.user_min_right,
'{read,write,upload,all}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);

CREATE POLICY "Allow admin to insert webhook_deliveries"
ON public.webhook_deliveries
FOR INSERT
TO authenticated, anon
WITH CHECK (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);

CREATE POLICY "Allow admin to update webhook_deliveries"
ON public.webhook_deliveries
FOR UPDATE
TO authenticated, anon
USING (
public.check_webhook_min_rights(
'admin'::public.user_min_right,
'{all,write,upload}'::public.key_mode [],
org_id,
NULL::character varying,
NULL::bigint
)
);
21 changes: 19 additions & 2 deletions tests/webhooks-apikey-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getEndpointUrl, getSupabaseClient, USER_ID_2 } from './test-utils.ts'
import { getEndpointUrl, getSupabaseClient, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_ID_2 } from './test-utils.ts'

const globalId = randomUUID()
const numericGlobalId = Number.parseInt(globalId.replaceAll('-', '').slice(0, 12), 16)
Expand Down Expand Up @@ -129,7 +129,7 @@ beforeAll(async () => {

const { error: policyError } = await supabase.from('orgs').update({
require_apikey_expiration: true,
max_apikey_expiration_days: 30,
max_apikey_expiration_days: null,
}).eq('id', policyOrgId)
if (policyError)
throw policyError
Expand Down Expand Up @@ -237,6 +237,23 @@ describe('webhook endpoints enforce org API key expiration policy', () => {
expect(data.error).toBe('org_requires_expiring_key')
})

it('prevents direct REST webhook reads for legacy non-expiring org keys', async () => {
if (!legacyApiKeyValue || !createdWebhookId)
throw new Error('Legacy direct REST prerequisites were not created')

const response = await fetch(`${SUPABASE_BASE_URL}/rest/v1/webhooks?select=id,org_id&id=eq.${createdWebhookId}`, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
capgkey: legacyApiKeyValue,
},
})

expect(response.status).toBe(200)
const data = await response.json() as Array<{ id: string }>
expect(data).toEqual([])
})

it('rejects webhook creation for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue)
throw new Error('Legacy API key was not created')
Expand Down
19 changes: 18 additions & 1 deletion tests/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

import { BASE_URL, fetchWithRetry, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts'
import { BASE_URL, fetchWithRetry, getSupabaseClient, headers, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, TEST_EMAIL, USER_ID } from './test-utils.ts'

// Test org and webhook IDs
const WEBHOOK_TEST_ORG_ID = randomUUID()
Expand Down Expand Up @@ -295,6 +295,23 @@ describe('[GET] /webhooks (single webhook)', () => {
expect(data.stats_24h).toBeDefined()
})

it('prevents app-scoped API keys from reading webhooks through direct REST access', async () => {
if (!createdWebhookId || !appScopedKey)
throw new Error('Direct REST app-scoped webhook prerequisites were not created')

const response = await fetchWithRetry(`${SUPABASE_BASE_URL}/rest/v1/webhooks?select=id,org_id&id=eq.${createdWebhookId}`, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
capgkey: appScopedKey,
},
})

expect(response.status).toBe(200)
const data = await response.json() as Array<{ id: string }>
expect(data).toEqual([])
})

it('get webhook with invalid webhookId', async () => {
const invalidWebhookId = randomUUID()
const response = await fetchWithRetry(`${BASE_URL}/webhooks?orgId=${WEBHOOK_TEST_ORG_ID}&webhookId=${invalidWebhookId}`, {
Expand Down
Loading