diff --git a/.changeset/shaggy-bulldogs-collect.md b/.changeset/shaggy-bulldogs-collect.md new file mode 100644 index 0000000000..04dea88cc7 --- /dev/null +++ b/.changeset/shaggy-bulldogs-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-vault-backend': minor +--- + +First implementation for the backend vault plugin. For more information refer to its `README.md`. diff --git a/.changeset/silent-plants-guess.md b/.changeset/silent-plants-guess.md new file mode 100644 index 0000000000..1dc9a0559e --- /dev/null +++ b/.changeset/silent-plants-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-vault': minor +--- + +First implementation of the frontend vault plugin. For more information refer to its `README.md`. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 4fa71329dd..24464dd700 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -353,6 +353,19 @@ to `scm-only`, the plugin will only take into account files stored in source control (e.g. ignoring generated code). If set to `enabled`, all files covered by a coverage report will be taken into account. +### vault.io/secrets-path + +```yaml +# Example: +metadata: + annotations: + vault.io/secrets-path: test/backstage +``` + +The value of this annotation contains the path to the secrets of the entity in +Vault. If not present when the Vault plugin is in use, a message will be shown +instead, letting the user know what is missing in the `catalog-info.yaml`. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in diff --git a/microsite/data/plugins/vault.yaml b/microsite/data/plugins/vault.yaml new file mode 100644 index 0000000000..3214cf669a --- /dev/null +++ b/microsite/data/plugins/vault.yaml @@ -0,0 +1,11 @@ +--- +title: Vault +author: Spread Group +authorUrl: https://github.com/ivangonzalezacuna +category: Vault +description: Visualize a list secrets stored in your vault instance. +documentation: https://github.com/backstage/backstage/tree/master/plugins/vault +iconUrl: img/vault.png +npmPackageName: '@backstage/plugin-vault' +tags: + - vault diff --git a/microsite/static/img/vault.png b/microsite/static/img/vault.png new file mode 100644 index 0000000000..9b5d59622d Binary files /dev/null and b/microsite/static/img/vault.png differ diff --git a/plugins/vault-backend/.eslintrc.js b/plugins/vault-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/vault-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/vault-backend/README.md b/plugins/vault-backend/README.md new file mode 100644 index 0000000000..d7d227607d --- /dev/null +++ b/plugins/vault-backend/README.md @@ -0,0 +1,144 @@ +# @backstage/plugin-vault-backend + +A backend for [Vault](https://www.vaultproject.io/), this plugin adds a few routes that are used by the frontend plugin to fetch the information from Vault. + +## Introduction + +Vault is an identity-based secrets and encryption management system. A secret is anything that you want to tightly control access to, such as API encryption keys, passwords, or certificates. Vault provides encryption services that are gated by authentication and authorization methods. + +This plugins allows you to view all the available secrets at a certain location, and redirect you to the official UI so backstage can rely on LIST permissions, which is safer. + +## Getting started + +To get started, first you need a running instance of Vault. You can follow [this tutorial](https://learn.hashicorp.com/tutorials/vault/getting-started-intro?in=vault/getting-started) to install vault and start your server locally. + +1. When your Vault instance is up and running, then you will need to install the plugin into your app: + + ```bash + # From your Backstage root directory + yarn add --cwd packages/backend @backstage/plugin-vault-backend + ``` + +2. Create a file in `src/plugins/vault.ts` and add a reference to it in `src/index.ts`: + + ```typescript + // In packages/backend/src/plugins/vault.ts + import { createRouter } from '@backstage/plugin-vault-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { + return await createRouter({ + logger: env.logger, + config: env.config, + scheduler: env.scheduler, + }); + } + ``` + + ```diff + diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts + index f2b14b2..2c64f47 100644 + --- a/packages/backend/src/index.ts + +++ b/packages/backend/src/index.ts + @@ -22,6 +22,7 @@ import { Config } from '@backstage/config'; + import app from './plugins/app'; + +import vault from './plugins/vault'; + import scaffolder from './plugins/scaffolder'; + @@ -56,6 +57,7 @@ async function main() { + const authEnv = useHotMemoize(module, () => createEnv('auth')); + + const vaultEnv = useHotMemoize(module, () => createEnv('vault')); + const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); + @@ -63,6 +65,7 @@ async function main() { + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); + + apiRouter.use('/vault', await vault(vaultEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + ``` + +3. Add some extra configurations in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). + + ```yaml + vault: + baseUrl: http://your-vault-url + token: + secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets' + kvVersion: # Optional. The K/V version that your instance is using. The available options are '1' or '2' + ``` + +4. Get a `VAULT_TOKEN` with **LIST** permissions, as it's enough for the plugin. You can check [this tutorial](https://learn.hashicorp.com/tutorials/vault/tokens) for more info. + +5. If you also want to use the `renew` functionality, you need to attach the following block to your custom policy, so that Backstage can perform a token-renew: + ``` + # Allow tokens to renew themselves + path "auth/token/renew-self" { + capabilities = ["update"] + } + ``` + +## Integration with the Catalog + +The plugin can be integrated into each Component in the catalog. To allow listing the available secrets a new annotation must be added to the `catalog-info.yaml`: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + vault.io/secrets-path: path/to/secrets +``` + +The path is relative to your secrets engine folder. So if you want to get the secrets for backstage and you have the following directory structure: + + . + ├── ... + ├── secrets # Your secret engine name (usually it is `secrets`) + │ ├── test # Folder with test secrets + │ │ ├── backstage # In this folder there are secrets for Backstage + │ ├── other # Other folder with more secrets inside + │ └── folder # And another folder + └── ... + +You will set the `vault.io/secret-path` to `test/backstage`. If the folder `backstage` contains other sub-folders, the plugin will fetch the secrets inside them and adapt the **View** and **Edit** URLs to point to the correct place. + +## Renew token + +In a secure Vault instance, it's usual that the tokens are refreshed after some time. In order to always have a valid token to fetch the secrets, it might be necessary to execute a renew action after some time. By default this is deactivated, but it can be easily activated and configured to be executed periodically (hourly by default, but customizable by the user). In order to do that, modify your `src/plugins/vault.ts` file to look like this one: + +```typescript +import { VaultBuilder } from '@backstage/plugin-vault-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const builder = await VaultBuilder.createBuilder({ + logger: env.logger, + config: env.config, + scheduler: env.scheduler, + }).enableTokenRenew( + env.scheduler.createScheduledTaskRunner({ + frequency: { minutes: 10 }, + timeout: { minutes: 1 }, + }), + ); + + const { router } = builder.build(); + + return router; +} +``` + +## Features + +- List the secrets present in a certain path +- Open a link to view the secret +- Open a link to edit the secret +- Renew the token automatically with a defined periodicity + +The secrets cannot be edited/viewed from within Backstage to make it more secure. Backstage will only have permissions to LIST data from Vault or to renew its own token if that is needed. And the user who wants to edit/view a certain secret needs the correct permissions to do so. diff --git a/plugins/vault-backend/api-report.md b/plugins/vault-backend/api-report.md new file mode 100644 index 0000000000..1c7e669ef2 --- /dev/null +++ b/plugins/vault-backend/api-report.md @@ -0,0 +1,93 @@ +## API Report File for "@backstage/plugin-vault-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; +import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { TaskRunner } from '@backstage/backend-tasks'; + +// @public +export function createRouter(options: RouterOptions): express.Router; + +// @public +export type RenewTokenResponse = { + auth: { + client_token: string; + }; +}; + +// @public +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; + // (undocumented) + scheduler: PluginTaskScheduler; +} + +// @public +export interface VaultApi { + getFrontendSecretsUrl(): string; + listSecrets(secretPath: string): Promise; + renewToken?(): Promise; +} + +// @public +export class VaultBuilder { + constructor(env: VaultEnvironment); + build(): VaultBuilderReturn; + protected buildRouter(vaultClient: VaultClient): express.Router; + static createBuilder(env: VaultEnvironment): VaultBuilder; + enableTokenRenew(schedule?: TaskRunner): Promise; + // (undocumented) + protected readonly env: VaultEnvironment; + protected renewToken(vaultClient: VaultClient): Promise; + setVaultClient(vaultClient: VaultClient): this; +} + +// @public +export type VaultBuilderReturn = { + router: express.Router; +}; + +// @public +export class VaultClient implements VaultApi { + constructor({ config }: { config: Config }); + // (undocumented) + getFrontendSecretsUrl(): string; + // (undocumented) + listSecrets(secretPath: string): Promise; + // (undocumented) + renewToken(): Promise; +} + +// @public +export interface VaultEnvironment { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; + // (undocumented) + scheduler: PluginTaskScheduler; +} + +// @public +export type VaultSecret = { + name: string; + showUrl: string; + editUrl: string; +}; + +// @public +export type VaultSecretList = { + data: { + keys: string[]; + }; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/vault-backend/config.d.ts b/plugins/vault-backend/config.d.ts new file mode 100644 index 0000000000..b1f917463e --- /dev/null +++ b/plugins/vault-backend/config.d.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +/** Configuration for the Vault plugin */ +export interface Config { + vault?: { + /** + * The baseUrl for your Vault instance. + */ + baseUrl: string; + + /** + * The token used by Backstage to access Vault. + * @visibility secret + */ + token: string; + + /** + * The secret engine name where in vault. Defaults to `secrets`. + */ + secretEngine?: string; + + /** + * The version of the K/V API. Defaults to `2`. + */ + kvVersion?: 1 | 2; + }; +} diff --git a/plugins/vault-backend/package.json b/plugins/vault-backend/package.json new file mode 100644 index 0000000000..15e5be42a8 --- /dev/null +++ b/plugins/vault-backend/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/plugin-vault-backend", + "description": "A Backstage backend plugin that integrates towards Vault", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/vault" + }, + "keywords": [ + "backstage", + "vault" + ], + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "^0.14.0-next.2", + "@backstage/backend-tasks": "^0.3.2-next.1", + "@backstage/backend-test-utils": "^0.1.25-next.1", + "@backstage/config": "^1.0.1", + "@backstage/errors": "^1.0.0", + "@types/express": "*", + "compression": "^1.7.4", + "cors": "^2.8.5", + "cross-fetch": "^3.1.5", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "helmet": "^5.0.2", + "winston": "^3.7.2", + "yn": "^5.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.2-next.1", + "@types/compression": "^1.7.2", + "@types/supertest": "^2.0.8", + "msw": "^0.35.0", + "supertest": "^4.0.2" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/vault-backend/src/config/config.test.ts b/plugins/vault-backend/src/config/config.test.ts new file mode 100644 index 0000000000..7cfdd8f468 --- /dev/null +++ b/plugins/vault-backend/src/config/config.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { getVaultConfig } from './config'; + +describe('GetVaultConfig', () => { + it('fails by missing keys', () => { + expect(() => getVaultConfig(new ConfigReader({}))).toThrow(); + expect(() => + getVaultConfig( + new ConfigReader({ + vault: {}, + }), + ), + ).toThrow(); + }); + + it('loads default params', () => { + const config = new ConfigReader({ + vault: { + baseUrl: 'http://www.example.com', + token: '123', + }, + }); + + const vaultConfig = getVaultConfig(config); + expect(vaultConfig).toStrictEqual({ + baseUrl: 'http://www.example.com', + token: '123', + kvVersion: 2, + secretEngine: 'secrets', + }); + }); + + it('loads custom params', () => { + const config = new ConfigReader({ + vault: { + baseUrl: 'http://www.example.com', + token: '123', + kvVersion: 1, + secretEngine: 'test', + }, + }); + + const vaultConfig = getVaultConfig(config); + expect(vaultConfig).toStrictEqual({ + baseUrl: 'http://www.example.com', + token: '123', + kvVersion: 1, + secretEngine: 'test', + }); + }); +}); diff --git a/plugins/vault-backend/src/config/config.ts b/plugins/vault-backend/src/config/config.ts new file mode 100644 index 0000000000..4920c73fed --- /dev/null +++ b/plugins/vault-backend/src/config/config.ts @@ -0,0 +1,60 @@ +/* + * 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'; + +/** + * The configuration needed for the vault-backend plugin + * + * @public + */ +export interface VaultConfig { + /** + * The baseUrl for your Vault instance. + */ + baseUrl: string; + + /** + * The token used by Backstage to access Vault. + */ + token: string; + + /** + * The secret engine name where in vault. Defaults to `secrets`. + */ + secretEngine: string; + + /** + * The version of the K/V API. Defaults to `2`. + */ + kvVersion: number; +} + +/** + * Extract the Vault config from a config object + * + * @public + * + * @param config - The config object to extract from + */ +export function getVaultConfig(config: Config): VaultConfig { + return { + baseUrl: config.getString('vault.baseUrl'), + token: config.getString('vault.token'), + kvVersion: config.getOptionalNumber('vault.kvVersion') ?? 2, + secretEngine: config.getOptionalString('vault.secretEngine') ?? 'secrets', + }; +} diff --git a/plugins/vault-backend/src/config/index.ts b/plugins/vault-backend/src/config/index.ts new file mode 100644 index 0000000000..a7eab2d9f0 --- /dev/null +++ b/plugins/vault-backend/src/config/index.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +/* + * 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. + */ +export * from './config'; diff --git a/plugins/vault-backend/src/index.ts b/plugins/vault-backend/src/index.ts new file mode 100644 index 0000000000..4c74fd051f --- /dev/null +++ b/plugins/vault-backend/src/index.ts @@ -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'; diff --git a/plugins/vault-backend/src/run.ts b/plugins/vault-backend/src/run.ts new file mode 100644 index 0000000000..0a3ed2b7f0 --- /dev/null +++ b/plugins/vault-backend/src/run.ts @@ -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); +}); diff --git a/plugins/vault-backend/src/service/VaultBuilder.tsx b/plugins/vault-backend/src/service/VaultBuilder.tsx new file mode 100644 index 0000000000..aa2858345f --- /dev/null +++ b/plugins/vault-backend/src/service/VaultBuilder.tsx @@ -0,0 +1,167 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { Logger } from 'winston'; +import express, { Router } from 'express'; +import { VaultClient } from './vaultApi'; +import { TaskRunner, PluginTaskScheduler } from '@backstage/backend-tasks'; + +/** + * Environment values needed by the VaultBuilder + * @public + */ +export interface VaultEnvironment { + logger: Logger; + config: Config; + scheduler: PluginTaskScheduler; +} + +/** + * The object returned by the VaultBuilder.build() function + * @public + */ +export type VaultBuilderReturn = { + router: express.Router; +}; + +/** + * Implementation for Vault. It creates the routing and initializes the backend + * to allow the use of the Vault's frontend plugin. + * @public + */ +export class VaultBuilder { + // private vaultTokenRefreshInterval: Duration = Duration.fromObject({ + // minutes: 60, + // }); + private vaultClient?: VaultClient; + + /** + * Creates a new instance of the VaultBuilder. + * + * @param env - The backstage environment + * @returns A new instance of the VaultBuilder + */ + static createBuilder(env: VaultEnvironment) { + return new VaultBuilder(env); + } + constructor(protected readonly env: VaultEnvironment) {} + + /** + * Builds the routes for Vault. + * + * @returns The router configured for Vault + */ + public 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); + + return { + router: router, + }; + } + + /** + * Overwrites the current vault client. + * + * @param vaultClient - The new Vault client + * @returns + */ + public setVaultClient(vaultClient: VaultClient) { + this.vaultClient = vaultClient; + return this; + } + + /** + * Enables the token renewal for Vault. + * + * @param schedule - The TaskRunner used to schedule the renewal, if not set, renewing hourly + * @returns + */ + public async enableTokenRenew(schedule?: TaskRunner) { + const taskRunner = schedule + ? schedule + : this.env.scheduler.createScheduledTaskRunner({ + frequency: { hours: 1 }, + timeout: { hours: 1 }, + }); + await taskRunner.run({ + id: 'refresh-vault-token', + fn: async () => { + this.env.logger.info('Renewing Vault token'); + const vaultClient = this.vaultClient ?? new VaultClient(this.env); + await this.renewToken(vaultClient); + }, + }); + return this; + } + + /** + * Renews the token for vault using a defined client. + * + * @param vaultClient - The vault client used to renew the token + */ + 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'); + } + } + + /** + * Builds the backend routes for Vault. + * + * @param vaultClient - The Vault client used to list the secrets. + * @returns The generated backend router + */ + protected buildRouter(vaultClient: VaultClient): express.Router { + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, res) => { + res.json({ status: 'ok' }); + }); + + router.get('/v1/secrets/:path', async (req, res) => { + const { path } = req.params; + if (typeof path !== 'string') { + throw new InputError(`Invalid path: ${path}`); + } + + const secrets = await vaultClient.listSecrets(path); + res.json(secrets); + }); + + return router; + } +} diff --git a/plugins/vault-backend/src/service/router.test.ts b/plugins/vault-backend/src/service/router.test.ts new file mode 100644 index 0000000000..97997b30ef --- /dev/null +++ b/plugins/vault-backend/src/service/router.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { getVoidLogger, DatabaseManager } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { TaskScheduler } from '@backstage/backend-tasks'; + +import { createRouter } from './router'; + +describe('createRouter', () => { + let app: express.Express; + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + beforeAll(async () => { + const knex = await databases.init('SQLITE_3'); + const databaseManager: Partial = { + forPlugin: (_: string) => ({ + getClient: async () => knex, + }), + }; + const manager = databaseManager as DatabaseManager; + const scheduler = new TaskScheduler(manager, getVoidLogger()); + + const router = createRouter({ + logger: getVoidLogger(), + config: new ConfigReader({ + vault: { + baseUrl: 'https://www.example.com', + token: '1234567890', + }, + }), + scheduler: scheduler.forPlugin('vault'), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/vault-backend/src/service/router.ts b/plugins/vault-backend/src/service/router.ts new file mode 100644 index 0000000000..45e2ca8c86 --- /dev/null +++ b/plugins/vault-backend/src/service/router.ts @@ -0,0 +1,44 @@ +/* + * 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 { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; +import { VaultBuilder } from './VaultBuilder'; + +/** + * Options for the router creation. + * @public + */ +export interface RouterOptions { + logger: Logger; + config: Config; + scheduler: PluginTaskScheduler; +} + +/** + * Creates the routes used for Vault. + * + * @param options - The options used for the Vault's router creation + * @returns The router for Vault + * @public + */ +export function createRouter(options: RouterOptions): express.Router { + const { router } = VaultBuilder.createBuilder(options).build(); + + return router; +} diff --git a/plugins/vault-backend/src/service/standaloneApplication.ts b/plugins/vault-backend/src/service/standaloneApplication.ts new file mode 100644 index 0000000000..bcec8ca419 --- /dev/null +++ b/plugins/vault-backend/src/service/standaloneApplication.ts @@ -0,0 +1,72 @@ +/* + * 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, + DatabaseManager, + getVoidLogger, +} 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'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { TaskScheduler } from '@backstage/backend-tasks'; + +export interface ApplicationOptions { + enableCors: boolean; + logger: Logger; +} + +export async function createStandaloneApplication( + options: ApplicationOptions, +): Promise { + const { enableCors, logger } = options; + const config = new ConfigReader({}); + const app = express(); + + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + const knex = await databases.init('SQLITE_3'); + const databaseManager: Partial = { + forPlugin: (_: string) => ({ + getClient: async () => knex, + }), + }; + const manager = databaseManager as DatabaseManager; + const scheduler = new TaskScheduler(manager, getVoidLogger()).forPlugin( + 'vault', + ); + + app.use(helmet()); + if (enableCors) { + app.use(cors()); + } + app.use(compression()); + app.use(express.json()); + app.use(requestLoggingHandler()); + app.use('/', createRouter({ logger, config, scheduler })); + app.use(notFoundHandler()); + app.use(errorHandler()); + + return app; +} diff --git a/plugins/vault-backend/src/service/standaloneServer.ts b/plugins/vault-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..8d42b1d4c8 --- /dev/null +++ b/plugins/vault-backend/src/service/standaloneServer.ts @@ -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 { + 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); + }); + }); +} diff --git a/plugins/vault-backend/src/service/vaultApi.test.ts b/plugins/vault-backend/src/service/vaultApi.test.ts new file mode 100644 index 0000000000..5a29633933 --- /dev/null +++ b/plugins/vault-backend/src/service/vaultApi.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { VaultSecret, VaultClient, VaultSecretList } from './vaultApi'; +import { ConfigReader } from '@backstage/config'; + +describe('VaultApi', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + + const mockBaseUrl = 'https://api-vault.com'; + const config = new ConfigReader({ + vault: { + baseUrl: mockBaseUrl, + token: '1234567890', + }, + }); + + const mockListResult: VaultSecretList = { + data: { + keys: ['secret::one', 'secret::two'], + }, + }; + const mockListResultEmpty: VaultSecretList = { + data: { + keys: [], + }, + }; + + const mockSecretsResult: VaultSecret[] = [ + { + name: 'secret::one', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::one`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::one`, + }, + { + name: 'secret::two', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::two`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::two`, + }, + ]; + + const setupHandlers = () => { + server.use( + rest.get( + `${mockBaseUrl}/v1/secrets/metadata/test/success`, + (_, res, ctx) => { + return res(ctx.json(mockListResult)); + }, + ), + rest.get( + `${mockBaseUrl}/v1/secrets/metadata/test/error`, + (_, res, ctx) => { + return res(ctx.json(mockListResultEmpty)); + }, + ), + rest.post(`${mockBaseUrl}/v1/auth/token/renew-self`, (_, res, ctx) => { + return res(ctx.json({ auth: { client_token: '0987654321' } })); + }), + ); + }; + + it('should return secrets', async () => { + setupHandlers(); + const api = new VaultClient({ config }); + const secrets = await api.listSecrets('test/success'); + expect(secrets).toEqual(mockSecretsResult); + }); + + it('should return empty secret list', async () => { + setupHandlers(); + const api = new VaultClient({ config }); + const secrets = await api.listSecrets('test/error'); + expect(secrets).toEqual([]); + }); + + it('should return success token renew', async () => { + setupHandlers(); + const api = new VaultClient({ config }); + const apiRenew = await api.renewToken(); + expect(apiRenew).toBeTruthy(); + }); + + it('should render frontend url', () => { + const api = new VaultClient({ config }); + const url = api.getFrontendSecretsUrl(); + expect(url).toEqual(`${mockBaseUrl}/ui/vault/secrets/secrets`); + }); +}); diff --git a/plugins/vault-backend/src/service/vaultApi.ts b/plugins/vault-backend/src/service/vaultApi.ts new file mode 100644 index 0000000000..0623780654 --- /dev/null +++ b/plugins/vault-backend/src/service/vaultApi.ts @@ -0,0 +1,157 @@ +/* + * 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'; +import { getVaultConfig, VaultConfig } from '../config'; + +/** + * Object received as a response from the Vault API when fetching secrets + * @public + */ +export type VaultSecretList = { + data: { + keys: string[]; + }; +}; + +/** + * Object containing the secret name and some links + * @public + */ +export type VaultSecret = { + name: string; + showUrl: string; + editUrl: string; +}; + +/** + * Object received as response when the token is renewed using the Vault API + * @public + */ +export type RenewTokenResponse = { + auth: { + client_token: string; + }; +}; + +/** + * Interface for the Vault API + * @public + */ +export interface VaultApi { + /** + * Returns the URL to acces the Vault UI with the defined config. + */ + getFrontendSecretsUrl(): string; + /** + * Returns a list of secrets used to show in a table. + * @param secretPath - The path where the secrets are stored in Vault + */ + listSecrets(secretPath: string): Promise; + /** + * Optional, to renew the token used to list the secrets. Returns true + * if the action was successfull, false otherwise. + */ + renewToken?(): Promise; +} + +/** + * Implementation of the Vault API to list secrets and renew the token if necessary + * @public + */ +export class VaultClient implements VaultApi { + private vaultConfig: VaultConfig; + + constructor({ config }: { config: Config }) { + this.vaultConfig = getVaultConfig(config); + } + + private async callApi( + path: string, + query: { [key in string]: any }, + method: string = 'GET', + ): Promise { + const url = new URL(path, this.vaultConfig.baseUrl); + const response = await fetch( + `${url.toString()}?${new URLSearchParams(query).toString()}`, + { + method, + headers: { + Accept: 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + }, + }, + ); + 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.vaultConfig.baseUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}`; + } + + async listSecrets(secretPath: string): Promise { + const listUrl = + this.vaultConfig.kvVersion === 2 + ? `v1/${this.vaultConfig.secretEngine}/metadata/${secretPath}` + : `v1/${this.vaultConfig.secretEngine}/${secretPath}`; + const result = await this.callApi(listUrl, { list: true }); + if (!result) { + return []; + } + + const secrets: VaultSecret[] = []; + 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.vaultConfig.baseUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}/edit/${secretPath}/${secret}`, + showUrl: `${this.vaultConfig.baseUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}/show/${secretPath}/${secret}`, + }); + } + }), + ); + + return secrets; + } + + async renewToken(): Promise { + const result = await this.callApi( + 'v1/auth/token/renew-self', + {}, + 'POST', + ); + if (!result) { + return false; + } + + this.vaultConfig.token = result.auth.client_token; + return true; + } +} diff --git a/plugins/vault-backend/src/setupTests.ts b/plugins/vault-backend/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/vault-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/vault/.eslintrc.js b/plugins/vault/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/vault/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/vault/README.md b/plugins/vault/README.md new file mode 100644 index 0000000000..824293632f --- /dev/null +++ b/plugins/vault/README.md @@ -0,0 +1,96 @@ +# @backstage/plugin-vault + +A frontend for [Vault](https://www.vaultproject.io/), this plugin allows you to display a list of secrets in a certain path inside your vault instance. There are also some useful links to edit and/or view them using the official UI. + +![Screenshot of the vault plugin table](images/vault-table.png) + +## Introduction + +Vault is an identity-based secrets and encryption management system. A secret is anything that you want to tightly control access to, such as API encryption keys, passwords, or certificates. Vault provides encryption services that are gated by authentication and authorization methods. + +This plugins allows you to view all the available secrets at a certain location, and redirect you to the official UI so backstage can rely on LIST permissions, which is safer. + +## Getting started + +To get started, first you need a running instance of Vault. You can follow [this tutorial](https://learn.hashicorp.com/tutorials/vault/getting-started-intro?in=vault/getting-started) to install vault and start your server locally. + +1. When your Vault instance is up and running, then you will need to install the plugin into your app: + + ```bash + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-vault + ``` + +2. Add the Vault card to the overview tab on the EntityPage: + + ```typescript + // In packages/app/src/components/catalog/EntityPage.tsx + import { EntityVaultCard } from '@backstage/plugin-vault'; + + const overviewContent = ( + + {/* ...other content */} + + + + ); + ``` + +3. Add some extra configurations in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). + + ```yaml + vault: + baseUrl: http://your-vault-url + token: + secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets' + kvVersion: # Optional. The K/V version that your instance is using. The available options are '1' or '2' + ``` + +4. Get a `VAULT_TOKEN` with **LIST** permissions, as it's enough for the plugin. You can check [this tutorial](https://learn.hashicorp.com/tutorials/vault/tokens) for more info. + +5. If you also want to use the `renew` functionality, you need to attach the following block to your custom policy, so that Backstage can perform a token-renew: + ``` + # Allow tokens to renew themselves + path "auth/token/renew-self" { + capabilities = ["update"] + } + ``` + +## Integration with the Catalog + +The plugin can be integrated into each Component in the catalog. To allow listing the available secrets a new annotation must be added to the `catalog-info.yaml`: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + vault.io/secrets-path: path/to/secrets +``` + +The path is relative to your secrets engine folder. So if you want to get the secrets for backstage and you have the following directory structure: + + . + ├── ... + ├── secrets # Your secret engine name (usually it is `secrets`) + │ ├── test # Folder with test secrets + │ │ ├── backstage # In this folder there are secrets for Backstage + │ ├── other # Other folder with more secrets inside + │ └── folder # And another folder + └── ... + +You will set the `vault.io/secret-path` to `test/backstage`. If the folder `backstage` contains other sub-folders, the plugin will fetch the secrets inside them and adapt the **View** and **Edit** URLs to point to the correct place. + +If the annotation is missing for a certain component, then the card will show some information to the user: + +![Screenshot of the vault plugin with missing annotation](images/annotation-missing.png) + +## Features + +- List the secrets present in a certain path +- Open a link to view the secret +- Open a link to edit the secret +- Renew the token automatically with a defined periodicity + +The secrets cannot be edited/viewed from within Backstage to make it more secure. Backstage will only have permissions to LIST data from Vault. And the user who wants to edit/view a certain secret needs the correct permissions to do so. diff --git a/plugins/vault/api-report.md b/plugins/vault/api-report.md new file mode 100644 index 0000000000..a0b5773c70 --- /dev/null +++ b/plugins/vault/api-report.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/plugin-vault" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; + +// @public +export const EntityVaultCard: () => JSX.Element; + +// @public +export const vaultPlugin: BackstagePlugin<{}, {}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/vault/dev/index.tsx b/plugins/vault/dev/index.tsx new file mode 100644 index 0000000000..fd89e65889 --- /dev/null +++ b/plugins/vault/dev/index.tsx @@ -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. + */ +import { createDevApp } from '@backstage/dev-utils'; +import { vaultPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(vaultPlugin).render(); diff --git a/plugins/vault/images/annotation-missing.png b/plugins/vault/images/annotation-missing.png new file mode 100644 index 0000000000..1d0be0c99b Binary files /dev/null and b/plugins/vault/images/annotation-missing.png differ diff --git a/plugins/vault/images/vault-table.png b/plugins/vault/images/vault-table.png new file mode 100644 index 0000000000..18a4b22dc5 Binary files /dev/null and b/plugins/vault/images/vault-table.png differ diff --git a/plugins/vault/package.json b/plugins/vault/package.json new file mode 100644 index 0000000000..05b8da1df4 --- /dev/null +++ b/plugins/vault/package.json @@ -0,0 +1,66 @@ +{ + "name": "@backstage/plugin-vault", + "description": "A Backstage plugin that integrates towards Vault", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/vault" + }, + "keywords": [ + "backstage", + "vault" + ], + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/catalog-model": "^1.0.3-next.0", + "@backstage/core-components": "^0.9.5-next.1", + "@backstage/core-plugin-api": "^1.0.3-next.0", + "@backstage/plugin-catalog-react": "^1.1.1-next.1", + "@backstage/theme": "^0.2.15", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.57", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.2-next.1", + "@backstage/core-app-api": "^1.0.3-next.0", + "@backstage/dev-utils": "^1.0.3-next.1", + "@backstage/test-utils": "^1.1.1-next.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jest": "*", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/vault/src/api.test.ts b/plugins/vault/src/api.test.ts new file mode 100644 index 0000000000..3452cbad38 --- /dev/null +++ b/plugins/vault/src/api.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { VaultSecret, VaultClient } from './api'; +import { UrlPatternDiscovery } from '@backstage/core-app-api'; + +describe('api', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + + const mockBaseUrl = 'https://api-vault.com/api/vault'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const mockSecretsResult: VaultSecret[] = [ + { + name: 'secret::one', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::one`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::one`, + }, + { + name: 'secret::two', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::two`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::two`, + }, + ]; + + const setupHandlers = () => { + server.use( + rest.get(`${mockBaseUrl}/v1/secrets/:path`, (req, res, ctx) => { + const { path } = req.params; + if (path === 'test%2Fsuccess') { + return res(ctx.json(mockSecretsResult)); + } else if (path === 'test%2Ferror') { + return res(ctx.json([])); + } + return res(ctx.status(400)); + }), + ); + }; + + it('should return secrets', async () => { + setupHandlers(); + const api = new VaultClient({ discoveryApi }); + const secrets = await api.listSecrets('test/success'); + expect(secrets).toEqual(mockSecretsResult); + }); + + it('should return empty secret list', async () => { + setupHandlers(); + const api = new VaultClient({ discoveryApi }); + expect(await api.listSecrets('test/error')).toEqual([]); + expect(await api.listSecrets('')).toEqual([]); + }); + + it('should return no secrets', async () => { + setupHandlers(); + const api = new VaultClient({ discoveryApi }); + const secrets = await api.listSecrets(''); + expect(secrets).toEqual([]); + }); +}); diff --git a/plugins/vault/src/api.ts b/plugins/vault/src/api.ts new file mode 100644 index 0000000000..74b212429f --- /dev/null +++ b/plugins/vault/src/api.ts @@ -0,0 +1,71 @@ +/* + * 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 { DiscoveryApi, createApiRef } from '@backstage/core-plugin-api'; + +export const vaultApiRef = createApiRef({ + id: 'plugin.vault.service', +}); + +export type VaultSecret = { + name: string; + showUrl: string; + editUrl: string; +}; + +export interface VaultApi { + listSecrets(secretPath: string): Promise; +} + +export class VaultClient implements VaultApi { + private readonly discoveryApi: DiscoveryApi; + + constructor({ discoveryApi }: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = discoveryApi; + } + + private async callApi( + path: string, + query: { [key in string]: any }, + ): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('vault')}`; + const response = await fetch( + `${apiUrl}/${path}?${new URLSearchParams(query).toString()}`, + { + headers: { + Accept: 'application/json', + }, + }, + ); + if (response.status === 200) { + return (await response.json()) as T; + } + return undefined; + } + + async listSecrets(secretPath: string): Promise { + if (secretPath === '') { + return []; + } + const result = await this.callApi( + `v1/secrets/${encodeURIComponent(secretPath)}`, + {}, + ); + if (!result) { + return []; + } + return result; + } +} diff --git a/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx new file mode 100644 index 0000000000..f46aa1e4c2 --- /dev/null +++ b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 React from 'react'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { ComponentEntity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import { EntityVaultCard } from './EntityVaultCard'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +describe('EntityVaultCard', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + const entityAnnotationMissing: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + description: 'This is the description', + }, + spec: { + lifecycle: 'production', + owner: 'owner', + type: 'service', + }, + }; + + it('should render missing entity annotation', async () => { + const rendered = render( + + + , + ); + expect( + rendered.getByText(/Add the annotation to your component YAML/), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.tsx b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.tsx new file mode 100644 index 0000000000..ae8e28cb01 --- /dev/null +++ b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.tsx @@ -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 React from 'react'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { isVaultAvailable } from '../../conditions'; +import { VAULT_SECRET_PATH_ANNOTATION } from '../../constants'; +import { EntityVaultTable } from '../EntityVaultTable'; +import { MissingAnnotationEmptyState } from '@backstage/core-components'; + +export const EntityVaultCard = () => { + const { entity } = useEntity(); + + if (isVaultAvailable(entity)) { + return ; + } + return ( + + ); +}; diff --git a/plugins/vault/src/components/EntityVaultCard/index.ts b/plugins/vault/src/components/EntityVaultCard/index.ts new file mode 100644 index 0000000000..0b91f6dde4 --- /dev/null +++ b/plugins/vault/src/components/EntityVaultCard/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { EntityVaultCard } from './EntityVaultCard'; diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx new file mode 100644 index 0000000000..3538d2e0ae --- /dev/null +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 React from 'react'; +import { setupServer } from 'msw/node'; +import { + setupRequestMockHandlers, + TestApiRegistry, +} from '@backstage/test-utils'; +import { ComponentEntity } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityVaultTable } from './EntityVaultTable'; +import { ApiProvider, UrlPatternDiscovery } from '@backstage/core-app-api'; +import { VaultSecret, vaultApiRef, VaultClient } from '../../api'; +import { rest } from 'msw'; + +describe('EntityVautTable', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + let apis: TestApiRegistry; + const mockBaseUrl = 'https://api-vault.com/api/vault'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const entityOk: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + description: 'This is the description', + annotations: { + 'vault.io/secrets-path': 'test/success', + }, + }, + spec: { + lifecycle: 'production', + owner: 'owner', + type: 'service', + }, + }; + + const entityNotOk: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + description: 'This is the description', + annotations: { + 'vault.io/secrets-path': 'test/error', + }, + }, + spec: { + lifecycle: 'production', + owner: 'owner', + type: 'service', + }, + }; + + const mockSecretsResult: VaultSecret[] = [ + { + name: 'secret::one', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::one`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::one`, + }, + { + name: 'secret::two', + editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::two`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::two`, + }, + ]; + + const setupHandlers = () => { + server.use( + rest.get(`${mockBaseUrl}/v1/secrets/:path`, (req, res, ctx) => { + const { path } = req.params; + if (path === 'test%2Fsuccess') { + return res(ctx.json(mockSecretsResult)); + } else if (path === 'test%2Ferror') { + return res(ctx.json([])); + } + return res(ctx.status(400)); + }), + ); + }; + + beforeEach(() => { + apis = TestApiRegistry.from([ + vaultApiRef, + new VaultClient({ discoveryApi }), + ]); + }); + + it('should render secrets', async () => { + setupHandlers(); + const rendered = await renderInTestApp( + + + , + ); + + expect(await rendered.findAllByText(/secret::one/)).toBeDefined(); + expect(await rendered.findAllByText(/secret::two/)).toBeDefined(); + }); + + it('should render no secrets found', async () => { + setupHandlers(); + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByText(/No secrets found/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx new file mode 100644 index 0000000000..197c82c5b8 --- /dev/null +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx @@ -0,0 +1,100 @@ +/* + * 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 React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { Box, Typography } from '@material-ui/core'; +import Edit from '@material-ui/icons/Edit'; +import Visibility from '@material-ui/icons/Visibility'; +import Alert from '@material-ui/lab/Alert'; +import useAsync from 'react-use/lib/useAsync'; +import { VaultSecret, vaultApiRef } from '../../api'; +import { VAULT_SECRET_PATH_ANNOTATION } from '../../constants'; + +export const vaultSecretPath = (entity: Entity) => { + const secretPath = + entity.metadata.annotations?.[VAULT_SECRET_PATH_ANNOTATION] ?? ''; + + return { secretPath }; +}; + +export const EntityVaultTable = ({ entity }: { entity: Entity }) => { + const vaultApi = useApi(vaultApiRef); + const { secretPath } = vaultSecretPath(entity); + const { value, loading, error } = useAsync(async (): Promise< + VaultSecret[] + > => { + return vaultApi.listSecrets(secretPath); + }, []); + + const columns: TableColumn[] = [ + { title: 'Secret', field: 'secret', highlight: true }, + { title: 'View URL', field: 'view', width: '10%' }, + { title: 'Edit URL', field: 'edit', width: '10%' }, + ]; + + const data = (value || []).map(secret => { + return { + secret: secret.name, + view: ( + + + + ), + edit: ( + + + + ), + }; + }); + + if (error) { + return {error.message}; + } + + return ( + + + No secrets found for {entity.metadata.name} in {secretPath} + + + } + /> + ); +}; diff --git a/plugins/vault/src/components/EntityVaultTable/index.tsx b/plugins/vault/src/components/EntityVaultTable/index.tsx new file mode 100644 index 0000000000..eeb8440b5c --- /dev/null +++ b/plugins/vault/src/components/EntityVaultTable/index.tsx @@ -0,0 +1,16 @@ +/* + * 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 { EntityVaultTable } from './EntityVaultTable'; diff --git a/plugins/vault/src/conditions.ts b/plugins/vault/src/conditions.ts new file mode 100644 index 0000000000..0ce34085e0 --- /dev/null +++ b/plugins/vault/src/conditions.ts @@ -0,0 +1,23 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { VAULT_SECRET_PATH_ANNOTATION } from './constants'; + +export function isVaultAvailable(entity: Entity): boolean { + return Boolean( + entity.metadata.annotations?.hasOwnProperty(VAULT_SECRET_PATH_ANNOTATION), + ); +} diff --git a/plugins/vault/src/constants.ts b/plugins/vault/src/constants.ts new file mode 100644 index 0000000000..1ac8b80b73 --- /dev/null +++ b/plugins/vault/src/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 const VAULT_SECRET_PATH_ANNOTATION = 'vault.io/secrets-path'; diff --git a/plugins/vault/src/index.ts b/plugins/vault/src/index.ts new file mode 100644 index 0000000000..c2d1fc3419 --- /dev/null +++ b/plugins/vault/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { vaultPlugin, EntityVaultCard } from './plugin'; diff --git a/plugins/vault/src/plugin.test.ts b/plugins/vault/src/plugin.test.ts new file mode 100644 index 0000000000..b6c5a9c9a0 --- /dev/null +++ b/plugins/vault/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { vaultPlugin } from './plugin'; + +describe('vault', () => { + it('should export plugin', () => { + expect(vaultPlugin).toBeDefined(); + }); +}); diff --git a/plugins/vault/src/plugin.ts b/plugins/vault/src/plugin.ts new file mode 100644 index 0000000000..8275505abb --- /dev/null +++ b/plugins/vault/src/plugin.ts @@ -0,0 +1,56 @@ +/* + * 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 { + createApiFactory, + createComponentExtension, + createPlugin, + DiscoveryApi, + discoveryApiRef, +} from '@backstage/core-plugin-api'; + +import { VaultClient, vaultApiRef } from './api'; + +/** + * The vault plugin. + * @public + */ +export const vaultPlugin = createPlugin({ + id: 'vault', + apis: [ + createApiFactory({ + api: vaultApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }: { discoveryApi: DiscoveryApi }) => + new VaultClient({ + discoveryApi, + }), + }), + ], +}); + +/** + * Card used to show the list of Vault secrets. + * @public + */ +export const EntityVaultCard = vaultPlugin.provide( + createComponentExtension({ + name: 'EntityVaultCard', + component: { + lazy: () => + import('./components/EntityVaultCard').then(m => m.EntityVaultCard), + }, + }), +); diff --git a/plugins/vault/src/routes.ts b/plugins/vault/src/routes.ts new file mode 100644 index 0000000000..40743b524f --- /dev/null +++ b/plugins/vault/src/routes.ts @@ -0,0 +1,20 @@ +/* + * 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 { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'vault', +}); diff --git a/plugins/vault/src/setupTests.ts b/plugins/vault/src/setupTests.ts new file mode 100644 index 0000000000..b201a9c83e --- /dev/null +++ b/plugins/vault/src/setupTests.ts @@ -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. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/yarn.lock b/yarn.lock index c7795b8004..73b604becb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5880,7 +5880,7 @@ dependencies: commander "*" -"@types/compression@^1.7.0": +"@types/compression@^1.7.0", "@types/compression@^1.7.2": version "1.7.2" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz#7cc1cdb01b4730eea284615a68fc70a2cdfd5e71" integrity sha512-lwEL4M/uAGWngWFLSG87ZDr2kLrbuR8p7X+QZB1OQlT+qkHsCPDVFnHPyXf4Vyl4yDDorNY+mAhosxkCvppatg== @@ -9823,7 +9823,7 @@ component-emitter@1.2.1: resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= -component-emitter@^1.2.1, component-emitter@^1.3.0, component-emitter@~1.3.0: +component-emitter@^1.2.0, component-emitter@^1.2.1, component-emitter@^1.3.0, component-emitter@~1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -10137,7 +10137,7 @@ cookie@0.5.0, cookie@~0.5.0: resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookiejar@^2.1.3: +cookiejar@^2.1.0, cookiejar@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ== @@ -13145,7 +13145,7 @@ form-data-encoder@^1.4.3, form-data-encoder@^1.7.1: resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz#ac80660e4f87ee0d3d3c3638b7da8278ddb8ec96" integrity sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg== -form-data@^2.3.2, form-data@^2.5.0: +form-data@^2.3.1, form-data@^2.3.2, form-data@^2.5.0: version "2.5.1" resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== @@ -13202,6 +13202,11 @@ formdata-node@^4.3.1: node-domexception "1.0.0" web-streams-polyfill "4.0.0-beta.1" +formidable@^1.2.0: + version "1.2.6" + resolved "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" + integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== + formidable@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz#4310bc7965d185536f9565184dee74fbb75557ff" @@ -18022,7 +18027,7 @@ meros@^1.1.4: resolved "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz#c17994d3133db8b23807f62bec7f0cb276cfd948" integrity sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ== -methods@^1.0.0, methods@^1.1.2, methods@~1.1.2: +methods@^1.0.0, methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -18361,7 +18366,7 @@ mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, m dependencies: mime-db "1.52.0" -mime@1.6.0, mime@^1.3.4: +mime@1.6.0, mime@^1.3.4, mime@^1.4.1: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -21224,7 +21229,7 @@ q@^1.5.1: resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= -qs@6.10.3, qs@^6.10.1, qs@^6.10.2, qs@^6.10.3, qs@^6.9.1, qs@^6.9.4, qs@^6.9.6: +qs@6.10.3, qs@^6.10.1, qs@^6.10.2, qs@^6.10.3, qs@^6.5.1, qs@^6.9.1, qs@^6.9.4, qs@^6.9.6: version "6.10.3" resolved "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== @@ -24051,6 +24056,22 @@ sucrase@^3.18.0, sucrase@^3.20.2: pirates "^4.0.1" ts-interface-checker "^0.1.9" +superagent@^3.8.3: + version "3.8.3" + resolved "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.2.0" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.3.5" + superagent@^7.1.3: version "7.1.3" resolved "https://registry.npmjs.org/superagent/-/superagent-7.1.3.tgz#783ff8330e7c2dad6ad8f0095edc772999273b6b" @@ -24068,6 +24089,14 @@ superagent@^7.1.3: readable-stream "^3.6.0" semver "^7.3.7" +supertest@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" + integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== + dependencies: + methods "^1.1.2" + superagent "^3.8.3" + supertest@^6.1.3, supertest@^6.1.6: version "6.2.3" resolved "https://registry.npmjs.org/supertest/-/supertest-6.2.3.tgz#291b220126e5faa654d12abe1ada3658757c8c67" @@ -25945,7 +25974,7 @@ winston-transport@^4.5.0: readable-stream "^3.6.0" triple-beam "^1.3.0" -winston@^3.2.1: +winston@^3.2.1, winston@^3.7.2: version "3.7.2" resolved "https://registry.npmjs.org/winston/-/winston-3.7.2.tgz#95b4eeddbec902b3db1424932ac634f887c400b1" integrity sha512-QziIqtojHBoyzUOdQvQiar1DH0Xp9nF1A1y7NVy2DGEsz82SBDtOalS0ulTRGVT14xPX3WRWkCsdcJKqNflKng== @@ -26441,6 +26470,11 @@ yn@^4.0.0: resolved "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz#611480051ea43b510da1dfdbe177ed159f00a979" integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== +yn@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/yn/-/yn-5.0.0.tgz#63fc2e2e0056cf294397eed6ad4a3fbdf707f26f" + integrity sha512-+l37+9TyGEsyxGLaTg6QgYy5KnOp74ZZl4dPFLQpBWSkO99uBC5jnS0pOGwXFViPbiaEtWbParH2KrgWWF2duQ== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"