From 7485addb1c528d6893c6ad3ad9e9a73cc74c9c7c Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 26 Mar 2026 22:15:11 +0100 Subject: [PATCH 1/3] fix(api): enforce org scope on organization members --- .../_backend/public/organization/members/get.ts | 14 +++++++++++--- tests/organization-api.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_backend/public/organization/members/get.ts b/supabase/functions/_backend/public/organization/members/get.ts index e6dbcb02e1..e0bbaa6d68 100644 --- a/supabase/functions/_backend/public/organization/members/get.ts +++ b/supabase/functions/_backend/public/organization/members/get.ts @@ -2,11 +2,11 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../../utils/hono.ts' import type { Database } from '../../../utils/supabase.types.ts' import { z } from 'zod/mini' -import { simpleError } from '../../../utils/hono.ts' +import { quickError, simpleError } from '../../../utils/hono.ts' import { cloudlog } from '../../../utils/logging.ts' import { checkPermission } from '../../../utils/rbac.ts' import { createSignedImageUrl } from '../../../utils/storage.ts' -import { supabaseApikey } from '../../../utils/supabase.ts' +import { apikeyHasOrgRightWithPolicy, supabaseApikey } from '../../../utils/supabase.ts' const bodySchema = z.object({ orgId: z.string(), @@ -43,8 +43,16 @@ export async function get(c: Context, bodyRaw: any, apik throw simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: body.orgId }) } - // Use authenticated client for data queries - RLS will enforce access const supabase = supabaseApikey(c, apikey.key) + const orgCheck = await apikeyHasOrgRightWithPolicy(c, apikey, body.orgId, supabase) + if (!orgCheck.valid) { + if (orgCheck.error === 'org_requires_expiring_key') { + throw quickError(401, 'org_requires_expiring_key', 'This organization requires API keys with an expiration date. Please use a different key or update this key with an expiration date.') + } + throw simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: body.orgId }) + } + + // Use authenticated client for data queries - RLS will enforce access const { data, error } = await supabase .rpc('get_org_members', { user_id: apikey.user_id, diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index 2b50a8016c..e6d645d814 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -222,6 +222,17 @@ describe('read-mode API keys cannot access destructive organization routes', () expect(members.parse(await response.json()).some(member => member.uid === USER_ID)).toBe(true) }) + it.concurrent('rejects GET /organization/members outside limited_to_orgs scope', async () => { + const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID}`, { + headers: { ...readOnlyHeaders, capgkey: readOnlyKey }, + method: 'GET', + }) + + expect(response.status).toBe(400) + const payload = await response.json() as { error: string } + expect(payload.error).toBe('cannot_access_organization') + }) + it.concurrent('allows GET /organization/audit for accessible organizations', async () => { const response = await fetch(`${BASE_URL}/organization/audit?orgId=${readOnlyOrgId}`, { headers: { ...readOnlyHeaders, capgkey: readOnlyKey }, From e7e9042329834f36fd601f744ff2e6aa06bf14b8 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 27 Mar 2026 13:58:38 +0100 Subject: [PATCH 2/3] fix(api): enforce org scope for members --- .../_backend/public/organization/members/get.ts | 6 ++++-- supabase/functions/_backend/utils/supabase.ts | 12 +++++++++--- tests/organization-api.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/supabase/functions/_backend/public/organization/members/get.ts b/supabase/functions/_backend/public/organization/members/get.ts index e0bbaa6d68..35c1146f73 100644 --- a/supabase/functions/_backend/public/organization/members/get.ts +++ b/supabase/functions/_backend/public/organization/members/get.ts @@ -37,14 +37,16 @@ export async function get(c: Context, bodyRaw: any, apik throw simpleError('invalid_body', 'Invalid body', { error: bodyParsed.error }) } const body = bodyParsed.data + const auth = c.get('auth') as { apikey?: Database['public']['Tables']['apikeys']['Row'] } | undefined + const effectiveApikey = auth?.apikey ?? apikey // Auth context is already set by middlewareKey if (!(await checkPermission(c, 'org.read_members', { orgId: body.orgId }))) { throw simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: body.orgId }) } - const supabase = supabaseApikey(c, apikey.key) - const orgCheck = await apikeyHasOrgRightWithPolicy(c, apikey, body.orgId, supabase) + const supabase = supabaseApikey(c, effectiveApikey.key) + const orgCheck = await apikeyHasOrgRightWithPolicy(c, effectiveApikey, body.orgId, supabase) if (!orgCheck.valid) { if (orgCheck.error === 'org_requires_expiring_key') { throw quickError(401, 'org_requires_expiring_key', 'This organization requires API keys with an expiration date. Please use a different key or update this key with an expiration date.') diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 4e2ad157a4..e6a5185d6e 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1592,14 +1592,20 @@ export async function checkApikeyMeetsOrgPolicy( orgId: string, supabase: SupabaseClient, ): Promise<{ valid: boolean, error?: string }> { - const { data: org } = await supabase + const { data: org, error } = await supabase .from('orgs') .select('require_apikey_expiration') .eq('id', orgId) .single() - if (!org) { - return { valid: true } + if (error || !org) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'checkApikeyMeetsOrgPolicy: unable to load org policy', + error, + orgId, + }) + return { valid: false, error: 'org_policy_lookup_failed' } } if (org.require_apikey_expiration && !key.expires_at) { diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index e6d645d814..f510ef7693 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -222,6 +222,21 @@ describe('read-mode API keys cannot access destructive organization routes', () expect(members.parse(await response.json()).some(member => member.uid === USER_ID)).toBe(true) }) + it.concurrent('allows GET /organization/members for same user without org scope limits', async () => { + const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID}`, { + headers, + method: 'GET', + }) + + expect(response.status).toBe(200) + const members = z.array(z.object({ + uid: z.string(), + email: z.string(), + role: z.string(), + })) + expect(members.parse(await response.json()).some(member => member.uid === USER_ID)).toBe(true) + }) + it.concurrent('rejects GET /organization/members outside limited_to_orgs scope', async () => { const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID}`, { headers: { ...readOnlyHeaders, capgkey: readOnlyKey }, From 1b2564e57631d9ad45722a4ee447339d16623e10 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 29 Mar 2026 10:50:22 +0200 Subject: [PATCH 3/3] fix: use effective api key for org members --- supabase/functions/_backend/public/organization/members/get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/_backend/public/organization/members/get.ts b/supabase/functions/_backend/public/organization/members/get.ts index 35c1146f73..ed6a925659 100644 --- a/supabase/functions/_backend/public/organization/members/get.ts +++ b/supabase/functions/_backend/public/organization/members/get.ts @@ -57,7 +57,7 @@ export async function get(c: Context, bodyRaw: any, apik // Use authenticated client for data queries - RLS will enforce access const { data, error } = await supabase .rpc('get_org_members', { - user_id: apikey.user_id, + user_id: effectiveApikey.user_id, guild_id: body.orgId, })