From 2e4545011b067ae2978d321b386315102bb85ca6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 10:59:07 +0000 Subject: [PATCH 1/3] fix(db): restore anon get_user_id for published CLI without oracle leak Re-grant anonymous execute on get_user_id for published CLI versions that still call rpc/get_user_id with { apikey }. Require matching capgkey header for anon callers so keys cannot be probed from the RPC body alone. Keep get_org_perm_for_apikey* and invite_user_to_org_rbac revoked for anon. Fixes #3197 Co-authored-by: Martin DONADIEU --- ...44_restore_get_user_id_anon_cli_compat.sql | 72 +++++++++++++++++++ .../rbac-apikey-request-identity-rpc.test.ts | 6 +- ...security-definer-execute-hardening.test.ts | 4 +- tests/security-oracle-rpc-hardening.test.ts | 29 +++++++- 4 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql diff --git a/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql b/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql new file mode 100644 index 0000000000..f728aeecc1 --- /dev/null +++ b/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql @@ -0,0 +1,72 @@ +-- Restore anonymous execute on get_user_id for published CLI versions that still +-- call rpc/get_user_id with { apikey }. Require the same key in the capgkey header +-- for anon callers so arbitrary keys cannot be probed from the RPC body alone. +-- Keep get_org_perm_for_apikey*, invite_user_to_org_rbac revoked for anon. + +CREATE OR REPLACE FUNCTION public.get_user_id(apikey text) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + api_key public.apikeys%ROWTYPE; + header_key text; +BEGIN + IF apikey IS NULL OR btrim(apikey) = '' THEN + RETURN NULL; + END IF; + + IF (SELECT auth.role()) = 'anon' THEN + header_key := public.get_apikey_header(); + IF header_key IS NULL OR header_key <> apikey THEN + RETURN NULL; + END IF; + END IF; + + SELECT * + INTO api_key + FROM public.find_apikey_by_value(apikey) + LIMIT 1; + + IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + RETURN NULL; + END IF; + + RETURN api_key.user_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.get_user_id(apikey text, app_id text) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + real_user_id uuid; +BEGIN + PERFORM app_id; + SELECT public.get_user_id(apikey) INTO real_user_id; + RETURN real_user_id; +END; +$$; + +ALTER FUNCTION public.get_user_id(text) OWNER TO postgres; +ALTER FUNCTION public.get_user_id(text, text) OWNER TO postgres; + +REVOKE ALL ON FUNCTION public.get_user_id(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO anon; +GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO service_role; + +REVOKE ALL ON FUNCTION public.get_user_id(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO anon; +GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO service_role; + +COMMENT ON FUNCTION public.get_user_id(text) IS + 'Compatibility RPC for published CLIs. Anonymous callers must pass the same API key in capgkey and apikey; invalid or mismatched keys return NULL without org/app oracle signals.'; + +COMMENT ON FUNCTION public.get_user_id(text, text) IS + 'Compatibility RPC for published CLIs. The app_id argument is ignored; write authorization must be checked through RBAC.'; diff --git a/tests/rbac-apikey-request-identity-rpc.test.ts b/tests/rbac-apikey-request-identity-rpc.test.ts index ae18522bdc..be29a22954 100644 --- a/tests/rbac-apikey-request-identity-rpc.test.ts +++ b/tests/rbac-apikey-request-identity-rpc.test.ts @@ -191,15 +191,15 @@ describe('legacy CLI RBAC compatibility RPCs', () => { expect(ownerPerm).toBe('perm_owner') }) - it.concurrent('denies get_user_id for anonymous API-key callers', async () => { + it.concurrent('keeps get_user_id callable for published CLI anonymous API-key callers', async () => { const client = createApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) const { data, error } = await (client.rpc as any)('get_user_id', { apikey: APIKEY_TEST_ORG_SUPER_ADMIN, }).single() - expect(data).toBeNull() - expect(error?.code === '42501' || /permission denied/i.test(error?.message ?? '')).toBe(true) + expect(error).toBeNull() + expect(data).toBe(USER_ID) }) it.concurrent('keeps get_user_id callable for authenticated API-key callers', async () => { diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 8dcab815c1..8d8c4637fc 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -87,6 +87,8 @@ const ANON_ALLOWED_PROCS = [ 'public.reject_access_due_to_2fa_for_app(character varying)', 'public.reject_access_due_to_2fa_for_org(uuid)', 'public.request_actor_user_id()', + 'public.get_user_id(text)', + 'public.get_user_id(text, text)', 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_tmp_invite_role_rbac(uuid, text, text)', 'public.verify_mfa()', @@ -106,8 +108,6 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.get_org_perm_for_apikey(text, text)', 'public.get_org_perm_for_apikey_v2(text, text)', 'public.get_org_user_access_rbac(uuid, uuid)', - 'public.get_user_id(text)', - 'public.get_user_id(text, text)', 'public.invite_user_to_org_rbac(character varying, uuid, text)', 'public.rbac_check_permission(text, uuid, character varying, bigint)', 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', diff --git a/tests/security-oracle-rpc-hardening.test.ts b/tests/security-oracle-rpc-hardening.test.ts index 78a35c7b64..4063c5114a 100644 --- a/tests/security-oracle-rpc-hardening.test.ts +++ b/tests/security-oracle-rpc-hardening.test.ts @@ -8,6 +8,7 @@ import { getAuthHeadersForCredentials, ORG_ID, USER_EMAIL, + USER_ID, USER_PASSWORD, } from './test-utils' @@ -76,7 +77,29 @@ describe('anonymous oracle RPC hardening', () => { expect(missingOrgResult.data).toBeNull() }) - it.concurrent('blocks get_user_id for anonymous callers without distinguishable outcomes', async () => { + it.concurrent('keeps get_user_id callable for published CLI callers with capgkey header', async () => { + const client = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) + + const validKeyResult = await client.rpc('get_user_id', { + apikey: APIKEY_TEST_ORG_SUPER_ADMIN, + }) + const invalidKeyResult = await client.rpc('get_user_id', { + apikey: '00000000-0000-0000-0000-000000000000', + }) + const mismatchedHeaderClient = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) + const mismatchedKeyResult = await mismatchedHeaderClient.rpc('get_user_id', { + apikey: '00000000-0000-0000-0000-000000000000', + }) + + expect(validKeyResult.error).toBeNull() + expect(validKeyResult.data).toBe(USER_ID) + expect(invalidKeyResult.error).toBeNull() + expect(invalidKeyResult.data).toBeNull() + expect(mismatchedKeyResult.error).toBeNull() + expect(mismatchedKeyResult.data).toBeNull() + }) + + it.concurrent('denies get_user_id for anonymous callers without capgkey header', async () => { const client = createAnonymousClient() const validKeyResult = await client.rpc('get_user_id', { @@ -86,9 +109,9 @@ describe('anonymous oracle RPC hardening', () => { apikey: '00000000-0000-0000-0000-000000000000', }) - expect(isPermissionDenied(validKeyResult.error)).toBe(true) - expect(isPermissionDenied(invalidKeyResult.error)).toBe(true) + expect(validKeyResult.error).toBeNull() expect(validKeyResult.data).toBeNull() + expect(invalidKeyResult.error).toBeNull() expect(invalidKeyResult.data).toBeNull() }) From 12e8c8f62e1e6d343b5afca24de6ef2c443fbb01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 11:02:43 +0000 Subject: [PATCH 2/3] fix(db): minimal anon grant on get_user_id(text) only Restore published CLI 8.42.x identity RPC without re-granting the two-arg overload or org-perm/invite oracle RPCs. Co-authored-by: Martin DONADIEU --- ...44_restore_get_user_id_anon_cli_compat.sql | 73 +------------------ ...security-definer-execute-hardening.test.ts | 2 +- tests/security-oracle-rpc-hardening.test.ts | 26 ++----- 3 files changed, 12 insertions(+), 89 deletions(-) diff --git a/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql b/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql index f728aeecc1..acdcefda21 100644 --- a/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql +++ b/supabase/migrations/20260825105544_restore_get_user_id_anon_cli_compat.sql @@ -1,72 +1,5 @@ --- Restore anonymous execute on get_user_id for published CLI versions that still --- call rpc/get_user_id with { apikey }. Require the same key in the capgkey header --- for anon callers so arbitrary keys cannot be probed from the RPC body alone. --- Keep get_org_perm_for_apikey*, invite_user_to_org_rbac revoked for anon. +-- Restore anon execute on get_user_id(text) for published CLI 8.42.x. +-- Keep get_user_id(text,text), get_org_perm_for_apikey*, invite_user_to_org_rbac +-- revoked for anon (see 20260824144021_revoke_anon_oracle_rpc_execute.sql). -CREATE OR REPLACE FUNCTION public.get_user_id(apikey text) -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - api_key public.apikeys%ROWTYPE; - header_key text; -BEGIN - IF apikey IS NULL OR btrim(apikey) = '' THEN - RETURN NULL; - END IF; - - IF (SELECT auth.role()) = 'anon' THEN - header_key := public.get_apikey_header(); - IF header_key IS NULL OR header_key <> apikey THEN - RETURN NULL; - END IF; - END IF; - - SELECT * - INTO api_key - FROM public.find_apikey_by_value(apikey) - LIMIT 1; - - IF api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN - RETURN NULL; - END IF; - - RETURN api_key.user_id; -END; -$$; - -CREATE OR REPLACE FUNCTION public.get_user_id(apikey text, app_id text) -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - real_user_id uuid; -BEGIN - PERFORM app_id; - SELECT public.get_user_id(apikey) INTO real_user_id; - RETURN real_user_id; -END; -$$; - -ALTER FUNCTION public.get_user_id(text) OWNER TO postgres; -ALTER FUNCTION public.get_user_id(text, text) OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.get_user_id(text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO anon; -GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_user_id(text) TO service_role; - -REVOKE ALL ON FUNCTION public.get_user_id(text, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO anon; -GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_user_id(text, text) TO service_role; - -COMMENT ON FUNCTION public.get_user_id(text) IS - 'Compatibility RPC for published CLIs. Anonymous callers must pass the same API key in capgkey and apikey; invalid or mismatched keys return NULL without org/app oracle signals.'; - -COMMENT ON FUNCTION public.get_user_id(text, text) IS - 'Compatibility RPC for published CLIs. The app_id argument is ignored; write authorization must be checked through RBAC.'; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 8d8c4637fc..9391ddd858 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -88,7 +88,6 @@ const ANON_ALLOWED_PROCS = [ 'public.reject_access_due_to_2fa_for_org(uuid)', 'public.request_actor_user_id()', 'public.get_user_id(text)', - 'public.get_user_id(text, text)', 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_tmp_invite_role_rbac(uuid, text, text)', 'public.verify_mfa()', @@ -108,6 +107,7 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.get_org_perm_for_apikey(text, text)', 'public.get_org_perm_for_apikey_v2(text, text)', 'public.get_org_user_access_rbac(uuid, uuid)', + 'public.get_user_id(text, text)', 'public.invite_user_to_org_rbac(character varying, uuid, text)', 'public.rbac_check_permission(text, uuid, character varying, bigint)', 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', diff --git a/tests/security-oracle-rpc-hardening.test.ts b/tests/security-oracle-rpc-hardening.test.ts index 4063c5114a..2fafbe45b5 100644 --- a/tests/security-oracle-rpc-hardening.test.ts +++ b/tests/security-oracle-rpc-hardening.test.ts @@ -77,8 +77,8 @@ describe('anonymous oracle RPC hardening', () => { expect(missingOrgResult.data).toBeNull() }) - it.concurrent('keeps get_user_id callable for published CLI callers with capgkey header', async () => { - const client = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) + it.concurrent('keeps get_user_id(text) callable for published CLI anonymous callers', async () => { + const client = createAnonymousClient() const validKeyResult = await client.rpc('get_user_id', { apikey: APIKEY_TEST_ORG_SUPER_ADMIN, @@ -86,33 +86,23 @@ describe('anonymous oracle RPC hardening', () => { const invalidKeyResult = await client.rpc('get_user_id', { apikey: '00000000-0000-0000-0000-000000000000', }) - const mismatchedHeaderClient = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) - const mismatchedKeyResult = await mismatchedHeaderClient.rpc('get_user_id', { - apikey: '00000000-0000-0000-0000-000000000000', - }) expect(validKeyResult.error).toBeNull() expect(validKeyResult.data).toBe(USER_ID) expect(invalidKeyResult.error).toBeNull() expect(invalidKeyResult.data).toBeNull() - expect(mismatchedKeyResult.error).toBeNull() - expect(mismatchedKeyResult.data).toBeNull() }) - it.concurrent('denies get_user_id for anonymous callers without capgkey header', async () => { - const client = createAnonymousClient() + it.concurrent('keeps get_user_id(text, text) denied for anonymous callers', async () => { + const client = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) - const validKeyResult = await client.rpc('get_user_id', { + const result = await client.rpc('get_user_id', { apikey: APIKEY_TEST_ORG_SUPER_ADMIN, - }) - const invalidKeyResult = await client.rpc('get_user_id', { - apikey: '00000000-0000-0000-0000-000000000000', + app_id: APP_ID, }) - expect(validKeyResult.error).toBeNull() - expect(validKeyResult.data).toBeNull() - expect(invalidKeyResult.error).toBeNull() - expect(invalidKeyResult.data).toBeNull() + expect(isPermissionDenied(result.error)).toBe(true) + expect(result.data).toBeNull() }) it.concurrent('blocks get_org_perm_for_apikey RPCs for anonymous callers', async () => { From cefa2bc3bbb7b5cef7fb8683a04312674e9e73d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 11:09:35 +0000 Subject: [PATCH 3/3] ci: re-run tests after minimal get_user_id grant Co-authored-by: Martin DONADIEU