diff --git a/supabase/migrations/20260908142414_revoke_remaining_anon_oracle_rpc_execute.sql b/supabase/migrations/20260908142414_revoke_remaining_anon_oracle_rpc_execute.sql new file mode 100644 index 0000000000..1fa323f790 --- /dev/null +++ b/supabase/migrations/20260908142414_revoke_remaining_anon_oracle_rpc_execute.sql @@ -0,0 +1,168 @@ +-- Complete the anon oracle RPC hardening started in +-- 20260824144021_revoke_anon_oracle_rpc_execute.sql. +-- +-- 1) Revoke anonymous EXECUTE on SECURITY DEFINER helpers that enumerate or +-- infer org/app/member state and are only needed from signed-in console JWT +-- traffic (authenticated role). Published CLI keeps anon EXECUTE on +-- get_user_id(text) and the capgkey-scoped helpers it still calls. +-- 2) Remove distinguishable "Organization does not exist" vs "NO_RIGHTS" +-- outcomes from org-member helpers that must remain anon-callable for CLI. +-- Internal/service_role callers still get ORG_NOT_FOUND for a missing org. + +-- --------------------------------------------------------------------------- +-- Fix org-member helpers: same denial for missing org and missing permission. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.check_org_members_2fa_enabled(org_id uuid) +RETURNS TABLE(user_id uuid, "2fa_enabled" boolean) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Internal callers keep a distinguishable missing-org signal; non-internal + -- callers still collapse missing-org into NO_RIGHTS (anon oracle closed). + IF public.is_internal_request_role(public.current_request_role()) THEN + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_2fa_enabled.org_id + ) THEN + RAISE EXCEPTION 'ORG_NOT_FOUND'; + END IF; + ELSIF ( + NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_2fa_enabled.org_id + ) + OR NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + check_org_members_2fa_enabled.org_id, + NULL::character varying, + NULL::bigint + ) + ) + THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + RETURN QUERY + SELECT DISTINCT + rb.principal_id AS user_id, + COALESCE(public.has_2fa_enabled(rb.principal_id), false) AS "2fa_enabled" + FROM public.role_bindings rb + JOIN public.roles r ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = check_org_members_2fa_enabled.org_id + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + AND r.name LIKE 'org_%'; +END; +$$; + +ALTER FUNCTION public.check_org_members_2fa_enabled(uuid) OWNER TO postgres; + +CREATE OR REPLACE FUNCTION public.check_org_members_password_policy(org_id uuid) +RETURNS TABLE( + user_id uuid, + email text, + first_name text, + last_name text, + password_policy_compliant boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Internal callers keep a distinguishable missing-org signal; non-internal + -- callers still collapse missing-org into NO_RIGHTS (anon oracle closed). + IF public.is_internal_request_role(public.current_request_role()) THEN + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_password_policy.org_id + ) THEN + RAISE EXCEPTION 'ORG_NOT_FOUND'; + END IF; + ELSIF ( + NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_password_policy.org_id + ) + OR NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + check_org_members_password_policy.org_id, + NULL::character varying, + NULL::bigint + ) + ) + THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + RETURN QUERY + SELECT DISTINCT + rb.principal_id AS user_id, + au.email::text, + u.first_name::text, + u.last_name::text, + public.user_meets_password_policy( + rb.principal_id, + check_org_members_password_policy.org_id + ) AS password_policy_compliant + FROM public.role_bindings rb + JOIN public.roles r ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + JOIN auth.users au ON au.id = rb.principal_id + LEFT JOIN public.users u ON u.id = rb.principal_id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id = check_org_members_password_policy.org_id + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + AND r.name LIKE 'org_%'; +END; +$$; + +ALTER FUNCTION public.check_org_members_password_policy(uuid) OWNER TO postgres; + +-- --------------------------------------------------------------------------- +-- Revoke anonymous EXECUTE on org/member oracle RPCs (console uses JWT). +-- exist_app / exist_app_v2 stay anon-callable: they return false without a +-- valid capgkey and do not distinguish missing apps from denied access. +-- --------------------------------------------------------------------------- + +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM anon; + +REVOKE ALL ON FUNCTION public.is_member_of_org(uuid, uuid) FROM anon; + +REVOKE ALL ON FUNCTION public.update_org_invite_role_rbac( + uuid, uuid, text +) FROM anon; + +REVOKE ALL ON FUNCTION public.update_tmp_invite_role_rbac( + uuid, text, text +) FROM anon; + +GRANT EXECUTE ON FUNCTION public.get_org_members_rbac(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_org_members_rbac(uuid) TO service_role; + +GRANT EXECUTE ON FUNCTION public.is_member_of_org(uuid, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_member_of_org(uuid, uuid) TO service_role; + +GRANT EXECUTE ON FUNCTION public.update_org_invite_role_rbac( + uuid, uuid, text +) TO authenticated; +GRANT EXECUTE ON FUNCTION public.update_org_invite_role_rbac( + uuid, uuid, text +) TO service_role; + +GRANT EXECUTE ON FUNCTION public.update_tmp_invite_role_rbac( + uuid, text, text +) TO authenticated; +GRANT EXECUTE ON FUNCTION public.update_tmp_invite_role_rbac( + uuid, text, text +) TO service_role; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index d99fdf433d..5034bfa4a6 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -3178,16 +3178,28 @@ CREATE OR REPLACE FUNCTION "public"."check_org_members_2fa_enabled"("org_id" "uu SET "search_path" TO '' AS $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_2fa_enabled.org_id) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - - IF NOT public.is_internal_request_role(public.current_request_role()) - AND NOT public.rbac_check_permission_request( - public.rbac_perm_org_update_settings(), - check_org_members_2fa_enabled.org_id, - NULL::character varying, - NULL::bigint + -- Internal callers keep a distinguishable missing-org signal; non-internal + -- callers still collapse missing-org into NO_RIGHTS (anon oracle closed). + IF public.is_internal_request_role(public.current_request_role()) THEN + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_2fa_enabled.org_id + ) THEN + RAISE EXCEPTION 'ORG_NOT_FOUND'; + END IF; + ELSIF ( + NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_2fa_enabled.org_id + ) + OR NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + check_org_members_2fa_enabled.org_id, + NULL::character varying, + NULL::bigint + ) ) THEN RAISE EXCEPTION 'NO_RIGHTS'; @@ -3217,32 +3229,43 @@ CREATE OR REPLACE FUNCTION "public"."check_org_members_password_policy"("org_id" SET "search_path" TO '' AS $$ BEGIN - IF NOT public.is_internal_request_role(public.current_request_role()) - AND NOT public.rbac_check_permission_request( - public.rbac_perm_org_update_settings(), - check_org_members_password_policy.org_id, - NULL::character varying, - NULL::bigint + -- Internal callers keep a distinguishable missing-org signal; non-internal + -- callers still collapse missing-org into NO_RIGHTS (anon oracle closed). + IF public.is_internal_request_role(public.current_request_role()) THEN + IF NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_password_policy.org_id + ) THEN + RAISE EXCEPTION 'ORG_NOT_FOUND'; + END IF; + ELSIF ( + NOT EXISTS ( + SELECT 1 + FROM public.orgs + WHERE public.orgs.id = check_org_members_password_policy.org_id + ) + OR NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + check_org_members_password_policy.org_id, + NULL::character varying, + NULL::bigint + ) ) THEN RAISE EXCEPTION 'NO_RIGHTS'; END IF; - IF NOT EXISTS ( - SELECT 1 - FROM public.orgs - WHERE public.orgs.id = check_org_members_password_policy.org_id - ) THEN - RAISE EXCEPTION 'Organization does not exist'; - END IF; - RETURN QUERY SELECT DISTINCT rb.principal_id AS user_id, au.email::text, u.first_name::text, u.last_name::text, - public.user_meets_password_policy(rb.principal_id, check_org_members_password_policy.org_id) AS password_policy_compliant + public.user_meets_password_policy( + rb.principal_id, + check_org_members_password_policy.org_id + ) AS password_policy_compliant FROM public.role_bindings rb JOIN public.roles r ON r.id = rb.role_id AND r.scope_type = rb.scope_type @@ -27228,7 +27251,6 @@ GRANT ALL ON FUNCTION "public"."get_org_members"("user_id" "uuid", "guild_id" "u REVOKE ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") TO "anon"; GRANT ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") TO "authenticated"; GRANT ALL ON FUNCTION "public"."get_org_members_rbac"("p_org_id" "uuid") TO "service_role"; @@ -27643,7 +27665,6 @@ GRANT ALL ON FUNCTION "public"."is_mau_exceeded_by_org"("org_id" "uuid") TO "ser REVOKE ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "service_role"; -GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "anon"; GRANT ALL ON FUNCTION "public"."is_member_of_org"("user_id" "uuid", "org_id" "uuid") TO "authenticated"; @@ -28919,7 +28940,6 @@ GRANT ALL ON FUNCTION "public"."update_apps_build_timeout_updated_at"() TO "serv REVOKE ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "anon"; GRANT ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "authenticated"; GRANT ALL ON FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uuid", "p_user_id" "uuid", "p_new_role_name" "text") TO "service_role"; @@ -28938,7 +28958,6 @@ GRANT ALL ON FUNCTION "public"."update_sso_providers_updated_at"() TO "authentic REVOKE ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") TO "anon"; GRANT ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") TO "authenticated"; GRANT ALL ON FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uuid", "p_email" "text", "p_new_role_name" "text") TO "service_role"; diff --git a/tests/apikeys.test.ts b/tests/apikeys.test.ts index 53b4a4885f..0db125f65b 100644 --- a/tests/apikeys.test.ts +++ b/tests/apikeys.test.ts @@ -18,13 +18,23 @@ import { USER_EMAIL_APIKEY_MANAGEMENT, USER_ID, USER_PASSWORD, - warmEdgeEndpoint, } from './test-utils.ts' const id = randomUUID() const APPNAME = `com.app.key.${id}` let authHeaders: Record +const TRANSIENT_GATEWAY_MARKERS = [ + 'An invalid response was received from the upstream server', + 'Your worker restarted mid-request', +] + +function isTransientGateway502503(status: number, body: string): boolean { + if (status !== 502 && status !== 503) + return false + return TRANSIENT_GATEWAY_MARKERS.some(marker => body.includes(marker)) +} + function orgKeyBody(name: string, extra: Record = {}) { return { name, @@ -41,11 +51,158 @@ async function appKeyBody(name: string, appId = APPNAME, extra: Record { + const url = `${BASE_URL}${path}` + let lastStatus = 0 + for (let attempt = 1; attempt <= 5; attempt++) { + if (Date.now() >= deadlineMs) + throw new Error(`[warmEdgeEndpoint] timed out before attempt ${attempt} url=${url}`) + + const response = await withFetchDeadline(deadlineMs, signal => + fetch(url, { ...options, signal }), + ) + await response.text().catch(() => undefined) + lastStatus = response.status + if (response.status !== 502 && response.status !== 503) + return + + if (attempt === 5) + break + + const delayMs = Math.min(500 * attempt, Math.max(0, deadlineMs - Date.now())) + if (delayMs <= 0) + throw new Error(`[warmEdgeEndpoint] timed out waiting to retry url=${url}`) + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + throw new Error(`[warmEdgeEndpoint] isolate still returning ${lastStatus} after 5 attempts url=${url}`) +} + +async function withFetchDeadline( + deadlineMs: number, + run: (signal: AbortSignal) => Promise, +): Promise { + const remainingMs = deadlineMs - Date.now() + if (remainingMs <= 0) + throw new Error('Request timed out before fetch started') + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), remainingMs) + try { + return await run(controller.signal) + } + finally { + clearTimeout(timeout) + } +} + +async function deleteApiKeysByName( + name: string, + headers: Record, + deadlineMs: number, +) { + try { + const listed = await withFetchDeadline(deadlineMs, async (signal) => { + const listResponse = await fetch(`${BASE_URL}/apikey`, { headers, signal }) + if (!listResponse.ok) + return null + return await listResponse.json() as Array<{ id: number, name: string }> + }) + if (listed === null) + return + + const matchingKeys = listed.filter(key => key.name === name) + await Promise.allSettled(matchingKeys.map(async (key) => { + await withFetchDeadline(deadlineMs, async (signal) => { + const deleteResponse = await fetch(`${BASE_URL}/apikey/${key.id}`, { method: 'DELETE', headers, signal }) + if (!deleteResponse.ok) + throw new Error(`DELETE /apikey/${key.id} failed with ${deleteResponse.status}`) + }) + })) + } + catch { + // Best-effort cleanup before a create-safe retry; do not block the caller. + } +} + +let postApiKeyQueue: Promise = Promise.resolve() + +async function postApiKey( + body: unknown, + headers: Record = authHeaders, + rawBody = false, +) { + const run = async () => { + const keyName = !rawBody && typeof body === 'object' && body !== null && 'name' in body + ? String((body as { name: unknown }).name) + : undefined + const requestInit: RequestInit = { + method: 'POST', + headers, + body: rawBody ? String(body) : JSON.stringify(body), + } + const url = `${BASE_URL}/apikey` + const deadline = Date.now() + 15000 + + while (true) { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) + throw new Error('POST /apikey timed out after 15s waiting for gateway response') + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), remainingMs) + + let response: Response + let responseBody = '' + try { + response = await fetch(url, { ...requestInit, signal: controller.signal }) + if (response.status === 502 || response.status === 503) + responseBody = await response.clone().text() + } + catch (error) { + if (error instanceof Error && error.name === 'AbortError') + throw new Error('POST /apikey timed out after 15s waiting for gateway response') + throw error + } + finally { + clearTimeout(timeout) + } + + if (response.status !== 502 && response.status !== 503) + return response + + if (!isTransientGateway502503(response.status, responseBody)) + return response + + // Create-safe retry: a 502 may have persisted the key without returning 200. + if (keyName) + await deleteApiKeysByName(keyName, headers, deadline) + + if (Date.now() >= deadline) + return response + + await new Promise(resolve => setTimeout(resolve, Math.min(300, Math.max(0, deadline - Date.now())))) + } + } + + const result = postApiKeyQueue.then(run, run) + postApiKeyQueue = result.then(() => undefined, () => undefined) + return result +} + beforeAll(async () => { authHeaders = await getAuthHeaders() await resetAndSeedAppData(APPNAME) - // Load the apikey isolate before concurrent POSTs from this file. - await warmEdgeEndpoint('/apikey', { method: 'GET', headers: authHeaders }) + // Warm GET and POST handlers before concurrent key creation in this file. + await warmEdgeEndpointWithDeadline('/apikey', { method: 'GET', headers: authHeaders }, Date.now() + 15000) + await warmEdgeEndpointWithDeadline('/apikey', { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({}), + }, Date.now() + 15000) }) afterAll(async () => { @@ -130,11 +287,7 @@ describe('[GET] /apikey operations', () => { describe('[POST] /apikey operations', () => { it('create api key', async () => { const keyName = 'test-key-creation' - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody(keyName)), - }) + const response = await postApiKey(orgKeyBody(keyName)) const data = await response.json<{ key: string, id: number }>() expect(response.status).toBe(200) expect(data).toHaveProperty('key') @@ -151,11 +304,7 @@ describe('[POST] /apikey operations', () => { it('create api key latency', async () => { const createdIds: number[] = [] try { - const warmup = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody(`latency-warmup-${id.slice(0, 8)}`)), - }) + const warmup = await postApiKey(orgKeyBody(`latency-warmup-${id.slice(0, 8)}`)) expect(warmup.status).toBe(200) const warmupData = await warmup.json<{ id: number }>() createdIds.push(warmupData.id) @@ -163,11 +312,7 @@ describe('[POST] /apikey operations', () => { const samples: number[] = [] for (let index = 0; index < 5; index += 1) { const startedAt = performance.now() - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody(`latency-${id.slice(0, 8)}-${index}`)), - }) + const response = await postApiKey(orgKeyBody(`latency-${id.slice(0, 8)}-${index}`)) samples.push(performance.now() - startedAt) expect(response.status).toBe(200) const data = await response.json<{ id: number }>() @@ -198,13 +343,9 @@ describe('[POST] /apikey operations', () => { it.concurrent('creates an app-only preview key bound to its owning organization', async () => { const appBindings = await appApiKeyBindings(APPNAME, 'app_preview') - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: `app-preview-key-${id.slice(0, 8)}`, - bindings: appBindings, - }), + const response = await postApiKey({ + name: `app-preview-key-${id.slice(0, 8)}`, + bindings: appBindings, }) expect(response.status).toBe(200) const data = await response.json<{ id: number, rbac_id: string }>() @@ -238,11 +379,7 @@ describe('[POST] /apikey operations', () => { }) it('app-limited key cannot create another API key', async () => { - const limitedCreatorResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(await appKeyBody('app-key-creator')), - }) + const limitedCreatorResponse = await postApiKey(await appKeyBody('app-key-creator')) expect(limitedCreatorResponse.status).toBe(200) const limitedCreatorData = await limitedCreatorResponse.json<{ id: number, key: string }>() @@ -251,11 +388,7 @@ describe('[POST] /apikey operations', () => { 'capgkey': limitedCreatorData.key, } - const escalationResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: limitedHeaders, - body: JSON.stringify(await appKeyBody('blocked-key-creation')), - }) + const escalationResponse = await postApiKey(await appKeyBody('blocked-key-creation'), limitedHeaders) const escalationData = await escalationResponse.json() as { error: string } expect(escalationResponse.status).toBe(400) expect(escalationData).toHaveProperty('error', 'cannot_create_apikey') @@ -270,20 +403,12 @@ describe('[POST] /apikey operations', () => { const createdKeyIds: number[] = [] try { - const limitedResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(await appKeyBody('app-management-blocked')), - }) + const limitedResponse = await postApiKey(await appKeyBody('app-management-blocked')) expect(limitedResponse.status).toBe(200) const limitedData = await limitedResponse.json<{ id: number, key: string }>() createdKeyIds.push(limitedData.id) - const siblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('sibling-management-target')), - }) + const siblingResponse = await postApiKey(orgKeyBody('sibling-management-target')) expect(siblingResponse.status).toBe(200) const siblingData = await siblingResponse.json<{ id: number }>() createdKeyIds.push(siblingData.id) @@ -344,22 +469,14 @@ describe('[POST] /apikey operations', () => { const orgId = orgApiKeyBindings()[0].org_id try { - const managerResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('org-management-blocked', { - bindings: orgApiKeyBindings(orgId, 'org_member'), - })), - }) + const managerResponse = await postApiKey(orgKeyBody('org-management-blocked', { + bindings: orgApiKeyBindings(orgId, 'org_member'), + })) expect(managerResponse.status).toBe(200) const managerData = await managerResponse.json<{ id: number, key: string }>() createdKeyIds.push(managerData.id) - const siblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('org-sibling-management-target')), - }) + const siblingResponse = await postApiKey(orgKeyBody('org-sibling-management-target')) expect(siblingResponse.status).toBe(200) const siblingData = await siblingResponse.json<{ id: number }>() createdKeyIds.push(siblingData.id) @@ -449,13 +566,9 @@ describe('[POST] /apikey operations', () => { } try { - const siblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: dedicatedAuthHeaders, - body: JSON.stringify(orgKeyBody('org-super-admin-key-sibling-management-target', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT), - })), - }) + const siblingResponse = await postApiKey(orgKeyBody('org-super-admin-key-sibling-management-target', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT), + }), dedicatedAuthHeaders) expect(siblingResponse.status).toBe(200) const siblingData = await siblingResponse.json<{ id: number }>() createdKeyIds.push(siblingData.id) @@ -561,13 +674,9 @@ describe('[POST] /apikey operations', () => { const createdKeyIds: number[] = [] try { - const siblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: dedicatedAuthHeaders, - body: JSON.stringify(orgKeyBody('apikey-manager-sibling-target', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), - })), - }) + const siblingResponse = await postApiKey(orgKeyBody('apikey-manager-sibling-target', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), + }), dedicatedAuthHeaders) expect(siblingResponse.status).toBe(200) const siblingData = await siblingResponse.json<{ id: number }>() createdKeyIds.push(siblingData.id) @@ -577,13 +686,9 @@ describe('[POST] /apikey operations', () => { const listData = await listResponse.json>() expect(listData.some(apikey => apikey.id === siblingData.id)).toBe(true) - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: managerKeyHeaders, - body: JSON.stringify(orgKeyBody('apikey-manager-created-sibling', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), - })), - }) + const createResponse = await postApiKey(orgKeyBody('apikey-manager-created-sibling', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), + }), managerKeyHeaders) expect(createResponse.status).toBe(400) await expect(createResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey') @@ -604,46 +709,34 @@ describe('[POST] /apikey operations', () => { expect(bindingUpdateResponse.status).toBe(401) await expect(bindingUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey') - const privilegedCreateResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: managerKeyHeaders, - body: JSON.stringify(orgKeyBody('apikey-manager-blocked-privileged-create', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), - })), - }) + const privilegedCreateResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-privileged-create', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), + }), managerKeyHeaders) expect(privilegedCreateResponse.status).toBe(400) await expect(privilegedCreateResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey') - const appAdminCreateResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: managerKeyHeaders, - body: JSON.stringify({ - name: 'apikey-manager-blocked-app-admin-create', - bindings: [{ - role_name: 'app_admin', - scope_type: 'app', - org_id: ORG_ID_APIKEY_MANAGEMENT, - app_id: APPNAME, - }], - }), - }) + const appAdminCreateResponse = await postApiKey({ + name: 'apikey-manager-blocked-app-admin-create', + bindings: [{ + role_name: 'app_admin', + scope_type: 'app', + org_id: ORG_ID_APIKEY_MANAGEMENT, + app_id: APPNAME, + }], + }, managerKeyHeaders) expect(appAdminCreateResponse.status).toBe(400) await expect(appAdminCreateResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey') - const allowSystemRoleBypassResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: managerKeyHeaders, - body: JSON.stringify({ - name: 'apikey-manager-blocked-allow-system-role-bypass', - bindings: [{ - role_name: 'app_admin', - scope_type: 'app', - org_id: ORG_ID_APIKEY_MANAGEMENT, - app_id: APPNAME, - allowSystemRole: true, - }], - }), - }) + const allowSystemRoleBypassResponse = await postApiKey({ + name: 'apikey-manager-blocked-allow-system-role-bypass', + bindings: [{ + role_name: 'app_admin', + scope_type: 'app', + org_id: ORG_ID_APIKEY_MANAGEMENT, + app_id: APPNAME, + allowSystemRole: true, + }], + }, managerKeyHeaders) expect(allowSystemRoleBypassResponse.status).toBe(400) await expect(allowSystemRoleBypassResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey') @@ -685,14 +778,10 @@ describe('[POST] /apikey operations', () => { const createdKeyIds: number[] = [] try { - const hashedSiblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: dedicatedAuthHeaders, - body: JSON.stringify(orgKeyBody('apikey-manager-blocked-super-admin-hashed', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), - hashed: true, - })), - }) + const hashedSiblingResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-super-admin-hashed', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), + hashed: true, + }), dedicatedAuthHeaders) expect(hashedSiblingResponse.status).toBe(200) const hashedSibling = await hashedSiblingResponse.json<{ id: number, key: string }>() createdKeyIds.push(hashedSibling.id) @@ -713,14 +802,10 @@ describe('[POST] /apikey operations', () => { }) expect(oldHashedAuthResponse.status).toBe(200) - const plainSiblingResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: dedicatedAuthHeaders, - body: JSON.stringify(orgKeyBody('apikey-manager-blocked-super-admin-plain', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), - hashed: false, - })), - }) + const plainSiblingResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-super-admin-plain', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'), + hashed: false, + }), dedicatedAuthHeaders) expect(plainSiblingResponse.status).toBe(200) const plainSibling = await plainSiblingResponse.json<{ id: number, key: string }>() createdKeyIds.push(plainSibling.id) @@ -775,49 +860,33 @@ describe('[POST] /apikey operations', () => { 'capgkey': APIKEY_MANAGEMENT_ORG_SUPER_ADMIN, } - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: superAdminKeyHeaders, - body: JSON.stringify(orgKeyBody('org-super-admin-key-creation-blocked', { - bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), - })), - }) + const createResponse = await postApiKey(orgKeyBody('org-super-admin-key-creation-blocked', { + bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'), + }), superAdminKeyHeaders) expect(createResponse.status).toBe(400) await expect(createResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey') }) it('create api key with missing name', async () => { - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({}), - }) + const response = await postApiKey({}, authHeaders) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data).toHaveProperty('error', 'name_is_required') }) it('create api key with empty name', async () => { - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ name: '' }), - }) + const response = await postApiKey({ name: '' }, authHeaders) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data).toHaveProperty('error', 'name_is_required') }) it('create api key with invalid binding scope', async () => { - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'test-key', - bindings: [{ role_name: 'org_admin', scope_type: 'invalid', org_id: randomUUID() }], - }), - }) + const response = await postApiKey({ + name: 'test-key', + bindings: [{ role_name: 'org_admin', scope_type: 'invalid', org_id: randomUUID() }], + }, authHeaders) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data).toHaveProperty('error', 'invalid_bindings') @@ -825,14 +894,10 @@ describe('[POST] /apikey operations', () => { it('create api key with non-existent org_id', async () => { const nonExistentOrgId = randomUUID() - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'test-key', - bindings: orgApiKeyBindings(nonExistentOrgId), - }), - }) + const response = await postApiKey({ + name: 'test-key', + bindings: orgApiKeyBindings(nonExistentOrgId), + }, authHeaders) expect(response.status).toBe(403) const data = await response.json() as { error: string } expect(data).toHaveProperty('error', 'forbidden_binding') @@ -840,30 +905,22 @@ describe('[POST] /apikey operations', () => { it('create api key with non-existent app_id', async () => { const nonExistentAppId = randomUUID() - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'test-key', - bindings: [{ - role_name: 'app_admin', - scope_type: 'app', - org_id: orgApiKeyBindings()[0].org_id, - app_id: nonExistentAppId, - }], - }), - }) + const response = await postApiKey({ + name: 'test-key', + bindings: [{ + role_name: 'app_admin', + scope_type: 'app', + org_id: orgApiKeyBindings()[0].org_id, + app_id: nonExistentAppId, + }], + }, authHeaders) expect(response.status).toBe(404) const data = await response.json() as { error: string } expect(data).toHaveProperty('error', 'binding_failed') }) it('create api key with invalid JSON body', async () => { - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: 'invalid json', - }) + const response = await postApiKey('invalid json', authHeaders, true) expect(response.status).toBeGreaterThanOrEqual(400) }) }) @@ -896,11 +953,7 @@ describe('[PUT] /apikey/:id operations', () => { try { for (const hashed of [false, true]) { const suffix = hashed ? 'hashed' : 'plain' - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody(`temp-metadata-no-leak-${suffix}-${randomUUID()}`, { hashed })), - }) + const createResponse = await postApiKey(orgKeyBody(`temp-metadata-no-leak-${suffix}-${randomUUID()}`, { hashed })) expect(createResponse.status).toBe(200) const createData = await createResponse.json<{ id: number }>() createdKeyIds.push(createData.id) @@ -934,11 +987,7 @@ describe('[PUT] /apikey/:id operations', () => { let createData: { id: number, rbac_id: string } | undefined try { - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody(`temp-key-update-bindings-${randomUUID()}`)), - }) + const createResponse = await postApiKey(orgKeyBody(`temp-key-update-bindings-${randomUUID()}`)) expect(createResponse.status).toBe(200) createData = await createResponse.json<{ id: number, rbac_id: string }>() const createdKey = createData @@ -1033,11 +1082,7 @@ describe('[PUT] /apikey/:id operations', () => { it('update api key with unsupported org scope field has no valid fields', async () => { // Create a temporary key for this test - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('temp-test-key')), - }) + const createResponse = await postApiKey(orgKeyBody('temp-test-key'), authHeaders) const createData = await createResponse.json<{ id: number }>() const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, { @@ -1054,11 +1099,7 @@ describe('[PUT] /apikey/:id operations', () => { it('update api key with no valid fields', async () => { // Create a temporary key for this test - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('temp-test-key-2')), - }) + const createResponse = await postApiKey(orgKeyBody('temp-test-key-2'), authHeaders) const createData = await createResponse.json<{ id: number }>() const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, { @@ -1072,11 +1113,7 @@ describe('[PUT] /apikey/:id operations', () => { }) it('regenerate plain api key (key changes and old key no longer works)', async () => { - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('temp-plain-key-regenerate', { hashed: false })), - }) + const createResponse = await postApiKey(orgKeyBody('temp-plain-key-regenerate', { hashed: false }), authHeaders) const createData = await createResponse.json<{ id: number, key: string }>() expect(createResponse.status).toBe(200) expect(typeof createData.key).toBe('string') @@ -1109,11 +1146,7 @@ describe('[PUT] /apikey/:id operations', () => { }) it('regenerate hashed api key (key changes and remains hashed in DB)', async () => { - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('temp-hashed-key-regenerate', { hashed: true })), - }) + const createResponse = await postApiKey(orgKeyBody('temp-hashed-key-regenerate', { hashed: true }), authHeaders) const createData = await createResponse.json<{ id: number, key: string, key_hash: string }>() expect(createResponse.status).toBe(200) @@ -1155,11 +1188,7 @@ describe('[PUT] /apikey/:id operations', () => { }) it('regenerate and update name in a single request', async () => { - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('temp-key-regenerate-and-rename', { hashed: false })), - }) + const createResponse = await postApiKey(orgKeyBody('temp-key-regenerate-and-rename', { hashed: false }), authHeaders) const createData = await createResponse.json<{ id: number, key: string }>() expect(createResponse.status).toBe(200) @@ -1190,11 +1219,7 @@ describe('[PUT] /apikey/:id operations', () => { describe('[DELETE] /apikey/:id operations', () => { it('delete api key', async () => { // Create a key specifically for deletion - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('key-to-delete')), - }) + const createResponse = await postApiKey(orgKeyBody('key-to-delete'), authHeaders) const createData = await createResponse.json<{ id: number }>() const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, { @@ -1223,11 +1248,7 @@ describe('[DELETE] /apikey/:id operations', () => { it('delete already deleted api key', async () => { // Create and delete a key, then try to delete again - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(orgKeyBody('key-to-double-delete')), - }) + const createResponse = await postApiKey(orgKeyBody('key-to-double-delete'), authHeaders) const createData = await createResponse.json<{ id: number }>() // First deletion @@ -1250,15 +1271,11 @@ describe('[DELETE] /apikey/:id operations', () => { describe('[POST] /apikey hashed key operations', () => { it('create hashed api key', async () => { const keyName = 'test-hashed-key' - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: keyName, - hashed: true, - bindings: orgApiKeyBindings(), - }), - }) + const response = await postApiKey({ + name: keyName, + hashed: true, + bindings: orgApiKeyBindings(), + }, authHeaders) const data = await response.json<{ key: string, key_hash: string, id: number }>() expect(response.status).toBe(200) expect(data).toHaveProperty('key') @@ -1300,15 +1317,11 @@ describe('[POST] /apikey hashed key operations', () => { it('create plain api key (hashed: false)', async () => { const keyName = 'test-plain-key-explicit' - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: keyName, - hashed: false, - bindings: orgApiKeyBindings(), - }), - }) + const response = await postApiKey({ + name: keyName, + hashed: false, + bindings: orgApiKeyBindings(), + }, authHeaders) const data = await response.json<{ key: string, key_hash: string | null, id: number }>() expect(response.status).toBe(200) expect(data).toHaveProperty('key') @@ -1342,15 +1355,11 @@ describe('[POST] /apikey hashed key operations', () => { }) it('create hashed api key with V2 bindings', async () => { - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-with-options', - hashed: true, - bindings: orgApiKeyBindings(), - }), - }) + const response = await postApiKey({ + name: 'hashed-key-with-options', + hashed: true, + bindings: orgApiKeyBindings(), + }, authHeaders) const data = await response.json<{ key: string, key_hash: string, id: number }>() expect(response.status).toBe(200) expect(data).toHaveProperty('key') @@ -1365,15 +1374,11 @@ describe('[POST] /apikey hashed key operations', () => { it('hashed key can be used for authentication', async () => { // Create a hashed key - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-for-auth-test', - hashed: true, - bindings: orgApiKeyBindings(), - }), - }) + const createResponse = await postApiKey({ + name: 'hashed-key-for-auth-test', + hashed: true, + bindings: orgApiKeyBindings(), + }, authHeaders) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) @@ -1402,16 +1407,12 @@ describe('[POST] /apikey hashed key operations', () => { describe('[POST] /apikey hashed key with expiration', () => { it('create hashed api key with expiration date', async () => { const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days from now - const response = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-with-expiration', - hashed: true, - expires_at: futureDate, - bindings: orgApiKeyBindings(), - }), - }) + const response = await postApiKey({ + name: 'hashed-key-with-expiration', + hashed: true, + expires_at: futureDate, + bindings: orgApiKeyBindings(), + }, authHeaders) const data = await response.json<{ key: string, key_hash: string, id: number, expires_at: string }>() expect(response.status).toBe(200) expect(data).toHaveProperty('key') @@ -1444,16 +1445,12 @@ describe('[POST] /apikey hashed key with expiration', () => { it('hashed key with expiration can be used for authentication', async () => { const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-expiration-auth-test', - hashed: true, - expires_at: futureDate, - bindings: orgApiKeyBindings(), - }), - }) + const createResponse = await postApiKey({ + name: 'hashed-key-expiration-auth-test', + hashed: true, + expires_at: futureDate, + bindings: orgApiKeyBindings(), + }, authHeaders) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) @@ -1479,16 +1476,12 @@ describe('[POST] /apikey hashed key with expiration', () => { it('expired hashed key should be rejected for authentication', async () => { // Create a hashed key with future expiration - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-to-expire', - hashed: true, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - bindings: orgApiKeyBindings(), - }), - }) + const createResponse = await postApiKey({ + name: 'hashed-key-to-expire', + hashed: true, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + bindings: orgApiKeyBindings(), + }, authHeaders) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) @@ -1514,15 +1507,11 @@ describe('[POST] /apikey hashed key with expiration', () => { describe('[RLS] hashed API key with direct Supabase SDK', () => { it('hashed key works with RLS via Supabase SDK (simulating CLI usage)', async () => { // Create a hashed key via API - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'hashed-key-rls-test', - hashed: true, - bindings: orgApiKeyBindings(), - }), - }) + const createResponse = await postApiKey({ + name: 'hashed-key-rls-test', + hashed: true, + bindings: orgApiKeyBindings(), + }, authHeaders) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) @@ -1565,15 +1554,11 @@ describe('[RLS] hashed API key with direct Supabase SDK', () => { it('plain key still works with RLS via Supabase SDK', async () => { // Create a plain (non-hashed) key via API - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'plain-key-rls-test', - hashed: false, - bindings: orgApiKeyBindings(), - }), - }) + const createResponse = await postApiKey({ + name: 'plain-key-rls-test', + hashed: false, + bindings: orgApiKeyBindings(), + }, authHeaders) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) @@ -1610,14 +1595,10 @@ describe('[RLS] hashed API key with direct Supabase SDK', () => { let createdKeyId: number | undefined try { - const createResponse = await fetch(`${BASE_URL}/apikey`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ - name: 'plain-key-rls-apikey-update-blocked', - hashed: false, - bindings: orgApiKeyBindings(), - }), + const createResponse = await postApiKey({ + name: 'plain-key-rls-apikey-update-blocked', + hashed: false, + bindings: orgApiKeyBindings(), }) const createData = await createResponse.json<{ key: string, id: number }>() expect(createResponse.status).toBe(200) diff --git a/tests/app.test.ts b/tests/app.test.ts index 4f066a1c44..13c83500b7 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -141,7 +141,7 @@ describe('[GET] /app operations with subkey', () => { it('should access app with subkey', async () => { // Access app with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } - const getAppWithSubkey = await fetch(`${BASE_URL}/app/${APPNAME}`, { + const getAppWithSubkey = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) @@ -154,7 +154,7 @@ describe('[GET] /app operations with subkey', () => { // Create another app const otherAppId = randomUUID() const OTHER_APPNAME = `com.other.subkey.${otherAppId}` - const createOtherApp = await fetch(`${BASE_URL}/app`, { + const createOtherApp = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify({ @@ -174,7 +174,7 @@ describe('[GET] /app operations with subkey', () => { // Try to access the other app with the subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } - const getOtherAppWithSubkey = await fetch(`${BASE_URL}/app/${OTHER_APPNAME}`, { + const getOtherAppWithSubkey = await fetchTestRequest(`${BASE_URL}/app/${OTHER_APPNAME}`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) @@ -190,7 +190,7 @@ describe('[GET] /app operations with subkey', () => { it('should update app with subkey', async () => { // Update app with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } - const updateApp = await fetch(`${BASE_URL}/app/${APPNAME}`, { + const updateApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers: { ...headers, ...subkeyHeaders }, body: JSON.stringify({ @@ -212,7 +212,7 @@ describe('[GET] /app operations with subkey', () => { appRoleName: 'app_reader', }) const subkeyHeaders = { 'x-limited-key-id': String(subkeyData.id) } - const deleteApp = await fetch(`${BASE_URL}/app/${APPNAME}`, { + const deleteApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'DELETE', headers: { ...headers, ...subkeyHeaders }, }) @@ -224,7 +224,7 @@ describe('[GET] /app operations with subkey', () => { it('should get all apps with subkey', async () => { // Get all apps with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } - const getAllApps = await fetch(`${BASE_URL}/app`, { + const getAllApps = await fetchTestRequest(`${BASE_URL}/app`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) @@ -236,7 +236,7 @@ describe('[GET] /app operations with subkey', () => { it('should get all apps without subkey', async () => { // Get all apps without subkey - const getAllApps = await fetch(`${BASE_URL}/app`, { + const getAllApps = await fetchTestRequest(`${BASE_URL}/app`, { method: 'GET', headers, }) diff --git a/tests/cron_stat_org.test.ts b/tests/cron_stat_org.test.ts index 9cb154908c..e4450efa47 100644 --- a/tests/cron_stat_org.test.ts +++ b/tests/cron_stat_org.test.ts @@ -526,4 +526,4 @@ describe('[POST] /triggers/cron_stat_org', () => { expect(bandwidthExceededErrorAfter).toBeFalsy() expect(bandwidthExceededAfter).toBe(false) }) -}) +}) \ No newline at end of file diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 9391ddd858..7cd273b330 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -69,7 +69,6 @@ const ANON_ALLOWED_PROCS = [ 'public.check_org_members_2fa_enabled(uuid)', 'public.check_org_members_password_policy(uuid)', 'public.get_org_members(uuid)', - 'public.get_org_members_rbac(uuid)', 'public.get_channel_current_bundle_rbac(character varying, bigint)', 'public.get_total_app_storage_size_orgs(uuid, character varying)', 'public.get_total_storage_size_org(uuid)', @@ -88,9 +87,14 @@ const ANON_ALLOWED_PROCS = [ 'public.reject_access_due_to_2fa_for_org(uuid)', 'public.request_actor_user_id()', 'public.get_user_id(text)', + 'public.verify_mfa()', +] as const + +const ANON_ORACLE_REVOKED_PROCS = [ + 'public.get_org_members_rbac(uuid)', + 'public.is_member_of_org(uuid, uuid)', 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_tmp_invite_role_rbac(uuid, text, text)', - 'public.verify_mfa()', ] as const const AUTHENTICATED_ONLY_PROCS = [ @@ -104,14 +108,18 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.get_account_removal_date()', 'public.get_app_access_rbac(uuid)', 'public.get_app_metrics(uuid)', + 'public.get_org_members_rbac(uuid)', '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.is_member_of_org(uuid, uuid)', 'public.rbac_check_permission(text, uuid, character varying, bigint)', 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', + 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_org_member_role(uuid, uuid, text)', + 'public.update_tmp_invite_role_rbac(uuid, text, text)', 'public.verify_email_otp_auth()', ] as const @@ -238,4 +246,17 @@ describe('security definer execute hardening', () => { expect(state?.auth_exec, proc).toBe(true) } }) + + it.concurrent('blocks anonymous execute on org/member oracle RPCs', async () => { + const states = await getProcStates(ANON_ORACLE_REVOKED_PROCS) + + expect(states.size).toBe(ANON_ORACLE_REVOKED_PROCS.length) + + for (const proc of ANON_ORACLE_REVOKED_PROCS) { + assertProcExists(states, proc) + const state = states.get(proc) + expect(state?.anon_exec, proc).toBe(false) + expect(state?.auth_exec, proc).toBe(true) + } + }) }) diff --git a/tests/security-oracle-rpc-hardening.test.ts b/tests/security-oracle-rpc-hardening.test.ts index 2fafbe45b5..dc6e09a4e4 100644 --- a/tests/security-oracle-rpc-hardening.test.ts +++ b/tests/security-oracle-rpc-hardening.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { APIKEY_TEST_ORG_SUPER_ADMIN, getAuthHeadersForCredentials, + getSupabaseClient, ORG_ID, USER_EMAIL, USER_ID, @@ -55,6 +56,14 @@ function isPermissionDenied(error: { code?: string, message?: string } | null) { return error?.code === '42501' || /permission denied/i.test(error?.message ?? '') } +function isNoRights(error: { message?: string } | null) { + return /NO_RIGHTS/i.test(error?.message ?? '') +} + +function isOrgNotFound(error: { message?: string } | null) { + return /ORG_NOT_FOUND/i.test(error?.message ?? '') +} + describe('anonymous oracle RPC hardening', () => { it.concurrent('blocks invite_user_to_org_rbac for anonymous callers', async () => { const client = createAnonymousClient() @@ -129,6 +138,69 @@ describe('anonymous oracle RPC hardening', () => { expect(invalidKeyResult.data).toBeNull() }) + it.concurrent('blocks anonymous execute on member/org oracle RPCs', async () => { + const client = createAnonymousClient() + const missingOrgId = randomUUID() + + const revokedCalls = await Promise.all([ + client.rpc('get_org_members_rbac', { p_org_id: ORG_ID }), + client.rpc('get_org_members_rbac', { p_org_id: missingOrgId }), + client.rpc('is_member_of_org', { user_id: USER_ID, org_id: ORG_ID }), + client.rpc('is_member_of_org', { user_id: USER_ID, org_id: missingOrgId }), + client.rpc('update_org_invite_role_rbac', { + p_org_id: ORG_ID, + p_user_id: USER_ID, + p_new_role_name: 'org_member', + }), + client.rpc('update_tmp_invite_role_rbac', { + p_org_id: ORG_ID, + p_email: 'oracle-test@capgo.app', + p_new_role_name: 'org_member', + }), + ]) + + for (const result of revokedCalls) { + expect(isPermissionDenied(result.error)).toBe(true) + expect(result.data).toBeNull() + } + }) + + it.concurrent('does not leak org existence through check_org_members_2fa_enabled', async () => { + const client = createAnonymousClient() + const missingOrgId = randomUUID() + + const existingOrgResult = await client.rpc('check_org_members_2fa_enabled', { + org_id: ORG_ID, + }) + const missingOrgResult = await client.rpc('check_org_members_2fa_enabled', { + org_id: missingOrgId, + }) + + expect(isNoRights(existingOrgResult.error)).toBe(true) + expect(isNoRights(missingOrgResult.error)).toBe(true) + expect(existingOrgResult.error?.message).toBe(missingOrgResult.error?.message) + expect(existingOrgResult.data).toBeNull() + expect(missingOrgResult.data).toBeNull() + }) + + it.concurrent('does not leak org existence through check_org_members_password_policy', async () => { + const client = createAnonymousClient() + const missingOrgId = randomUUID() + + const existingOrgResult = await client.rpc('check_org_members_password_policy', { + org_id: ORG_ID, + }) + const missingOrgResult = await client.rpc('check_org_members_password_policy', { + org_id: missingOrgId, + }) + + expect(isNoRights(existingOrgResult.error)).toBe(true) + expect(isNoRights(missingOrgResult.error)).toBe(true) + expect(existingOrgResult.error?.message).toBe(missingOrgResult.error?.message) + expect(existingOrgResult.data).toBeNull() + expect(missingOrgResult.data).toBeNull() + }) + it.concurrent('keeps invite_user_to_org_rbac callable for authenticated callers', async () => { const authHeaders = await getAuthHeadersForCredentials(USER_EMAIL, USER_PASSWORD) const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { @@ -150,4 +222,113 @@ describe('anonymous oracle RPC hardening', () => { // Unknown email is read-only and proves authenticated execute still works. expect(data).toBe('NO_EMAIL') }) + + it.concurrent('keeps revoked member/org oracle RPCs callable for authenticated callers', async () => { + const authHeaders = await getAuthHeadersForCredentials(USER_EMAIL, USER_PASSWORD) + const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: authHeaders, + }, + auth: { + persistSession: false, + }, + }) + + const members = await client.rpc('get_org_members_rbac', { p_org_id: ORG_ID }) + expect(isPermissionDenied(members.error)).toBe(false) + expect(members.error).toBeNull() + expect(Array.isArray(members.data)).toBe(true) + expect((members.data ?? []).length).toBeGreaterThan(0) + + const membership = await client.rpc('is_member_of_org', { + user_id: USER_ID, + org_id: ORG_ID, + }) + expect(isPermissionDenied(membership.error)).toBe(false) + expect(membership.error).toBeNull() + expect(membership.data).toBe(true) + + // Missing invite/user must not be EXECUTE denial — proves re-grant path. + const missingUserId = randomUUID() + const missingInviteEmail = `oracle-missing-invite-${randomUUID()}@capgo.app` + const orgInviteUpdate = await client.rpc('update_org_invite_role_rbac', { + p_org_id: ORG_ID, + p_user_id: missingUserId, + p_new_role_name: 'org_member', + }) + const tmpInviteUpdate = await client.rpc('update_tmp_invite_role_rbac', { + p_org_id: ORG_ID, + p_email: missingInviteEmail, + p_new_role_name: 'org_member', + }) + + expect(isPermissionDenied(orgInviteUpdate.error)).toBe(false) + expect(isPermissionDenied(tmpInviteUpdate.error)).toBe(false) + + const orgInviteOutcome = orgInviteUpdate.error?.message ?? orgInviteUpdate.data + const tmpInviteOutcome = tmpInviteUpdate.error?.message ?? tmpInviteUpdate.data + expect(String(orgInviteOutcome)).toMatch(/NO_INVITATION|ROLE_NOT_FOUND/i) + expect(String(tmpInviteOutcome)).toMatch(/NO_INVITATION|ROLE_NOT_FOUND/i) + }) }) + +describe('internal missing-org distinction on org-member helpers', () => { + it.concurrent('raises ORG_NOT_FOUND for service_role on missing org (2fa)', async () => { + const client = getSupabaseClient() + const missingOrgId = randomUUID() + + const missing = await client.rpc('check_org_members_2fa_enabled', { + org_id: missingOrgId, + }) + expect(isOrgNotFound(missing.error)).toBe(true) + expect(missing.data).toBeNull() + + const existing = await client.rpc('check_org_members_2fa_enabled', { + org_id: ORG_ID, + }) + expect(existing.error).toBeNull() + expect(Array.isArray(existing.data)).toBe(true) + }) + + it.concurrent('raises ORG_NOT_FOUND for service_role on missing org (password policy)', async () => { + const client = getSupabaseClient() + const missingOrgId = randomUUID() + + const missing = await client.rpc('check_org_members_password_policy', { + org_id: missingOrgId, + }) + expect(isOrgNotFound(missing.error)).toBe(true) + expect(missing.data).toBeNull() + + const existing = await client.rpc('check_org_members_password_policy', { + org_id: ORG_ID, + }) + expect(existing.error).toBeNull() + expect(Array.isArray(existing.data)).toBe(true) + }) + + it.concurrent('keeps authenticated missing-org as NO_RIGHTS (anon oracle closed)', async () => { + const authHeaders = await getAuthHeadersForCredentials(USER_EMAIL, USER_PASSWORD) + const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: authHeaders, + }, + auth: { + persistSession: false, + }, + }) + const missingOrgId = randomUUID() + + const twoFa = await client.rpc('check_org_members_2fa_enabled', { + org_id: missingOrgId, + }) + const password = await client.rpc('check_org_members_password_policy', { + org_id: missingOrgId, + }) + + expect(isNoRights(twoFa.error)).toBe(true) + expect(isNoRights(password.error)).toBe(true) + expect(isOrgNotFound(twoFa.error)).toBe(false) + expect(isOrgNotFound(password.error)).toBe(false) + }) +}) \ No newline at end of file