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
@@ -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 vault that vault curator is using to fetch
all the relative secrets for a component. 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
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+140
View File
@@ -0,0 +1,140 @@
# @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<Router> {
return await createRouter({
logger: env.logger,
config: env.config,
});
}
```
```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:
sourceUrl: http://your-vault-url
token: <VAULT_TOKEN>
secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets'
kvVersion: <kv-version> # 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. 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 { Duration } from 'luxon';
import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = VaultBuilder.createBuilder({
logger: env.logger,
config: env.config,
})
.setVaultTokenRefreshInterval(Duration.fromObject({ hours: 5 })) // Optional, by default it's executed every hour
.enableTokenRenew();
const { router } = await 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.
+41
View File
@@ -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 Jira plugin */
export interface Config {
vault?: {
/**
* The sourceUrl for your Vault instance.
*/
sourceUrl: 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;
};
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@backstage/plugin-vault-backend",
"description": "A Backstage backend plugin that integrates towards Vault",
"version": "0.1.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.13.2",
"@backstage/config": "^1.0.0",
"@types/compression": "^1.7.2",
"@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",
"luxon": "^2.4.0",
"winston": "^3.7.2",
"yn": "^5.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.0",
"@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"
}
+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 {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+96
View File
@@ -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 = (
<Grid container spacing={3} alignItems="stretch">
{/* ...other content */}
<Grid item md={6} xs={12}>
<EntityVaultCard />
</Grid>
);
```
3. Add some extra configurations in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml).
```yaml
vault:
sourceUrl: http://your-vault-url
token: <VAULT_TOKEN>
secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets'
kvVersion: <kv-version> # 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.
+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.
*/
import { createDevApp } from '@backstage/dev-utils';
import { vaultPlugin } from '../src/plugin';
createDevApp().registerPlugin(vaultPlugin).render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+66
View File
@@ -0,0 +1,66 @@
{
"name": "@backstage/plugin-vault",
"description": "A Backstage plugin that integrates towards Vault",
"version": "0.1.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.1",
"@backstage/core-components": "^0.9.3",
"@backstage/core-plugin-api": "^1.0.1",
"@backstage/plugin-catalog-react": "^1.0.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.0",
"@backstage/core-app-api": "^1.0.1",
"@backstage/dev-utils": "^1.0.1",
"@backstage/test-utils": "^1.0.1",
"@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"
]
}
+73
View File
@@ -0,0 +1,73 @@
/*
* 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<VaultApi>({
id: 'plugin.vault.service',
});
export type VaultSecretList = {
data: {
keys: string[];
};
};
export type Secret = {
name: string;
showUrl: string;
editUrl: string;
};
export interface VaultApi {
listSecrets(secretPath: string): Promise<Secret[]>;
}
export class VaultClient implements VaultApi {
private readonly discoveryApi: DiscoveryApi;
constructor({ discoveryApi }: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = discoveryApi;
}
private async callApi<T>(
path: string,
query: { [key in string]: any },
): Promise<T | undefined> {
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<Secret[]> {
const result = await this.callApi<Secret[]>('v1/secrets', {
path: secretPath,
});
if (!result) {
return [];
}
return result;
}
}
@@ -0,0 +1,75 @@
/*
* 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 { makeStyles, Typography } from '@material-ui/core';
import { isVaultAvailable } from '../../conditions';
import { CodeSnippet, InfoCard, Button } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import { VAULT_SECRET_PATH_ANNOTATION } from '../../constants';
import { EntityVaultTable } from '../EntityVaultTable';
const COMPONENT_YAML = `metadata:
name: example
annotations:
${VAULT_SECRET_PATH_ANNOTATION}: value`;
const useStyles = makeStyles<BackstageTheme>(
theme => ({
code: {
borderRadius: 6,
margin: `${theme.spacing(2)}px 0px`,
background: theme.palette.type === 'dark' ? '#444' : '#fff',
},
}),
{ name: 'BackstageMissingVaultAnnotation' },
);
export const EntityVaultCard = () => {
const { entity } = useEntity();
const classes = useStyles();
if (isVaultAvailable(entity)) {
return <EntityVaultTable entity={entity} />;
}
return (
<InfoCard title="Vault">
<>
<Typography variant="body1">
Add the annotation to your component YAML as shown in the highlighted
example below:
</Typography>
<div className={classes.code}>
<CodeSnippet
text={COMPONENT_YAML}
language="yaml"
showLineNumbers
highlightedNumbers={[3, 4]}
customStyle={{ background: 'inherit', fontSize: '115%' }}
/>
</div>
<Button
color="primary"
variant="contained"
style={{ textDecoration: 'none' }}
to="https://backstage.io/docs/features/software-catalog/well-known-annotations"
>
Read more
</Button>
</>
</InfoCard>
);
};
@@ -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';
@@ -0,0 +1,98 @@
/*
* 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 { Secret, 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<Secret[]> => {
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: (
<a
aria-label="View"
title={`View ${secret.name}`}
href={secret.showUrl}
>
<Visibility style={{ fontSize: 16 }} />
</a>
),
edit: (
<a
aria-label="Edit"
title={`Edit ${secret.name}`}
href={secret.editUrl}
>
<Edit style={{ fontSize: 16 }} />
</a>
),
};
});
if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return (
<Table
title="Vault"
subtitle={`Secrets for ${entity.metadata.name} in ${secretPath}`}
columns={columns}
data={data}
isLoading={loading}
options={{
padding: 'dense',
pageSize: 10,
emptyRowsWhenPaging: false,
search: false,
}}
emptyContent={
<Box style={{ textAlign: 'center', padding: '15px' }}>
<Typography variant="body1">
No secrets found for {entity.metadata.name} in {secretPath}
</Typography>
</Box>
}
/>
);
};
@@ -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';
+23
View File
@@ -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),
);
}
+16
View File
@@ -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';
+16
View File
@@ -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';
+22
View File
@@ -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();
});
});
+48
View File
@@ -0,0 +1,48 @@
/*
* 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';
export const vaultPlugin = createPlugin({
id: 'vault',
apis: [
createApiFactory({
api: vaultApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }: { discoveryApi: DiscoveryApi }) =>
new VaultClient({
discoveryApi,
}),
}),
],
});
export const EntityVaultCard = vaultPlugin.provide(
createComponentExtension({
name: 'EntityVaultCard',
component: {
lazy: () =>
import('./components/EntityVaultCard').then(m => m.EntityVaultCard),
},
}),
);
+20
View File
@@ -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',
});
+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.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+2271 -2706
View File
File diff suppressed because it is too large Load Diff