Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sample env comment refers to "GET /tenants endpoints" but these routes are part of the admin API (served on the admin port / behind admin API keys). Consider clarifying the comment to avoid implying that the public API exposes this toggle.

Suggested change
# When set to false, GET /tenants endpoints omit decrypted secrets (database urls, jwt secret, service key, anon key, jwks). Defaults to true.
# When set to false, admin API GET /tenants endpoints omit decrypted secrets (database urls, jwt secret, service key, anon key, jwks). Defaults to true.

Copilot uses AI. Check for mistakes.
# ADMIN_RETURN_TENANT_SENSITIVE_DATA=true
Comment on lines +36 to +37
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sample env var name here (ADMIN_RETURN_TENANT_SENSITIVE_DATA) doesn’t match the SERVER_ADMIN_* naming used for other admin settings in this file (e.g. SERVER_ADMIN_API_KEYS, SERVER_ADMIN_PORT). Aligning the name (or documenting both with a clear precedence) would reduce operator confusion.

Suggested change
# 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
# When set to false, GET /tenants endpoints omit decrypted secrets (database urls, jwt secret, service key, anon key, jwks). Defaults to true.
# Uses the legacy/current env var name below; `SERVER_ADMIN_RETURN_TENANT_SENSITIVE_DATA` is the convention-aligned equivalent for admin settings.
# ADMIN_RETURN_TENANT_SENSITIVE_DATA=true
# SERVER_ADMIN_RETURN_TENANT_SENSITIVE_DATA=true

Copilot uses AI. Check for mistakes.
AUTH_ENCRYPTION_KEY=encryptionkey


Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type StorageConfigType = {
headersTimeout: number
adminApiKeys: string
adminRequestIdHeader?: string
adminReturnTenantSensitiveData: boolean
encryptionKey: string
uploadFileSizeLimit: number
uploadFileSizeLimitStandard?: number
Expand Down Expand Up @@ -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',
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new flag is read only from ADMIN_RETURN_TENANT_SENSITIVE_DATA, but other admin-related settings in this config consistently use the SERVER_ADMIN_* prefix with a fallback (e.g. SERVER_ADMIN_PORT, SERVER_ADMIN_API_KEYS). To avoid configuration confusion and keep naming consistent, consider reading SERVER_ADMIN_RETURN_TENANT_SENSITIVE_DATA with a fallback to ADMIN_RETURN_TENANT_SENSITIVE_DATA (or rename the variable entirely and keep a backward-compatible fallback).

Suggested change
getOptionalConfigFromEnv('ADMIN_RETURN_TENANT_SENSITIVE_DATA') !== 'false',
getOptionalConfigFromEnv(
'SERVER_ADMIN_RETURN_TENANT_SENSITIVE_DATA',
'ADMIN_RETURN_TENANT_SENSITIVE_DATA'
) !== 'false',

Copilot uses AI. Check for mistakes.

encryptionKey: getOptionalConfigFromEnv('AUTH_ENCRYPTION_KEY', 'ENCRYPTION_KEY') || '',
jwtSecret: getOptionalIfMultitenantConfigFromEnv('AUTH_JWT_SECRET', 'PGRST_JWT_SECRET') || '',
Expand Down
45 changes: 27 additions & 18 deletions src/http/routes/admin/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
}
Comment on lines +172 to +180
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is gated by adminReturnTenantSensitiveData, but there are no automated tests asserting that GET /tenants and GET /tenants/:tenantId omit decrypted secrets when the env var is set to false. Add a test case (e.g., in src/test/tenant.test.ts) that sets process.env.ADMIN_RETURN_TENANT_SENSITIVE_DATA='false', reloads config, and verifies the response does not include anonKey, databaseUrl, databasePoolUrl, jwtSecret, jwks, or serviceKey.

Copilot uses AI. Check for mistakes.
: {}),
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,
Expand Down Expand Up @@ -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: {
Expand Down
72 changes: 72 additions & 0 deletions src/test/tenant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../admin-app').default> | 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,
Expand Down