config-loader: taint secret paths to treat them as secret in later configs as well

This commit is contained in:
Patrik Oldsberg
2020-08-08 10:46:15 +02:00
parent 1f2216fd77
commit 1a6fb8cf8f
5 changed files with 65 additions and 10 deletions
+37 -7
View File
@@ -26,6 +26,13 @@ function memoryFiles(files: { [path: string]: string }) {
};
}
const mockContext: ReaderContext = {
env: {},
skip: () => false,
readFile: jest.fn(),
readSecret: jest.fn(),
};
describe('readConfigFile', () => {
it('should read a plain config file', async () => {
const readFile = memoryFiles({
@@ -34,8 +41,9 @@ describe('readConfigFile', () => {
});
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
} as ReaderContext);
});
await expect(config).resolves.toEqual({
data: {
@@ -55,8 +63,9 @@ describe('readConfigFile', () => {
});
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
} as ReaderContext);
});
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
});
@@ -67,8 +76,9 @@ describe('readConfigFile', () => {
});
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
} as ReaderContext);
});
await expect(config).rejects.toThrow('Expected object at config root');
});
@@ -80,7 +90,7 @@ describe('readConfigFile', () => {
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
...mockContext,
readFile,
readSecret: readSecret as ReadSecretFunc,
});
@@ -91,7 +101,9 @@ describe('readConfigFile', () => {
},
context: 'app-config.yaml',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
expect(readSecret).toHaveBeenCalledWith('.app', {
file: './my-secret',
});
});
it('should require secrets to be objects', async () => {
@@ -101,7 +113,7 @@ describe('readConfigFile', () => {
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
...mockContext,
readFile,
readSecret: readSecret as ReadSecretFunc,
});
@@ -119,11 +131,29 @@ describe('readConfigFile', () => {
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
const config = readConfigFile('./app-config.yaml', {
env: {},
...mockContext,
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
});
it('should omit skipped values', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { title: skip, name: include }',
});
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
skip: (path: string) => path === '.app.title',
readSecret: jest.fn() as ReadSecretFunc,
});
await expect(config).resolves.toEqual({
context: 'app-config.yaml',
data: { app: { name: 'include' } },
});
});
});
+5 -1
View File
@@ -35,6 +35,10 @@ export async function readConfigFile(
obj: JsonValue,
path: string,
): Promise<JsonValue | undefined> {
if (ctx.skip(path)) {
return undefined;
}
if (typeof obj !== 'object') {
return obj;
} else if (obj === null) {
@@ -58,7 +62,7 @@ export async function readConfigFile(
}
try {
return await ctx.readSecret(obj.$secret);
return await ctx.readSecret(path, obj.$secret);
} catch (error) {
throw new Error(`Invalid secret at ${path}: ${error.message}`);
}
@@ -21,6 +21,7 @@ const ctx: ReaderContext = {
env: {
SECRET: 'my-secret',
},
skip: () => false,
readSecret: jest.fn(),
async readFile(path) {
const content = ({
+6 -1
View File
@@ -17,13 +17,18 @@
import { JsonObject } from '@backstage/config';
export type ReadFileFunc = (path: string) => Promise<string>;
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
export type ReadSecretFunc = (
path: string,
desc: JsonObject,
) => Promise<string | undefined>;
export type SkipFunc = (path: string) => boolean;
/**
* Common context that provides all the necessary hooks for reading configuration files.
*/
export type ReaderContext = {
env: { [name in string]?: string };
skip: SkipFunc;
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
+16 -1
View File
@@ -38,6 +38,7 @@ export type LoadConfigOptions = {
class Context {
constructor(
private readonly options: {
secretPaths: Set<string>;
env: { [name in string]?: string };
rootPath: string;
shouldReadSecrets: boolean;
@@ -48,11 +49,22 @@ class Context {
return this.options.env;
}
skip(path: string): boolean {
if (this.options.shouldReadSecrets) {
return false;
}
return this.options.secretPaths.has(path);
}
async readFile(path: string): Promise<string> {
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
}
async readSecret(desc: JsonObject): Promise<string | undefined> {
async readSecret(
path: string,
desc: JsonObject,
): Promise<string | undefined> {
this.options.secretPaths.add(path);
if (!this.options.shouldReadSecrets) {
return undefined;
}
@@ -69,10 +81,13 @@ export async function loadConfig(
const configPaths = await resolveStaticConfig(options);
try {
const secretPaths = new Set<string>();
for (const configPath of configPaths) {
const config = await readConfigFile(
configPath,
new Context({
secretPaths,
env: process.env,
rootPath: dirname(configPath),
shouldReadSecrets: Boolean(options.shouldReadSecrets),