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
24 changes: 22 additions & 2 deletions supabase/functions/_backend/private/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TrackOptions } from '@logsnag/node'
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { Hono } from 'hono/tiny'
import { trackBentoEvent } from '../utils/bento.ts'
Expand All @@ -7,17 +8,36 @@
import { logsnag } from '../utils/logsnag.ts'
import { trackPosthogEvent } from '../utils/posthog.ts'
import { broadcastCLIEvent } from '../utils/realtime_broadcast.ts'
import { supabaseWithAuth } from '../utils/supabase.ts'
import { hasOrgRight, hasOrgRightApikey, supabaseWithAuth } from '../utils/supabase.ts'
import { backgroundTask } from '../utils/utils.ts'

export const app = new Hono<MiddlewareKeyVariables>()

app.use('/', useCors)

async function canAccessRequestedOrg(c: Context<MiddlewareKeyVariables>, orgId: string) {
const auth = c.get('auth')
if (!auth?.userId || !orgId)
return false

if (auth.authType === 'apikey')
return hasOrgRightApikey(c, orgId, auth.userId, 'read', c.get('capgkey'))

return hasOrgRight(c, orgId, auth.userId, 'read')
}

app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {

Check failure on line 29 in supabase/functions/_backend/private/events.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZz3wvKUyADDpoK7GBDS&open=AZz3wvKUyADDpoK7GBDS&pullRequest=1802
const body = await parseBody<TrackOptions & { notifyConsole?: boolean }>(c)
const requestedOrgId = body.notifyConsole && typeof body.user_id === 'string' && body.user_id.length > 0
? body.user_id
: undefined

if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId)))
return c.json({ error: 'Forbidden' }, 403)

const orgId = body.user_id ?? c.get('auth')?.userId ?? ''
const orgId = typeof body.user_id === 'string' && body.user_id.length > 0
? body.user_id
: c.get('auth')?.userId ?? ''

// notifyConsole: broadcast to Supabase Realtime only, skip all tracking
if (body.notifyConsole) {
Expand Down
81 changes: 80 additions & 1 deletion tests/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { APP_NAME, BASE_URL, headers, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts'
import { APP_NAME, BASE_URL, getAuthHeaders, headers, ORG_ID, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts'

const id = randomUUID()
const APPNAME_EVENT = `${APP_NAME}.e.${id}`
const FOREIGN_ORG_ID = randomUUID()

beforeAll(async () => {
await Promise.all([resetAndSeedAppData(APPNAME_EVENT), resetAndSeedAppDataStats(APPNAME_EVENT)])
Expand Down Expand Up @@ -38,6 +39,65 @@ describe('[POST] /private/events operations', () => {
expect(data.status).toBe('ok')
})

it('rejects apikey attempts to broadcast events to a foreign org', async () => {
const response = await fetch(`${BASE_URL}/private/events`, {
method: 'POST',
headers: {
capgkey: headers.Authorization,
},
body: JSON.stringify({
channel: 'test',
event: 'test_event',
description: 'Testing event tracking',
notifyConsole: true,
user_id: FOREIGN_ORG_ID,
}),
})

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

it('allows apikey broadcasts for an authorized org', async () => {
const response = await fetch(`${BASE_URL}/private/events`, {
method: 'POST',
headers: {
capgkey: headers.Authorization,
},
body: JSON.stringify({
channel: 'test',
event: 'test_event',
description: 'Testing event tracking',
notifyConsole: true,
user_id: ORG_ID,
}),
})

const data = await response.json() as { status: string }
expect(response.status).toBe(200)
expect(data.status).toBe('ok')
})

it('allows jwt broadcasts for an authorized org', async () => {
const authHeaders = await getAuthHeaders()
const response = await fetch(`${BASE_URL}/private/events`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
channel: 'test',
event: 'test_event',
description: 'Testing event tracking',
notifyConsole: true,
user_id: ORG_ID,
}),
})

const data = await response.json() as { status: string }
expect(response.status).toBe(200)
expect(data.status).toBe('ok')
})

// Skip JWT test as it requires auth infrastructure that may not be reliably available
// The important test is that API key auth works, which is covered above
it.skip('track event with authorization jwt', async () => {
Expand All @@ -46,6 +106,25 @@ describe('[POST] /private/events operations', () => {
// the main authentication path.
})

it('rejects jwt attempts to broadcast events to a foreign org', async () => {
const authHeaders = await getAuthHeaders()
const response = await fetch(`${BASE_URL}/private/events`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
channel: 'test',
event: 'test_event',
description: 'Testing event tracking',
notifyConsole: true,
user_id: FOREIGN_ORG_ID,
}),
})

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

it('track event without authentication', async () => {
const response = await fetch(`${BASE_URL}/private/events`, {
method: 'POST',
Expand Down
Loading