diff --git a/supabase/functions/_backend/public/organization/members/get.ts b/supabase/functions/_backend/public/organization/members/get.ts index e6dbcb02e1..ed6a925659 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(), @@ -37,17 +37,27 @@ 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, 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.') + } + 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 { data, error } = await supabase .rpc('get_org_members', { - user_id: apikey.user_id, + user_id: effectiveApikey.user_id, guild_id: body.orgId, }) 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 2b50a8016c..f510ef7693 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -222,6 +222,32 @@ 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 }, + 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 },