First implementation of vault-plugin

Signed-off-by: ivgo <ivgo@spreadgroup.com>
This commit is contained in:
ivgo
2022-05-09 16:15:22 +02:00
parent 63f01a3554
commit 09f1256a85
33 changed files with 3658 additions and 2706 deletions
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
export * from './service/router';
export * from './service/VaultBuilder';
export * from './service/vaultApi';
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,116 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { Config } from '@backstage/config';
import { Logger } from 'winston';
import express, { Router } from 'express';
import { Duration } from 'luxon';
import { VaultClient } from './vaultApi';
import { runPeriodically } from './runPeriodically';
export interface VaultEnvironment {
logger: Logger;
config: Config;
}
export type VaultBuilderReturn = Promise<{
router: express.Router;
}>;
export class VaultBuilder {
private vaultTokenRefreshInterval: Duration = Duration.fromObject({
minutes: 60,
});
private vaultClient?: VaultClient;
static createBuilder(env: VaultEnvironment) {
return new VaultBuilder(env);
}
constructor(protected readonly env: VaultEnvironment) {}
public async build(): VaultBuilderReturn {
const { logger, config } = this.env;
logger.info('Initializing Vault backend');
if (!config.has('vault')) {
logger.warn(
'Failed to initialize Vault backend: vault config is missing',
);
return {
router: Router(),
};
}
this.vaultClient = this.vaultClient ?? new VaultClient(this.env);
const router = this.buildRouter(this.vaultClient);
await this.renewToken(this.vaultClient);
return {
router: router,
};
}
public setVaultClient(vaultClient: VaultClient) {
this.vaultClient = vaultClient;
return this;
}
public setVaultTokenRefreshInterval(refreshInterval: Duration) {
this.vaultTokenRefreshInterval = refreshInterval;
return this;
}
public enableTokenRenew() {
runPeriodically(async () => {
this.env.logger.info('Renewing Vault token');
const vaultClient = this.vaultClient ?? new VaultClient(this.env);
await this.renewToken(vaultClient);
}, this.vaultTokenRefreshInterval.toMillis());
return this;
}
protected async renewToken(vaultClient: VaultClient) {
const result = await vaultClient.renewToken();
if (!result) {
this.env.logger.warn('Error renewing vault token');
} else {
this.env.logger.info('Vault token renewed');
}
}
protected buildRouter(vaultClient: VaultClient): express.Router {
const router = Router();
router.use(express.json());
router.get('/v1/secrets', async (req, res) => {
const path = req.query.path;
if (typeof path !== 'string') {
res
.status(400)
.send('Something was unexpected about the path query string');
return;
}
const secrets = await vaultClient.listSecrets(path);
res.json(secrets);
});
return router;
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { VaultBuilder } from './VaultBuilder';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { router } = await VaultBuilder.createBuilder(options).build();
return router;
}
@@ -0,0 +1,54 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
/**
* Runs a function repeatedly, with a fixed wait between invocations.
*
* Supports async functions, and silently ignores exceptions and rejections.
*
* @param fn - The function to run. May return a Promise.
* @param delayMs - The delay between a completed function invocation and the
* next.
* @returns A function that, when called, stops the invocation loop.
*/
export function runPeriodically(fn: () => any, delayMs: number): () => void {
let cancel: () => void;
let cancelled = false;
const cancellationPromise = new Promise<void>(resolve => {
cancel = () => {
resolve();
cancelled = true;
};
});
const startRefresh = async () => {
while (!cancelled) {
try {
await fn();
} catch {
// ignore intentionally
}
await Promise.race([
new Promise(resolve => setTimeout(resolve, delayMs)),
cancellationPromise,
]);
}
};
startRefresh();
return cancel!;
}
@@ -0,0 +1,54 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger } = options;
const config = new ConfigReader({});
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -0,0 +1,50 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'vault-backend' });
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
});
}
@@ -0,0 +1,131 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Config } from '@backstage/config';
import fetch from 'cross-fetch';
type VaultSecretList = {
data: {
keys: string[];
};
};
type Secret = {
name: string;
showUrl: string;
editUrl: string;
};
type RenewTokenResponse = {
auth: {
client_token: string;
};
};
export interface VaultApi {
getFrontendSecretsUrl(): string;
listSecrets(secretPath: string): Promise<Secret[]>;
renewToken?(): Promise<boolean>;
}
export class VaultClient implements VaultApi {
private readonly vaultUrl: string;
private vaultToken: string;
private readonly kvVersion: number;
private readonly secretEngineName: string;
constructor({ config }: { config: Config }) {
this.vaultUrl = config.getString('vault.sourceUrl');
this.vaultToken = config.getString('vault.token');
this.kvVersion = config.getOptionalNumber('vault.kvVersion') ?? 2;
this.secretEngineName =
config.getOptionalString('vault.secretEngine') ?? 'secrets';
}
private async callApi<T>(
path: string,
query: { [key in string]: any },
method: string = 'GET',
): Promise<T | undefined> {
const response = await fetch(
`${this.vaultUrl}/${path}?${new URLSearchParams(query).toString()}`,
{
method,
headers: {
Accept: 'application/json',
'X-Vault-Token': this.vaultToken,
},
},
);
if (response.status === 200) {
return (await response.json()) as T;
}
return undefined;
}
private isFolder(secretName: string): boolean {
const regex = /^.*\/$/gm;
return regex.test(secretName);
}
getFrontendSecretsUrl(): string {
return `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}`;
}
async listSecrets(secretPath: string): Promise<Secret[]> {
const listUrl =
this.kvVersion === 2
? `v1/${this.secretEngineName}/metadata/${secretPath}`
: `v1/${this.secretEngineName}/${secretPath}`;
const result = await this.callApi<VaultSecretList>(listUrl, { list: true });
if (!result) {
return [];
}
const secrets: Secret[] = [];
await Promise.all(
result.data.keys.map(async secret => {
if (this.isFolder(secret)) {
secrets.push(
...(await this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`)),
);
} else {
secrets.push({
name: secret,
editUrl: `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}/edit/${secretPath}/${secret}`,
showUrl: `${this.vaultUrl}/ui/vault/secrets/${this.secretEngineName}/show/${secretPath}/${secret}`,
});
}
}),
);
return secrets;
}
async renewToken(): Promise<boolean> {
const result = await this.callApi<RenewTokenResponse>(
'v1/auth/token/renew-self',
{},
'POST',
);
if (!result) {
return false;
}
this.vaultToken = result.auth.client_token;
return true;
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
export {};