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
58 changes: 51 additions & 7 deletions supabase/functions/_backend/public/bundle/set_channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../../utils/hono.ts'
import type { Database } from '../../utils/supabase.types.ts'
import { quickError, simpleError } from '../../utils/hono.ts'
import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { supabaseApikey } from '../../utils/supabase.ts'
import { isValidAppId } from '../../utils/utils.ts'
Expand Down Expand Up @@ -64,15 +65,58 @@ export async function setChannel(c: Context<MiddlewareKeyVariables>, body: SetCh
throw simpleError('cannot_find_channel', 'Cannot find channel', { supabaseError: channelError })
}

const effectiveApikey = apikey.key ?? c.get('capgkey')
if (!effectiveApikey) {
throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: 'Missing API key context for audit logging' })
}

// Update the channel to set the new version
const { error: updateError } = await supabaseApikey(c, apikey.key)
.from('channels')
.update({ version: body.version_id })
.eq('id', body.channel_id)
.eq('app_id', body.app_id)
// Keep the supported write-scoped /bundle flow working after explicit RBAC
// and ownership checks while preserving API-key identity for audit triggers.
const pgClient = getPgClient(c)
let dbClient: {
query: (text: string, params?: unknown[]) => Promise<{ rowCount?: number | null }>
release: () => void
} | null = null
try {
dbClient = await pgClient.connect()
await dbClient.query('BEGIN')
await dbClient.query(
'SELECT set_config(\'request.headers\', $1, true)',
[JSON.stringify({ capgkey: effectiveApikey })],
)

if (updateError) {
throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { supabaseError: updateError })
const updateResult = await dbClient.query(
`UPDATE public.channels
SET version = $1
WHERE id = $2
AND app_id = $3
AND owner_org = $4
RETURNING id`,
[body.version_id, body.channel_id, body.app_id, org.owner_org],
)

if ((updateResult.rowCount ?? 0) !== 1) {
throw new Error('Channel update affected 0 rows')
}

await dbClient.query('COMMIT')
}
catch (error) {
if (dbClient) {
try {
await dbClient.query('ROLLBACK')
}
catch {
// Ignore rollback failures to preserve the original database error.
}
}
logPgError(c, 'set_channel_update', error)
throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: (error as Error)?.message })
}
finally {
dbClient?.release()
await closeClient(c, pgClient)
}

return c.json({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Block direct PostgREST channel updates for write-scoped API keys.
-- Authenticated users keep their existing write access, and all-scoped API keys
-- still retain the direct channel update behavior expected by the CLI.

DROP POLICY IF EXISTS "Allow update for auth, api keys (write, all) (write+)" ON public.channels;

CREATE POLICY "Allow update for auth, api keys (write, all) (write+)" ON public.channels
FOR UPDATE
TO anon, authenticated
USING (
public.check_min_rights(
'write'::public.user_min_right,
public.get_identity_org_appid('{all}'::public.key_mode[], owner_org, app_id),
Comment thread
riderx marked this conversation as resolved.
owner_org,
app_id,
NULL::bigint
)
)
WITH CHECK (
public.check_min_rights(
'write'::public.user_min_right,
public.get_identity_org_appid('{all}'::public.key_mode[], owner_org, app_id),
owner_org,
app_id,
NULL::bigint
)
);
110 changes: 109 additions & 1 deletion tests/audit-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ beforeAll(async () => {

const { data: apiKeyData, error: apiKeyError } = await getSupabaseClient().rpc('create_hashed_apikey_for_user', {
p_user_id: USER_ID,
p_mode: 'all',
p_mode: 'write',
p_name: `audit-api-key-${globalId}`,
p_limited_to_orgs: [ORG_ID],
p_limited_to_apps: [APIKEY_AUDIT_APP_ID],
Expand Down Expand Up @@ -583,3 +583,111 @@ describe('audit logs for app_versions via API key', () => {
}
})
})

describe('audit logs for channel promotions via API key bundle flow', () => {
const sourceVersionName = `99.0.0-audit-channel-source-${randomUUID()}`
const targetVersionName = `99.0.0-audit-channel-target-${randomUUID()}`
const channelName = `audit-channel-${randomUUID()}`
let sourceVersionId: number | null = null
let targetVersionId: number | null = null
let channelId: number | null = null

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

const { data: insertedVersions, error: versionError } = await supabase
.from('app_versions')
.insert([
{
app_id: APIKEY_AUDIT_APP_ID,
name: sourceVersionName,
owner_org: ORG_ID,
},
{
app_id: APIKEY_AUDIT_APP_ID,
name: targetVersionName,
owner_org: ORG_ID,
},
])
.select('id,name')

if (versionError || !insertedVersions || insertedVersions.length !== 2) {
throw new Error(`Failed to create audit channel versions: ${versionError?.message ?? 'missing versions'}`)
}

sourceVersionId = insertedVersions.find(version => version.name === sourceVersionName)?.id ?? null
targetVersionId = insertedVersions.find(version => version.name === targetVersionName)?.id ?? null
if (sourceVersionId === null || targetVersionId === null) {
throw new Error('Failed to resolve audit channel version IDs')
}

const { data: insertedChannel, error: channelError } = await supabase
.from('channels')
.insert({
name: channelName,
app_id: APIKEY_AUDIT_APP_ID,
version: sourceVersionId,
created_by: USER_ID,
owner_org: ORG_ID,
})
.select('id')
.single()

if (channelError || !insertedChannel) {
throw new Error(`Failed to create audit channel: ${channelError?.message ?? 'missing channel'}`)
}

channelId = insertedChannel.id
})

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

if (channelId !== null) {
await supabase.from('channels').delete().eq('id', channelId)
await supabase.from('audit_logs').delete().eq('record_id', channelId.toString()).eq('table_name', 'channels')
}

if (sourceVersionId !== null) {
await supabase.from('app_versions').delete().eq('id', sourceVersionId)
}

if (targetVersionId !== null) {
await supabase.from('app_versions').delete().eq('id', targetVersionId)
}
})

it('channel UPDATE via API key bundle promotion keeps audit user_id attribution', async () => {
if (!channelId || !targetVersionId) {
throw new Error('Audit channel promotion test setup did not complete')
}
const promotionChannelId = channelId

const response = await fetchWithRetry(`${BASE_URL}/bundle`, {
method: 'PUT',
headers: apiKeyAuthHeaders,
body: JSON.stringify({
app_id: APIKEY_AUDIT_APP_ID,
version_id: targetVersionId,
channel_id: promotionChannelId,
}),
})

expect(response.status).toBe(200)

const promotionAuditLog = await waitForAuditLog(
`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=channels&operation=UPDATE`,
log => log.record_id === promotionChannelId.toString() && log.changed_fields?.includes('version') === true,
)

expect(promotionAuditLog.operation).toBe('UPDATE')
expect(promotionAuditLog.table_name).toBe('channels')
expect(promotionAuditLog.org_id).toBe(ORG_ID)
expect(promotionAuditLog.user_id).toBe(USER_ID)
expect(promotionAuditLog.changed_fields).toContain('version')

if (promotionAuditLog.new_record && typeof promotionAuditLog.new_record === 'object') {
expect((promotionAuditLog.new_record as Record<string, unknown>).version).toBe(targetVersionId)
}
})
})
64 changes: 64 additions & 0 deletions tests/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ describe('[DELETE] /bundle operations', () => {
describe('[PUT] /bundle operations - Set bundle to channel', () => {
let versionId: number
let channelId: number
let writeScopedKeyId: number | undefined
let writeScopedHeaders: Record<string, string> | undefined

beforeAll(async () => {
// Create a test version
Expand Down Expand Up @@ -235,6 +237,36 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => {
throw new Error('Failed to create test channel: channel is null')
}
channelId = channel.id

const createKeyResponse = await fetch(`${BASE_URL}/apikey`, {
method: 'POST',
headers,
body: JSON.stringify({
name: `bundle-write-key-${APPNAME}`,
mode: 'write',
limited_to_apps: [APPNAME],
}),
})

const createKeyData = await createKeyResponse.json() as { id: number, key: string }
if (createKeyResponse.status !== 200) {
throw new Error(`Failed to create write-scoped bundle key: ${JSON.stringify(createKeyData)}`)
}

writeScopedKeyId = createKeyData.id
writeScopedHeaders = {
'Content-Type': 'application/json',
'capgkey': createKeyData.key,
}
})

afterAll(async () => {
if (writeScopedKeyId != null) {
await fetch(`${BASE_URL}/apikey/${writeScopedKeyId}`, {
method: 'DELETE',
headers,
})
}
})

it('should set bundle to channel successfully', async () => {
Expand Down Expand Up @@ -266,6 +298,38 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => {
}
})

it('should keep the supported write-scoped API key bundle promotion flow working', async () => {
if (!writeScopedHeaders) {
throw new Error('Write-scoped bundle test key was not created')
}

const response = await fetch(`${BASE_URL}/bundle`, {
method: 'PUT',
headers: writeScopedHeaders,
body: JSON.stringify({
app_id: APPNAME,
version_id: versionId,
channel_id: channelId,
}),
})

const data = await response.json() as { status: string, message: string }
expect(response.status).toBe(200)
expect(data.status).toBe('success')
expect(data.message).toContain('set to channel')

const supabase = getSupabaseClient()
const { data: channel, error } = await supabase
.from('channels')
.select('version')
.eq('id', channelId)
.eq('app_id', APPNAME)
.single()

expect(error).toBeNull()
expect(channel?.version).toBe(versionId)
})

it('should handle missing required fields', async () => {
const response = await fetch(`${BASE_URL}/bundle`, {
method: 'PUT',
Expand Down
Loading
Loading