Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 14 additions & 4 deletions supabase/functions/_backend/public/organization/members/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -37,17 +37,27 @@ export async function get(c: Context<MiddlewareKeyVariables>, 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,
})

Expand Down
12 changes: 9 additions & 3 deletions supabase/functions/_backend/utils/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1592,14 +1592,20 @@ export async function checkApikeyMeetsOrgPolicy(
orgId: string,
supabase: SupabaseClient<Database>,
): 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) {
Expand Down
26 changes: 26 additions & 0 deletions tests/organization-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading