Merge pull request #1277 from spotify/rugvip/secrets

packages/config-loader: implement secrets parsing + tests and refactoring
This commit is contained in:
Patrik Oldsberg
2020-06-15 11:51:22 +02:00
committed by GitHub
15 changed files with 713 additions and 81 deletions
+4 -2
View File
@@ -32,11 +32,13 @@
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"fs-extra": "^9.0.0",
"yaml": "^1.9.2"
"yaml": "^1.9.2",
"yup": "^0.28.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
"@types/node": "^12.0.0",
"@types/yup": "^0.28.2"
},
"files": [
"dist/**/*.{js,d.ts}"
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { loadConfig } from './loader';
export type { LoadConfigOptions } from './types';
export type { LoadConfigOptions } from './loader';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { readEnv } from './loader';
import { readEnv } from './env';
describe('readEnv', () => {
it('should return empty config for empty env', () => {
+91
View File
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppConfig, JsonObject } from '@backstage/config';
const ENV_PREFIX = 'APP_CONFIG_';
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
/**
* Read runtime configuration from the environment.
*
* Only environment variables prefixed with APP_CONFIG_ will be considered.
*
* For each variable, the prefix will be removed, and rest of the key will
* be split by '_'. Each part will then be used as keys to build up a nested
* config object structure. The treatment of the entire environment variable
* is case-sensitive.
*
* The value of the variable should be JSON serialized, as it will be parsed
* and the type will be kept intact. For example "true" and true are treated
* differently, as well as "42" and 42.
*
* For example, to set the config app.title to "My Title", use the following:
*
* APP_CONFIG_app_title='"My Title"'
*/
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
}
return config ? [config] : [];
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};
export { resolveStaticConfig } from './resolver';
export { readConfigFile } from './reader';
export { readEnv } from './env';
export { readSecret } from './secrets';
@@ -0,0 +1,123 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { readConfigFile } from './reader';
import { ReaderContext, ReadSecretFunc } from './types';
function memoryFiles(files: { [path: string]: string }) {
return async (path: string) => {
if (path in files) {
return files[path];
}
throw new Error(`File not found, ${path}`);
};
}
describe('readConfigFile', () => {
it('should read a plain config file', async () => {
const readFile = memoryFiles({
'./app-config.yaml':
'app: { title: "Test", x: 1, y: [null, true], z: null }',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).resolves.toEqual({
app: {
title: 'Test',
x: 1,
y: [true],
},
});
});
it('should error out if the config file has invalid syntax', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { title: ]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
});
it('should error out if config is not an object', async () => {
const readFile = memoryFiles({
'./app-config.yaml': '[]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Expected object at config root');
});
it('should read secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).resolves.toEqual({
app: 'secret',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
});
it('should require secrets to be objects', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
expect(readSecret).not.toHaveBeenCalled();
await expect(config).rejects.toThrow(
'Expected object at secret .app.$secret',
);
});
it('should forward secret reading errors', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: {} }',
});
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
});
});
+80
View File
@@ -0,0 +1,80 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yaml from 'yaml';
import { isObject } from './utils';
import { JsonValue, JsonObject } from '@backstage/config';
import { ReaderContext } from './types';
/**
* Reads and parses, and validates, and transforms a single config file.
* The transformation rewrites any special values, like the $secret key.
*/
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
async function transform(
obj: JsonValue,
path: string,
): Promise<JsonValue | undefined> {
if (typeof obj !== 'object') {
return obj;
} else if (obj === null) {
return undefined;
} else if (Array.isArray(obj)) {
const arr = new Array<JsonValue>();
for (const [index, value] of obj.entries()) {
const out = await transform(value, `${path}[${index}]`);
if (out !== undefined) {
arr.push(out);
}
}
return arr;
}
if ('$secret' in obj) {
if (!isObject(obj.$secret)) {
throw TypeError(`Expected object at secret ${path}.$secret`);
}
try {
return await ctx.readSecret(obj.$secret);
} catch (error) {
throw new Error(`Invalid secret at ${path}: ${error.message}`);
}
}
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
}
}
return out;
}
const finalConfig = await transform(config, '');
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return finalConfig;
}
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { findRootPath } from './paths';
type ResolveOptions = {
// Same as configPath in LoadConfigOptions
configPath?: string;
};
/**
* Resolves all configuration files that should be loaded in the given environment.
*/
export async function resolveStaticConfig(
options: ResolveOptions,
): Promise<string[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
}
return [configPath];
}
@@ -0,0 +1,114 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { readSecret } from './secrets';
import { ReaderContext } from './types';
const ctx: ReaderContext = {
env: {
SECRET: 'my-secret',
},
readSecret: jest.fn(),
async readFile(path) {
const content = ({
'my-secret': 'secret',
'my-data.json': '{"a":{"b":{"c":42}}}',
'my-data.yaml': 'some:\n yaml:\n key: 7',
'my-data.yml': 'different: { key: hello }',
} as { [key: string]: string })[path];
if (!content) {
throw new Error('File not found!');
}
return content;
},
};
describe('readSecret', () => {
it('should read file secrets', async () => {
await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
'secret',
);
await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
'File not found!',
);
});
it('should read present env secrets', async () => {
await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
undefined,
);
});
it('should read data secrets', async () => {
await expect(
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
).resolves.toBe('42');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
).resolves.toBe('7');
await expect(
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
).resolves.toBe('hello');
await expect(
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
).rejects.toThrow('File not found!');
});
it('should reject invalid secrets', async () => {
await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
'secret must be a `object` type, but the final value was: `"hello"`.',
);
await expect(readSecret({}, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
'path is a required field',
);
await expect(
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
).rejects.toThrow('No data secret parser available for extension .js');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
});
it('should have 100% test coverage', async () => {
let firstVisit = true;
const secret = {};
const proto = {
get file() {
if (!firstVisit) {
Object.setPrototypeOf(secret, {});
}
firstVisit = false;
return 'a-file';
},
};
Object.setPrototypeOf(secret, proto);
await expect(readSecret(secret, ctx)).rejects.toThrow(
'Secret was left unhandled',
);
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yup from 'yup';
import yaml from 'yaml';
import { extname } from 'path';
import { JsonObject, JsonValue } from '@backstage/config';
import { isObject, isNever } from './utils';
import { ReaderContext } from './types';
// Reads a file and forwards the contents as is, assuming ut8 encoding
type FileSecret = {
// Path to the secret file, relative to the config file.
file: string;
};
// Reads the secret from an environment variable.
type EnvSecret = {
// The name of the environment file.
env: string;
};
// Reads a secret from a json-like file and extracts a value at a path.
// The supported extensions are define in dataSecretParser below.
type DataSecret = {
// Path to the data secret file, relative to the config file.
data: string;
// The path to the value inside the data file.
// Either a '.' separated list, or an array of path segments.
path: string | string[];
};
type Secret = FileSecret | EnvSecret | DataSecret;
// Schema for each type of secret description
const secretLoaderSchemas = {
file: yup.object({
file: yup.string().required(),
}),
env: yup.object({
env: yup.string().required(),
}),
data: yup.object({
data: yup.string().required(),
path: yup.lazy(value => {
if (typeof value === 'string') {
return yup.string().required();
}
return yup.array().of(yup.string().required()).required();
}),
}),
};
// The top-level secret schema, which figures out what type of secret it is.
const secretSchema = yup.lazy<object>(value => {
if (typeof value !== 'object' || value === null) {
return yup.object().required().label('secret');
}
const loaderTypes = Object.keys(
secretLoaderSchemas,
) as (keyof typeof secretLoaderSchemas)[];
for (const key of loaderTypes) {
if (key in value) {
return secretLoaderSchemas[key];
}
}
throw new yup.ValidationError(
`Secret must contain one of '${loaderTypes.join("', '")}'`,
value,
'$secret',
);
});
// Parsers for each type of data secret file.
const dataSecretParser: {
[ext in string]: (content: string) => Promise<JsonObject>;
} = {
'.json': async content => JSON.parse(content),
'.yaml': async content => yaml.parse(content),
'.yml': async content => yaml.parse(content),
};
/**
* Transforms a secret description into the actual secret value.
*/
export async function readSecret(
data: JsonObject,
ctx: ReaderContext,
): Promise<string | undefined> {
const secret = secretSchema.validateSync(data, { strict: true }) as Secret;
if ('file' in secret) {
return ctx.readFile(secret.file);
}
if ('env' in secret) {
return ctx.env[secret.env];
}
if ('data' in secret) {
const ext = extname(secret.data);
const parser = dataSecretParser[ext];
if (!parser) {
throw new Error(`No data secret parser available for extension ${ext}`);
}
const content = await ctx.readFile(secret.data);
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
let value: JsonValue = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
throw new Error(
`Value is not an object at ${errPath} in ${secret.data}`,
);
}
value = value[part];
}
return String(value);
}
isNever<typeof secret>();
throw new Error('Secret was left unhandled');
}
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject } from '@backstage/config';
export type ReadFileFunc = (path: string) => Promise<string>;
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
/**
* Common context that provides all the necessary hooks for reading configuration files.
*/
export type ReaderContext = {
env: { [name in string]?: string };
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue, JsonObject } from '@backstage/config';
export function isObject(obj: JsonValue | undefined): obj is JsonObject {
if (typeof obj !== 'object') {
return false;
} else if (Array.isArray(obj)) {
return false;
}
return obj !== null;
}
// A thing to make sure we've narrowed the type down to never
export function isNever<T extends never>() {
return void 0 as T;
}
+52 -73
View File
@@ -15,87 +15,46 @@
*/
import fs from 'fs-extra';
import yaml from 'yaml';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, dirname } from 'path';
import { AppConfig, JsonObject } from '@backstage/config';
import { findRootPath } from './paths';
import { LoadConfigOptions } from './types';
import {
resolveStaticConfig,
readConfigFile,
readEnv,
readSecret,
} from './lib';
const ENV_PREFIX = 'APP_CONFIG_';
export type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
// Whether to read secrets or omit them, defaults to false.
shouldReadSecrets?: boolean;
};
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
class Context {
constructor(
private readonly options: {
env: { [name in string]?: string };
rootPath: string;
shouldReadSecrets: boolean;
},
) {}
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
get env() {
return this.options.env;
}
return config ? [config] : [];
}
export async function readStaticConfig(
options: LoadConfigOptions,
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
async readFile(path: string): Promise<string> {
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
}
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
async readSecret(desc: JsonObject): Promise<string | undefined> {
if (!this.options.shouldReadSecrets) {
return undefined;
}
return readSecret(desc, this);
}
}
@@ -105,7 +64,27 @@ export async function loadConfig(
const configs = [];
configs.push(...readEnv(process.env));
configs.push(...(await readStaticConfig(options)));
const configPaths = await resolveStaticConfig(options);
try {
for (const configPath of configPaths) {
const config = await readConfigFile(
configPath,
new Context({
env: process.env,
rootPath: dirname(configPath),
shouldReadSecrets: Boolean(options.shouldReadSecrets),
}),
);
configs.push(config);
}
} catch (error) {
throw new Error(
`Failed to read static configuration file: ${error.message}`,
);
}
return configs;
}