diff --git a/src/lib/secrets.ts b/src/lib/secrets.ts new file mode 100644 index 00000000..5cd58e44 --- /dev/null +++ b/src/lib/secrets.ts @@ -0,0 +1,25 @@ +// Use dynamic import to support module mock +const fs = await import('fs/promises') + +export const getSecret = async (key: string) => { + if (!key) { + return '' + } + + const env = process.env[key] + if (env) { + return env + } + + const file = process.env[key + '_FILE'] + if (!file) { + return '' + } + + return await fs.readFile(file, { encoding: 'utf8' }).catch((e) => { + if (e.code == 'ENOENT') { + return '' + } + throw e + }) +} diff --git a/src/server/constants.ts b/src/server/constants.ts index db2ad05b..b27be94d 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -1,12 +1,14 @@ +import { getSecret } from '../lib/secrets.js' + export const PG_META_HOST = process.env.PG_META_HOST || '0.0.0.0' export const PG_META_PORT = Number(process.env.PG_META_PORT || 1337) -export const CRYPTO_KEY = process.env.CRYPTO_KEY || 'SAMPLE_KEY' +export const CRYPTO_KEY = (await getSecret('CRYPTO_KEY')) || 'SAMPLE_KEY' const PG_META_DB_HOST = process.env.PG_META_DB_HOST || 'localhost' const PG_META_DB_NAME = process.env.PG_META_DB_NAME || 'postgres' const PG_META_DB_USER = process.env.PG_META_DB_USER || 'postgres' const PG_META_DB_PORT = Number(process.env.PG_META_DB_PORT) || 5432 -const PG_META_DB_PASSWORD = process.env.PG_META_DB_PASSWORD || 'postgres' +const PG_META_DB_PASSWORD = (await getSecret('PG_META_DB_PASSWORD')) || 'postgres' const PG_META_DB_SSL_MODE = process.env.PG_META_DB_SSL_MODE || 'disable' const PG_CONN_TIMEOUT_SECS = Number(process.env.PG_CONN_TIMEOUT_SECS || 15) diff --git a/test/lib/index.test.ts b/test/lib/index.test.ts index 74ca9140..464c61a1 100644 --- a/test/lib/index.test.ts +++ b/test/lib/index.test.ts @@ -2,6 +2,7 @@ // TODO: Test server. import './query' import './config' +import './secrets' import './version' import './schemas' import './types' diff --git a/test/lib/secrets.ts b/test/lib/secrets.ts new file mode 100644 index 00000000..2b41a7ff --- /dev/null +++ b/test/lib/secrets.ts @@ -0,0 +1,57 @@ +import { jest } from '@jest/globals' + +// Ref: https://jestjs.io/docs/ecmascript-modules +jest.unstable_mockModule('fs/promises', () => ({ + readFile: jest.fn(), +})) +const { readFile } = await import('fs/promises') +const { getSecret } = await import('../../src/lib/secrets') + +describe('getSecret', () => { + const value = 'dummy' + + beforeEach(() => { + // Clears env var + jest.resetModules() + }) + + afterEach(() => { + delete process.env.SECRET + delete process.env.SECRET_FILE + }) + + it('loads from env', async () => { + process.env.SECRET = value + const res = await getSecret('SECRET') + expect(res).toBe(value) + }) + + it('loads from file', async () => { + process.env.SECRET_FILE = '/run/secrets/db_password' + jest.mocked(readFile).mockResolvedValueOnce(value) + const res = await getSecret('SECRET') + expect(res).toBe(value) + }) + + it('defaults to empty string', async () => { + expect(await getSecret('')).toBe('') + expect(await getSecret('SECRET')).toBe('') + }) + + it('default on file not found', async () => { + process.env.SECRET_FILE = '/run/secrets/db_password' + const e: NodeJS.ErrnoException = new Error('no such file or directory') + e.code = 'ENOENT' + jest.mocked(readFile).mockRejectedValueOnce(e) + const res = await getSecret('SECRET') + expect(res).toBe('') + }) + + it('throws on permission denied', async () => { + process.env.SECRET_FILE = '/run/secrets/db_password' + const e: NodeJS.ErrnoException = new Error('permission denied') + e.code = 'EACCES' + jest.mocked(readFile).mockRejectedValueOnce(e) + expect(getSecret('SECRET')).rejects.toThrow() + }) +})