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
29 changes: 26 additions & 3 deletions supabase/functions/_backend/public/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Context } from 'hono'
import type { AuthInfo, MiddlewareKeyVariables } from '../../utils/hono.ts'
import type { Database } from '../../utils/supabase.types.ts'
import { getBodyOrQuery, honoFactory, simpleError } from '../../utils/hono.ts'
import { getBodyOrQuery, honoFactory, quickError, simpleError } from '../../utils/hono.ts'
import { middlewareKey, middlewareV2 } from '../../utils/hono_middleware.ts'
import { apikeyHasOrgRight, hasOrgRight, hasOrgRightApikey } from '../../utils/supabase.ts'
import { apikeyHasOrgRight, apikeyHasOrgRightWithPolicy, hasOrgRight, hasOrgRightApikey, supabaseApikey } from '../../utils/supabase.ts'
import { deleteWebhook } from './delete.ts'
import { getDeliveries, retryDelivery } from './deliveries.ts'
import { get } from './get.ts'
Expand Down Expand Up @@ -35,11 +35,21 @@ export async function checkWebhookPermission(
orgId: string,
apikey: Database['public']['Tables']['apikeys']['Row'],
): Promise<void> {
const orgCheck = await apikeyHasOrgRightWithPolicy(c, apikey, orgId, supabaseApikey(c, c.get('capgkey') as string))
Comment thread
riderx marked this conversation as resolved.
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('invalid_org_id', 'You can\'t access this organization', { org_id: orgId })
}

if (!(await hasOrgRightApikey(c, orgId, apikey.user_id, 'admin', c.get('capgkey') as string))) {
throw simpleError('no_permission', 'You need admin access to manage webhooks', { org_id: orgId })
}

assertOrgWebhookScope(orgId, apikey)
if (apikey.limited_to_apps?.length) {
throw simpleError('no_permission', 'App-scoped API keys cannot manage organization webhooks', { org_id: orgId })
}
}

/**
Expand All @@ -51,6 +61,19 @@ export async function checkWebhookPermissionV2(
orgId: string,
auth: AuthInfo,
): Promise<void> {
const parentApikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] | undefined
const policyApikey = parentApikey ?? auth.apikey

if (auth.authType === 'apikey' && policyApikey) {
const orgCheck = await apikeyHasOrgRightWithPolicy(c, policyApikey, orgId, supabaseApikey(c, c.get('capgkey') as string))
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('invalid_org_id', 'You can\'t access this organization', { org_id: orgId })
}
}

// Check org admin access
if (!(await hasOrgRight(c, orgId, auth.userId, 'admin'))) {
throw simpleError('no_permission', 'You need admin access to manage webhooks', { org_id: orgId })
Expand Down
30 changes: 15 additions & 15 deletions tests/organization-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,21 @@ describe('read-mode API keys cannot access destructive organization routes', ()
expect(response.status).toBe(401)
})

it.concurrent('rejects POST /organization', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers: { ...readOnlyHeaders, capgkey: readOnlyKey },
method: 'POST',
body: JSON.stringify({
orgId: readOnlyOrgId,
name: `Blocked create ${randomUUID()}`,
}),
})

expect(response.status).toBe(401)
// Ensure this is blocked by API-key auth (key mode allowlist), not by RLS deeper in the handler.
const payload = await response.json() as { error?: string }
expect(payload.error).toBe('invalid_apikey')
})
it.concurrent('rejects POST /organization', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers: { ...readOnlyHeaders, capgkey: readOnlyKey },
method: 'POST',
body: JSON.stringify({
orgId: readOnlyOrgId,
name: `Blocked create ${randomUUID()}`,
}),
})

expect(response.status).toBe(401)
// Ensure this is blocked by API-key auth (key mode allowlist), not by RLS deeper in the handler.
const payload = await response.json() as { error?: string }
expect(payload.error).toBe('invalid_apikey')
})

it.concurrent('rejects DELETE /organization', async () => {
const response = await fetch(`${BASE_URL}/organization?orgId=${readOnlyOrgId}`, {
Expand Down
273 changes: 273 additions & 0 deletions tests/webhooks-apikey-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getEndpointUrl, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts'

const globalId = randomUUID()
const policyOrgId = randomUUID()
const policyCustomerId = `cus_webhook_policy_${globalId}`
const APIKEY_URL = getEndpointUrl('/apikey')
const WEBHOOKS_URL = getEndpointUrl('/webhooks')
const WEBHOOKS_TEST_URL = getEndpointUrl('/webhooks/test')
const WEBHOOKS_RETRY_URL = getEndpointUrl('/webhooks/deliveries/retry')

let legacyApiKeyId: number | null = null
let legacyApiKeyValue: string | null = null
let expiringSubkeyId: number | null = null
let expiringSubkeyValue: string | null = null
let createdWebhookId: string | null = null
let createdDeliveryId: string | null = null

beforeAll(async () => {
const supabase = getSupabaseClient()

const { error: stripeError } = await supabase.from('stripe_info').insert({
customer_id: policyCustomerId,
status: 'succeeded',
product_id: 'prod_LQIregjtNduh4q',
subscription_id: `sub_webhook_policy_${globalId}`,
trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
is_good_plan: true,
})
if (stripeError)
throw stripeError

const { error: orgError } = await supabase.from('orgs').insert({
id: policyOrgId,
name: `Webhook Policy Org ${globalId}`,
management_email: TEST_EMAIL,
created_by: USER_ID,
customer_id: policyCustomerId,
})
if (orgError)
throw orgError

const { error: memberError } = await supabase.from('org_users').insert({
org_id: policyOrgId,
user_id: USER_ID,
user_right: 'super_admin',
})
if (memberError)
throw memberError

const keyResponse = await fetch(APIKEY_URL, {
method: 'POST',
headers,
body: JSON.stringify({
name: `legacy-webhook-key-${globalId}`,
limited_to_orgs: [policyOrgId],
}),
})
expect(keyResponse.status).toBe(200)
const keyData = await keyResponse.json() as { id: number, key: string }
legacyApiKeyId = keyData.id
legacyApiKeyValue = keyData.key

const webhookResponse = await fetch(WEBHOOKS_URL, {
method: 'POST',
headers,
body: JSON.stringify({
orgId: policyOrgId,
name: `policy-webhook-${globalId}`,
url: 'https://example.com/webhook-policy',
events: ['orgs'],
}),
})
expect(webhookResponse.status).toBe(201)
const webhookData = await webhookResponse.json() as { webhook: { id: string } }
createdWebhookId = webhookData.webhook.id

const testWebhookResponse = await fetch(WEBHOOKS_TEST_URL, {
method: 'POST',
headers,
body: JSON.stringify({
orgId: policyOrgId,
webhookId: createdWebhookId,
}),
})
expect(testWebhookResponse.status).toBe(200)
const testWebhookData = await testWebhookResponse.json() as { delivery_id: string }
createdDeliveryId = testWebhookData.delivery_id

const { error: policyError } = await supabase.from('orgs').update({
require_apikey_expiration: true,
max_apikey_expiration_days: 30,
}).eq('id', policyOrgId)
if (policyError)
throw policyError

const subkeyResponse = await fetch(APIKEY_URL, {
method: 'POST',
headers,
body: JSON.stringify({
name: `expiring-webhook-subkey-${globalId}`,
limited_to_orgs: [policyOrgId],
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
}),
})
expect(subkeyResponse.status).toBe(200)
const subkeyData = await subkeyResponse.json() as { id: number, key: string }
expiringSubkeyId = subkeyData.id
expiringSubkeyValue = subkeyData.key
}, 60000)

afterAll(async () => {
const supabase = getSupabaseClient()

if (createdWebhookId) {
await (supabase as any).from('webhooks').delete().eq('id', createdWebhookId)
}

if (legacyApiKeyId) {
await supabase.from('apikeys').delete().eq('id', legacyApiKeyId)
}

if (expiringSubkeyId) {
await supabase.from('apikeys').delete().eq('id', expiringSubkeyId)
}

await supabase.from('org_users').delete().eq('org_id', policyOrgId)
await supabase.from('orgs').delete().eq('id', policyOrgId)
await supabase.from('stripe_info').delete().eq('customer_id', policyCustomerId)
}, 60000)

describe('webhook endpoints enforce org API key expiration policy', () => {
it('rejects webhook listing for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue)
throw new Error('Legacy API key was not created')

const response = await fetch(`${WEBHOOKS_URL}?orgId=${policyOrgId}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
},
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('rejects webhook creation for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue)
throw new Error('Legacy API key was not created')

const response = await fetch(WEBHOOKS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
},
body: JSON.stringify({
orgId: policyOrgId,
name: `blocked-webhook-${globalId}`,
url: 'https://example.com/blocked-webhook',
events: ['orgs'],
}),
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('rejects webhook deletion for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue || !createdWebhookId)
throw new Error('Webhook deletion prerequisites were not created')

const response = await fetch(WEBHOOKS_URL, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
},
body: JSON.stringify({
orgId: policyOrgId,
webhookId: createdWebhookId,
}),
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('rejects webhook test for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue || !createdWebhookId)
throw new Error('Webhook test prerequisites were not created')

const response = await fetch(WEBHOOKS_TEST_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
},
body: JSON.stringify({
orgId: policyOrgId,
webhookId: createdWebhookId,
}),
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('rejects delivery retry for legacy non-expiring org key', async () => {
if (!legacyApiKeyValue || !createdDeliveryId)
throw new Error('Webhook delivery prerequisites were not created')

const response = await fetch(WEBHOOKS_RETRY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
},
body: JSON.stringify({
orgId: policyOrgId,
deliveryId: createdDeliveryId,
}),
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('rejects webhook test when a legacy parent key attaches an expiring subkey', async () => {
if (!legacyApiKeyValue || !expiringSubkeyId || !createdWebhookId)
throw new Error('Webhook subkey policy prerequisites were not created')

const response = await fetch(WEBHOOKS_TEST_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': legacyApiKeyValue,
'x-limited-key-id': String(expiringSubkeyId),
},
body: JSON.stringify({
orgId: policyOrgId,
webhookId: createdWebhookId,
}),
})

expect(response.status).toBe(401)
const data = await response.json() as { error: string }
expect(data.error).toBe('org_requires_expiring_key')
})

it('allows webhook listing for a compliant expiring org key', async () => {
if (!expiringSubkeyValue)
throw new Error('Expiring API key was not created')

const response = await fetch(`${WEBHOOKS_URL}?orgId=${policyOrgId}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': expiringSubkeyValue,
},
})

expect(response.status).toBe(200)
const data = await response.json()
expect(Array.isArray(data)).toBe(true)
})
})
2 changes: 1 addition & 1 deletion tests/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ afterAll(async () => {
// Clean up test organization and stripe_info
await getSupabaseClient().from('orgs').delete().eq('id', WEBHOOK_TEST_ORG_ID)
await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId)
})
}, 60000)

describe('[GET] /webhooks', () => {
it('list webhooks for organization', async () => {
Expand Down
Loading