diff --git a/supabase/functions/_backend/private/files.ts b/supabase/functions/_backend/private/files.ts index a51aa7b996..6ad369ef94 100644 --- a/supabase/functions/_backend/private/files.ts +++ b/supabase/functions/_backend/private/files.ts @@ -11,8 +11,10 @@ import { ALLOWED_HEADERS, ALLOWED_METHODS, EXPOSED_HEADERS, MAX_UPLOAD_LENGTH_BY import { simpleError } from '../utils/hono.ts' import { middlewareKey } from '../utils/hono_middleware.ts' import { cloudlog } from '../utils/logging.ts' +import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { getAppByAppIdPg, getUserIdFromApikey, hasAppRightApikeyPg } from '../utils/pg_files.ts' import { createStatsBandwidth } from '../utils/stats.ts' -import { hasAppRightApikey, supabaseAdmin } from '../utils/supabase.ts' +import { supabaseAdmin } from '../utils/supabase.ts' import { backgroundTask } from '../utils/utils.ts' import { app as download_link } from './download_link.ts' import { app as files_config } from './files_config.ts' @@ -335,129 +337,133 @@ async function checkWriteAppAccess(c: Context, next: Next) { userId: apikey.user_id, }) - const { data: userId, error: _errorUserId } = await supabaseAdmin(c) - .rpc('get_user_id', { apikey: capgkey, app_id }) + // Use Postgres instead of Supabase SDK + const pgClient = getPgClient(c, true) // read-only query + const drizzleClient = getDrizzleClient(pgClient) - cloudlog({ - requestId: c.get('requestId'), - message: 'checkWriteAppAccess - get_user_id result', - userId, - hasError: !!_errorUserId, - error: _errorUserId, - userIdIsNull: userId === null, - }) + try { + // Get user_id from apikey using Postgres + const userId = await getUserIdFromApikey(c, capgkey, drizzleClient) - if (_errorUserId || userId === null) { cloudlog({ requestId: c.get('requestId'), - message: 'checkWriteAppAccess - user lookup failed', - error: _errorUserId, + message: 'checkWriteAppAccess - get_user_id result', userId, - app_id, - capgkeyPrefix: capgkey ? capgkey.substring(0, 15) : 'missing', - }) - throw new HTTPException(400, { - res: c.json({ - error: 'user_not_found', - message: 'User not found for the provided API key', - moreInfo: { app_id, hasApiKey: !!capgkey, apiKeyLength: capgkey?.length ?? 0, requestId: c.get('requestId') }, - }), + userIdIsNull: userId === null, }) - } - - cloudlog({ - requestId: c.get('requestId'), - message: 'checkWriteAppAccess - checking app permissions via hasAppRightApikey', - userId, - app_id, - }) - // Use the hasAppRightApikey function which checks org membership, roles, and permissions - const hasPermission = await hasAppRightApikey(c, app_id, userId, 'read', capgkey) - - cloudlog({ - requestId: c.get('requestId'), - message: 'checkWriteAppAccess - hasAppRightApikey result', - hasPermission, - }) + if (userId === null) { + cloudlog({ + requestId: c.get('requestId'), + message: 'checkWriteAppAccess - user lookup failed', + userId, + app_id, + capgkeyPrefix: capgkey ? capgkey.substring(0, 15) : 'missing', + }) + throw new HTTPException(400, { + res: c.json({ + error: 'user_not_found', + message: 'User not found for the provided API key', + moreInfo: { app_id, hasApiKey: !!capgkey, apiKeyLength: capgkey?.length ?? 0, requestId: c.get('requestId') }, + }), + }) + } - if (!hasPermission) { cloudlog({ requestId: c.get('requestId'), - message: 'checkWriteAppAccess - insufficient permissions', + message: 'checkWriteAppAccess - checking app permissions via hasAppRightApikeyPg', userId, app_id, }) - throw new HTTPException(403, { - res: c.json({ - error: 'insufficient_permissions', - message: 'You don\'t have permission to access this app', - moreInfo: { app_id, requestId: c.get('requestId') }, - }), - }) - } - const { data: app, error: errorApp } = await supabaseAdmin(c) - .from('apps') - .select('app_id, owner_org') - .eq('app_id', app_id) - .single() + // Use the Postgres version of hasAppRightApikey + const requiredRight: Database['public']['Enums']['user_min_right'] = 'read' + const hasPermission = await hasAppRightApikeyPg(c, app_id, requiredRight, userId, capgkey, drizzleClient) - if (errorApp) { cloudlog({ requestId: c.get('requestId'), - message: 'checkWriteAppAccess - app not found', - error: errorApp, - app_id, - }) - throw new HTTPException(404, { - res: c.json({ - error: 'app_not_found', - message: 'App not found', - moreInfo: { app_id, requestId: c.get('requestId') }, - }), + message: 'checkWriteAppAccess - hasAppRightApikeyPg result', + hasPermission, }) - } - if (app.owner_org !== owner_org) { + if (!hasPermission) { + cloudlog({ + requestId: c.get('requestId'), + message: 'checkWriteAppAccess - insufficient permissions', + userId, + app_id, + }) + throw new HTTPException(403, { + res: c.json({ + error: 'insufficient_permissions', + message: 'You don\'t have permission to access this app', + moreInfo: { app_id, requestId: c.get('requestId') }, + }), + }) + } + + // Get app using Postgres + const app = await getAppByAppIdPg(c, app_id, drizzleClient) + + if (!app) { + cloudlog({ + requestId: c.get('requestId'), + message: 'checkWriteAppAccess - app not found', + app_id, + }) + throw new HTTPException(404, { + res: c.json({ + error: 'app_not_found', + message: 'App not found', + moreInfo: { app_id, requestId: c.get('requestId') }, + }), + }) + } + + if (app.owner_org !== owner_org) { + cloudlog({ + requestId: c.get('requestId'), + message: 'checkWriteAppAccess - owner org mismatch', + filePathOwnerOrg: owner_org, + actualOwnerOrg: app.owner_org, + app_id, + }) + throw new HTTPException(403, { + res: c.json({ + error: 'owner_org_mismatch', + message: 'The owner organization in the file path does not match the app\'s owner organization', + moreInfo: { + app_id, + filePathOwnerOrg: owner_org, + actualOwnerOrg: app.owner_org, + requestId: c.get('requestId'), + }, + }), + }) + } + cloudlog({ requestId: c.get('requestId'), - message: 'checkWriteAppAccess - owner org mismatch', - filePathOwnerOrg: owner_org, - actualOwnerOrg: app.owner_org, + message: 'checkWriteAppAccess - access granted', app_id, - }) - throw new HTTPException(403, { - res: c.json({ - error: 'owner_org_mismatch', - message: 'The owner organization in the file path does not match the app\'s owner organization', - moreInfo: { - app_id, - filePathOwnerOrg: owner_org, - actualOwnerOrg: app.owner_org, - requestId: c.get('requestId'), - }, - }), + owner_org, }) } - - cloudlog({ - requestId: c.get('requestId'), - message: 'checkWriteAppAccess - access granted', - app_id, - owner_org, - }) + finally { + // Always close the connection + await backgroundTask(c, closeClient(c, pgClient)) + } await next() } app.options(`/upload/${ATTACHMENT_PREFIX}`, optionsHandler) -app.post(`/upload/${ATTACHMENT_PREFIX}`, middlewareKey(['all', 'write', 'upload']), setKeyFromMetadata, checkWriteAppAccess, uploadHandler) +app.post(`/upload/${ATTACHMENT_PREFIX}`, middlewareKey(['all', 'write', 'upload'], true), setKeyFromMetadata, checkWriteAppAccess, uploadHandler) app.options(`/upload/${ATTACHMENT_PREFIX}/:id{.+}`, optionsHandler) -app.get(`/upload/${ATTACHMENT_PREFIX}/:id{.+}`, middlewareKey(['all', 'write', 'upload']), setKeyFromIdParam, checkWriteAppAccess, getHandler) +app.get(`/upload/${ATTACHMENT_PREFIX}/:id{.+}`, middlewareKey(['all', 'write', 'upload'], true), setKeyFromIdParam, checkWriteAppAccess, getHandler) app.get(`/read/${ATTACHMENT_PREFIX}/:id{.+}`, setKeyFromIdParam, getHandler) -app.patch(`/upload/${ATTACHMENT_PREFIX}/:id{.+}`, middlewareKey(['all', 'write', 'upload']), setKeyFromIdParam, checkWriteAppAccess, uploadHandler) +app.patch(`/upload/${ATTACHMENT_PREFIX}/:id{.+}`, middlewareKey(['all', 'write', 'upload'], true), setKeyFromIdParam, checkWriteAppAccess, uploadHandler) app.route('/config', files_config) app.route('/download_link', download_link) diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index 255be10bcb..c644951887 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -1,8 +1,11 @@ import type { Context } from 'hono' import type { AuthInfo } from './hono.ts' import type { Database } from './supabase.types.ts' +import { and, eq, inArray } from 'drizzle-orm' import { honoFactory, quickError } from './hono.ts' import { cloudlog } from './logging.ts' +import { getDrizzleClient, getPgClient, logPgError } from './pg.ts' +import * as schema from './postgres_schema.ts' import { checkKey, checkKeyById, supabaseAdmin, supabaseClient } from './supabase.ts' // TODO: make universal middleware who @@ -23,6 +26,92 @@ function isUUID(str: string) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str) } +/** + * Check API key using Postgres/Drizzle instead of Supabase SDK + */ +async function checkKeyPg( + c: Context, + keyString: string, + rights: Database['public']['Enums']['key_mode'][], + drizzleClient: ReturnType, +): Promise { + try { + const result = await drizzleClient + .select() + .from(schema.apikeys) + .where(and( + eq(schema.apikeys.key, keyString), + inArray(schema.apikeys.mode, rights), + )) + .limit(1) + .then(data => data[0]) + + if (!result) { + return null + } + + // Convert to the expected format, ensuring arrays are properly handled + return { + id: result.id, + created_at: result.created_at?.toISOString() || null, + user_id: result.user_id, + key: result.key, + mode: result.mode, + updated_at: result.updated_at?.toISOString() || null, + name: result.name, + limited_to_orgs: result.limited_to_orgs || [], + limited_to_apps: result.limited_to_apps || [], + } as Database['public']['Tables']['apikeys']['Row'] + } + catch (e: unknown) { + logPgError(c, 'checkKeyPg', e) + return null + } +} + +/** + * Check API key by ID using Postgres/Drizzle instead of Supabase SDK + */ +async function checkKeyByIdPg( + c: Context, + id: number, + rights: Database['public']['Enums']['key_mode'][], + drizzleClient: ReturnType, +): Promise { + try { + const result = await drizzleClient + .select() + .from(schema.apikeys) + .where(and( + eq(schema.apikeys.id, id), + inArray(schema.apikeys.mode, rights), + )) + .limit(1) + .then(data => data[0]) + + if (!result) { + return null + } + + // Convert to the expected format, ensuring arrays are properly handled + return { + id: result.id, + created_at: result.created_at?.toISOString() || null, + user_id: result.user_id, + key: result.key, + mode: result.mode, + updated_at: result.updated_at?.toISOString() || null, + name: result.name, + limited_to_orgs: result.limited_to_orgs || [], + limited_to_apps: result.limited_to_apps || [], + } as Database['public']['Tables']['apikeys']['Row'] + } + catch (e: unknown) { + logPgError(c, 'checkKeyByIdPg', e) + return null + } +} + async function foundAPIKey(c: Context, capgkeyString: string, rights: Database['public']['Enums']['key_mode'][]) { const subkey_id = c.req.header('x-limited-key-id') ? Number(c.req.header('x-limited-key-id')) : null @@ -106,12 +195,13 @@ export function middlewareV2(rights: Database['public']['Enums']['key_mode'][]) }) } -export function middlewareKey(rights: Database['public']['Enums']['key_mode'][]) { +export function middlewareKey(rights: Database['public']['Enums']['key_mode'][], usePostgres = false) { const subMiddlewareKey = honoFactory.createMiddleware(async (c, next) => { const capgkey_string = c.req.header('capgkey') const apikey_string = c.req.header('authorization') const subkey_id = c.req.header('x-limited-key-id') ? Number(c.req.header('x-limited-key-id')) : null const key = capgkey_string ?? apikey_string + cloudlog({ requestId: c.get('requestId'), message: 'middlewareKey - checking authorization', @@ -120,20 +210,71 @@ export function middlewareKey(rights: Database['public']['Enums']['key_mode'][]) hasCapgkey: !!capgkey_string, hasAuthorization: !!apikey_string, hasKey: !!key, + usePostgres, }) if (!key) { cloudlog({ requestId: c.get('requestId'), message: 'No key provided', method: c.req.method, url: c.req.url }) return quickError(401, 'no_key_provided', 'No key provided') } - const apikey: Database['public']['Tables']['apikeys']['Row'] | null = await checkKey(c, key, supabaseAdmin(c), rights) + + let apikey: Database['public']['Tables']['apikeys']['Row'] | null = null + let pgClient: ReturnType | null = null + + if (usePostgres) { + try { + pgClient = getPgClient(c, true) // read-only query + const drizzleClient = getDrizzleClient(pgClient) + apikey = await checkKeyPg(c, key, rights, drizzleClient) + } + finally { + if (pgClient) { + pgClient.end().catch((err) => { + cloudlog({ + requestId: c.get('requestId'), + message: 'middlewareKey - PG connection close error', + error: err instanceof Error ? err.message : String(err), + }) + }) + } + } + } + else { + apikey = await checkKey(c, key, supabaseAdmin(c), rights) + } + if (!apikey) { cloudlog({ requestId: c.get('requestId'), message: 'Invalid apikey', key, method: c.req.method, url: c.req.url }) return quickError(401, 'invalid_apikey', 'Invalid apikey') } c.set('apikey', apikey) c.set('capgkey', key) + if (subkey_id) { - const subkey: Database['public']['Tables']['apikeys']['Row'] | null = await checkKeyById(c, subkey_id, supabaseAdmin(c), rights) + let subkey: Database['public']['Tables']['apikeys']['Row'] | null = null + let subkeyPgClient: ReturnType | null = null + + if (usePostgres) { + try { + subkeyPgClient = getPgClient(c, true) + const drizzleClient = getDrizzleClient(subkeyPgClient) + subkey = await checkKeyByIdPg(c, subkey_id, rights, drizzleClient) + } + finally { + if (subkeyPgClient) { + subkeyPgClient.end().catch((err) => { + cloudlog({ + requestId: c.get('requestId'), + message: 'middlewareKey - Subkey PG connection close error', + error: err instanceof Error ? err.message : String(err), + }) + }) + } + } + } + else { + subkey = await checkKeyById(c, subkey_id, supabaseAdmin(c), rights) + } + if (!subkey) { cloudlog({ requestId: c.get('requestId'), message: 'Invalid subkey', subkey_id }) return quickError(401, 'invalid_subkey', 'Invalid subkey') diff --git a/supabase/functions/_backend/utils/pg_files.ts b/supabase/functions/_backend/utils/pg_files.ts new file mode 100644 index 0000000000..3b8da4224e --- /dev/null +++ b/supabase/functions/_backend/utils/pg_files.ts @@ -0,0 +1,181 @@ +import type { Context } from 'hono' +import type { getDrizzleClient } from './pg.ts' +import type { Database } from './supabase.types.ts' +import { eq, sql } from 'drizzle-orm' +import { cloudlog } from './logging.ts' +import { logPgError } from './pg.ts' +import * as schema from './postgres_schema.ts' + +/** + * Get user_id from apikey using the existing Postgres function + */ +export async function getUserIdFromApikey( + c: Context, + apikey: string, + drizzleClient: ReturnType, +): Promise { + try { + cloudlog({ + requestId: c.get('requestId'), + message: 'getUserIdFromApikey - querying', + apikeyPrefix: apikey?.substring(0, 15), + }) + + // Call the existing Postgres function + const result = await drizzleClient.execute<{ get_user_id: string }>( + sql`SELECT get_user_id(${apikey})`, + ) + + const userId = result.rows[0]?.get_user_id ?? null + + cloudlog({ + requestId: c.get('requestId'), + message: 'getUserIdFromApikey - result', + userId, + }) + + return userId + } + catch (e: unknown) { + logPgError(c, 'getUserIdFromApikey', e) + return null + } +} + +/** + * Get owner_org from app_id using the existing Postgres function + */ +export async function getOwnerOrgByAppId( + c: Context, + appId: string, + drizzleClient: ReturnType, +): Promise { + try { + // Call the existing Postgres function + const result = await drizzleClient.execute<{ get_user_main_org_id_by_app_id: string }>( + sql`SELECT get_user_main_org_id_by_app_id(${appId})`, + ) + + return result.rows[0]?.get_user_main_org_id_by_app_id ?? null + } + catch (e: unknown) { + logPgError(c, 'getOwnerOrgByAppId', e) + return null + } +} + +/** + * Check minimum rights for a user using the existing Postgres function + */ +export async function checkMinRightsPg( + c: Context, + minRight: Database['public']['Enums']['user_min_right'], + userId: string, + orgId: string, + appId: string | null, + channelId: number | null, + drizzleClient: ReturnType, +): Promise { + try { + if (!userId) { + cloudlog({ + requestId: c.get('requestId'), + message: 'checkMinRightsPg - userId is null', + }) + return false + } + + // Call the existing Postgres function + const result = await drizzleClient.execute<{ check_min_rights: boolean }>( + sql`SELECT check_min_rights(${minRight}::user_min_right, ${userId}::uuid, ${orgId}::uuid, ${appId}, ${channelId})`, + ) + + const hasPermission = result.rows[0]?.check_min_rights ?? false + + cloudlog({ + requestId: c.get('requestId'), + message: 'checkMinRightsPg - result', + hasPermission, + minRight, + userId, + orgId, + appId, + channelId, + }) + + return hasPermission + } + catch (e: unknown) { + logPgError(c, 'checkMinRightsPg', e) + return false + } +} + +/** + * Check if an API key has the right access to an app using the existing Postgres function + */ +export async function hasAppRightApikeyPg( + c: Context, + appId: string, + right: Database['public']['Enums']['user_min_right'], + userId: string, + apikey: string, + drizzleClient: ReturnType, +): Promise { + try { + cloudlog({ + requestId: c.get('requestId'), + message: 'hasAppRightApikeyPg - start', + appId, + right, + userId, + apikeyPrefix: apikey?.substring(0, 15), + }) + + // Call the existing Postgres function + const result = await drizzleClient.execute<{ has_app_right_apikey: boolean }>( + sql`SELECT has_app_right_apikey(${appId}, ${right}::user_min_right, ${userId}::uuid, ${apikey})`, + ) + + const hasPermission = result.rows[0]?.has_app_right_apikey ?? false + + cloudlog({ + requestId: c.get('requestId'), + message: 'hasAppRightApikeyPg - result', + hasPermission, + }) + + return hasPermission + } + catch (e: unknown) { + logPgError(c, 'hasAppRightApikeyPg', e) + return false + } +} + +/** + * Get app by app_id with owner_org + */ +export async function getAppByAppIdPg( + c: Context, + appId: string, + drizzleClient: ReturnType, +): Promise<{ app_id: string, owner_org: string } | null> { + try { + const app = await drizzleClient + .select({ + app_id: schema.apps.app_id, + owner_org: schema.apps.owner_org, + }) + .from(schema.apps) + .where(eq(schema.apps.app_id, appId)) + .limit(1) + .then(data => data[0]) + + return app ?? null + } + catch (e: unknown) { + logPgError(c, 'getAppByAppIdPg', e) + return null + } +} diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index fed093a90d..8e87af8544 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -3,6 +3,18 @@ import { bigint, boolean, pgEnum, pgTable, serial, text, timestamp, uuid, varcha // do_not_change export const disableUpdatePgEnum = pgEnum('disable_update', ['major', 'minor', 'patch', 'version_number', 'none']) +export const keyModePgEnum = pgEnum('key_mode', ['read', 'write', 'all', 'upload']) +export const userMinRightPgEnum = pgEnum('user_min_right', [ + 'invite_read', + 'invite_upload', + 'invite_write', + 'invite_admin', + 'read', + 'upload', + 'write', + 'admin', + 'super_admin', +]) export const apps = pgTable('apps', { created_at: timestamp('created_at').notNull().defaultNow(), @@ -92,3 +104,26 @@ export const stripe_info = pgTable('stripe_info', { storage_exceeded: boolean('storage_exceeded'), bandwidth_exceeded: boolean('bandwidth_exceeded'), }) + +export const apikeys = pgTable('apikeys', { + id: bigint('id', { mode: 'number' }).primaryKey().notNull(), + created_at: timestamp('created_at').defaultNow(), + user_id: uuid('user_id').notNull(), + key: varchar('key').notNull(), + mode: keyModePgEnum('mode').notNull(), + updated_at: timestamp('updated_at').defaultNow(), + name: varchar('name').notNull(), + limited_to_orgs: uuid('limited_to_orgs').array(), + limited_to_apps: varchar('limited_to_apps').array(), +}) + +export const org_users = pgTable('org_users', { + id: bigint('id', { mode: 'number' }).primaryKey().notNull(), + created_at: timestamp('created_at').defaultNow(), + updated_at: timestamp('updated_at').defaultNow(), + user_id: uuid('user_id').notNull(), + org_id: uuid('org_id').notNull(), + app_id: varchar('app_id'), + channel_id: bigint('channel_id', { mode: 'number' }), + user_right: userMinRightPgEnum('user_right'), +})