From c6ed793e7100d5496e2f4927a688fbe59373b3f1 Mon Sep 17 00:00:00 2001 From: fenos Date: Tue, 7 Apr 2026 10:28:42 +0200 Subject: [PATCH] fix: env to control tenant config visibility --- .env.sample | 2 + src/config.ts | 3 ++ src/http/routes/admin/tenants.ts | 45 ++++++++++++-------- src/test/tenant.test.ts | 72 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 18 deletions(-) diff --git a/.env.sample b/.env.sample index 1816f6915..b79467a8f 100644 --- a/.env.sample +++ b/.env.sample @@ -33,6 +33,8 @@ DATABASE_MULTITENANT_URL=postgresql://postgres:postgres@127.0.0.1:5433/postgres DATABASE_MULTITENANT_POOL_URL=postgresql://postgres:postgres@127.0.0.1:6454/postgres REQUEST_X_FORWARDED_HOST_REGEXP=^([a-z]{20}).local.(?:com|dev)$ SERVER_ADMIN_API_KEYS=apikey +# When set to false, GET /tenants endpoints omit decrypted secrets (database urls, jwt secret, service key, anon key, jwks). Defaults to true. +# ADMIN_RETURN_TENANT_SENSITIVE_DATA=true AUTH_ENCRYPTION_KEY=encryptionkey diff --git a/src/config.ts b/src/config.ts index 84506f6c0..980be8a86 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ type StorageConfigType = { headersTimeout: number adminApiKeys: string adminRequestIdHeader?: string + adminReturnTenantSensitiveData: boolean encryptionKey: string uploadFileSizeLimit: number uploadFileSizeLimitStandard?: number @@ -302,6 +303,8 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType { 'REQUEST_TRACE_HEADER', 'REQUEST_ADMIN_TRACE_HEADER' ), + adminReturnTenantSensitiveData: + getOptionalConfigFromEnv('ADMIN_RETURN_TENANT_SENSITIVE_DATA') !== 'false', encryptionKey: getOptionalConfigFromEnv('AUTH_ENCRYPTION_KEY', 'ENCRYPTION_KEY') || '', jwtSecret: getOptionalIfMultitenantConfigFromEnv('AUTH_JWT_SECRET', 'PGRST_JWT_SECRET') || '', diff --git a/src/http/routes/admin/tenants.ts b/src/http/routes/admin/tenants.ts index c535732d7..b047f82fe 100644 --- a/src/http/routes/admin/tenants.ts +++ b/src/http/routes/admin/tenants.ts @@ -131,7 +131,8 @@ interface tenantDBInterface { disable_events?: string[] | null } -const { dbMigrationFreezeAt, icebergEnabled, vectorEnabled } = getConfig() +const { dbMigrationFreezeAt, icebergEnabled, vectorEnabled, adminReturnTenantSensitiveData } = + getConfig() const migrationQueueName = RunMigrationsOnTenants.getQueueName() export default async function routes(fastify: FastifyInstance) { @@ -168,15 +169,19 @@ export default async function routes(fastify: FastifyInstance) { disable_events, }) => ({ id, - anonKey: decrypt(anon_key), - databaseUrl: decrypt(database_url), - databasePoolUrl: database_pool_url ? decrypt(database_pool_url) : undefined, + ...(adminReturnTenantSensitiveData + ? { + anonKey: decrypt(anon_key), + databaseUrl: decrypt(database_url), + databasePoolUrl: database_pool_url ? decrypt(database_pool_url) : undefined, + jwtSecret: decrypt(jwt_secret), + jwks, + serviceKey: decrypt(service_key), + } + : {}), databasePoolMode: database_pool_mode, maxConnections: max_connections ? Number(max_connections) : undefined, fileSizeLimit: Number(file_size_limit), - jwtSecret: decrypt(jwt_secret), - jwks, - serviceKey: decrypt(service_key), migrationVersion: migrations_version, migrationStatus: migrations_status, tracingMode: tracing_mode, @@ -246,20 +251,24 @@ export default async function routes(fastify: FastifyInstance) { const capabilities = await getTenantCapabilities(request.params.tenantId) return { - anonKey: decrypt(anon_key), - databaseUrl: decrypt(database_url), - databasePoolUrl: - database_pool_url === null - ? null - : database_pool_url - ? decrypt(database_pool_url) - : undefined, + ...(adminReturnTenantSensitiveData + ? { + anonKey: decrypt(anon_key), + databaseUrl: decrypt(database_url), + databasePoolUrl: + database_pool_url === null + ? null + : database_pool_url + ? decrypt(database_pool_url) + : undefined, + jwtSecret: decrypt(jwt_secret), + jwks, + serviceKey: decrypt(service_key), + } + : {}), databasePoolMode: database_pool_mode, maxConnections: max_connections ? Number(max_connections) : undefined, fileSizeLimit: Number(file_size_limit), - jwtSecret: decrypt(jwt_secret), - jwks, - serviceKey: decrypt(service_key), capabilities, features: { imageTransformation: { diff --git a/src/test/tenant.test.ts b/src/test/tenant.test.ts index ed9bdca33..31537dc5a 100644 --- a/src/test/tenant.test.ts +++ b/src/test/tenant.test.ts @@ -220,6 +220,78 @@ describe('Tenant configs', () => { await expect(getFeatures('abc')).resolves.toEqual(payload.features) }) + test('Get tenant config omits sensitive data when ADMIN_RETURN_TENANT_SENSITIVE_DATA is false', async () => { + await adminApp.inject({ + method: 'POST', + url: `/tenants/abc`, + payload, + headers: { + apikey: process.env.ADMIN_API_KEYS, + }, + }) + + const previousValue = process.env.ADMIN_RETURN_TENANT_SENSITIVE_DATA + process.env.ADMIN_RETURN_TENANT_SENSITIVE_DATA = 'false' + + try { + let isolatedApp: ReturnType | undefined + await jest.isolateModulesAsync(async () => { + const { default: createApp } = await import('../admin-app') + isolatedApp = createApp({}) + }) + + const singleResponse = await isolatedApp!.inject({ + method: 'GET', + url: `/tenants/abc`, + headers: { + apikey: process.env.ADMIN_API_KEYS, + }, + }) + expect(singleResponse.statusCode).toBe(200) + const singleJSON = JSON.parse(singleResponse.body) + + expect(singleJSON.anonKey).toBeUndefined() + expect(singleJSON.databaseUrl).toBeUndefined() + expect(singleJSON.databasePoolUrl).toBeUndefined() + expect(singleJSON.jwtSecret).toBeUndefined() + expect(singleJSON.jwks).toBeUndefined() + expect(singleJSON.serviceKey).toBeUndefined() + + // Non-sensitive fields are still returned + expect(singleJSON.fileSizeLimit).toBe(payload.fileSizeLimit) + expect(singleJSON.maxConnections).toBe(payload.maxConnections) + expect(singleJSON.features).toEqual(payload.features) + expect(singleJSON.tracingMode).toBe(payload.tracingMode) + + const listResponse = await isolatedApp!.inject({ + method: 'GET', + url: `/tenants`, + headers: { + apikey: process.env.ADMIN_API_KEYS, + }, + }) + expect(listResponse.statusCode).toBe(200) + const listJSON = JSON.parse(listResponse.body) + expect(listJSON).toHaveLength(1) + expect(listJSON[0].id).toBe('abc') + expect(listJSON[0].anonKey).toBeUndefined() + expect(listJSON[0].databaseUrl).toBeUndefined() + expect(listJSON[0].databasePoolUrl).toBeUndefined() + expect(listJSON[0].jwtSecret).toBeUndefined() + expect(listJSON[0].jwks).toBeUndefined() + expect(listJSON[0].serviceKey).toBeUndefined() + expect(listJSON[0].fileSizeLimit).toBe(payload.fileSizeLimit) + + await isolatedApp!.close() + } finally { + if (previousValue === undefined) { + delete process.env.ADMIN_RETURN_TENANT_SENSITIVE_DATA + } else { + process.env.ADMIN_RETURN_TENANT_SENSITIVE_DATA = previousValue + } + } + }) + test('Create tenant config preserves disableEvents and image transformation maxResolution', async () => { const createPayload = { ...payload,