From 49c3657ac6d4176e315c6c596c3000ee875be1ed Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 08:59:15 +0200 Subject: [PATCH 01/10] feat: allow to override vault secret engine in entity level Signed-off-by: Ilya Katlinski --- plugins/vault-backend/README.md | 17 ++++++++++++- .../vault-backend/src/service/VaultBuilder.ts | 7 +++++- plugins/vault-backend/src/service/vaultApi.ts | 25 +++++++++++++------ plugins/vault/README.md | 18 ++++++++++++- plugins/vault/src/api.ts | 18 ++++++++++--- .../EntityVaultTable/EntityVaultTable.tsx | 16 ++++++++---- plugins/vault/src/constants.ts | 1 + 7 files changed, 84 insertions(+), 18 deletions(-) diff --git a/plugins/vault-backend/README.md b/plugins/vault-backend/README.md index c1b49f623a..753281c1dc 100644 --- a/plugins/vault-backend/README.md +++ b/plugins/vault-backend/README.md @@ -66,7 +66,7 @@ To get started, first you need a running instance of Vault. You can follow [this baseUrl: http://your-internal-vault-url.svc publicUrl: https://your-vault-url.example.com token: - secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets' + secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets'. Can be overwritten by the annotation of the entity kvVersion: # Optional. The K/V version that your instance is using. The available options are '1' or '2' ``` @@ -106,6 +106,20 @@ The path is relative to your secrets engine folder. So if you want to get the se 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. +In case you need to support different secret engines for entities of the catalog you can proivde optional annotion to the entity in `catalog-info.yaml`: + +```diff + apiVersion: backstage.io/v1alpha1 + kind: Component + metadata: + # ... + annotations: + vault.io/secrets-path: path/to/secrets ++ vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. +``` + +That will overwrite the default secret engine from the configuration. + ## 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: @@ -138,6 +152,7 @@ export default async function createPlugin( ## Features - List the secrets present in a certain path +- Use different secret engines for different components - Open a link to view the secret - Open a link to edit the secret - Renew the token automatically with a defined periodicity diff --git a/plugins/vault-backend/src/service/VaultBuilder.ts b/plugins/vault-backend/src/service/VaultBuilder.ts index a61d99ac70..ec118d7648 100644 --- a/plugins/vault-backend/src/service/VaultBuilder.ts +++ b/plugins/vault-backend/src/service/VaultBuilder.ts @@ -140,11 +140,16 @@ export class VaultBuilder { router.get('/v1/secrets/:path', async (req, res) => { const { path } = req.params; + const { engine } = req.query; + if (typeof path !== 'string') { throw new InputError(`Invalid path: ${path}`); } + if (engine && typeof engine !== 'string') { + throw new InputError(`Invalid engine: ${engine}`); + } - const secrets = await vaultApi.listSecrets(path); + const secrets = await vaultApi.listSecrets(path, engine); res.json({ items: secrets }); }); diff --git a/plugins/vault-backend/src/service/vaultApi.ts b/plugins/vault-backend/src/service/vaultApi.ts index b75ea76368..adcaf07d56 100644 --- a/plugins/vault-backend/src/service/vaultApi.ts +++ b/plugins/vault-backend/src/service/vaultApi.ts @@ -59,11 +59,17 @@ export interface VaultApi { * Returns the URL to access 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 + * @param secretMount - The mount point of the secrets engine, optional, overrides default secret engine */ - listSecrets(secretPath: string): Promise; + listSecrets( + secretPath: string, + secretMount?: string | undefined, + ): Promise; + /** * Optional, to renew the token used to list the secrets. Throws an * error if the token renewal went wrong. @@ -115,11 +121,16 @@ export class VaultClient implements VaultApi { return `${this.vaultConfig.baseUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}`; } - async listSecrets(secretPath: string): Promise { + async listSecrets( + secretPath: string, + secretEngine?: string | undefined, + ): Promise { + const mount = secretEngine || this.vaultConfig.secretEngine; + const listUrl = this.vaultConfig.kvVersion === 2 - ? `v1/${this.vaultConfig.secretEngine}/metadata/${secretPath}` - : `v1/${this.vaultConfig.secretEngine}/${secretPath}`; + ? `v1/${mount}/metadata/${secretPath}` + : `v1/${mount}/${secretPath}`; const result = await this.limit(() => this.callApi(listUrl, { list: true }), ); @@ -131,7 +142,7 @@ export class VaultClient implements VaultApi { if (secret.endsWith('/')) { secrets.push( ...(await this.limit(() => - this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`), + this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`, mount), )), ); } else { @@ -140,8 +151,8 @@ export class VaultClient implements VaultApi { secrets.push({ name: secret, path: secretPath, - editUrl: `${vaultUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}/edit/${secretPath}/${secret}`, - showUrl: `${vaultUrl}/ui/vault/secrets/${this.vaultConfig.secretEngine}/show/${secretPath}/${secret}`, + editUrl: `${vaultUrl}/ui/vault/secrets/${mount}/edit/${secretPath}/${secret}`, + showUrl: `${vaultUrl}/ui/vault/secrets/${mount}/show/${secretPath}/${secret}`, }); } }), diff --git a/plugins/vault/README.md b/plugins/vault/README.md index 824293632f..d128d15dd9 100644 --- a/plugins/vault/README.md +++ b/plugins/vault/README.md @@ -42,7 +42,7 @@ To get started, first you need a running instance of Vault. You can follow [this vault: baseUrl: http://your-vault-url token: - secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets' + secretEngine: 'customSecretEngine' # Optional. By default it uses 'secrets'. Can be overwritten by the annotation of the entity kvVersion: # Optional. The K/V version that your instance is using. The available options are '1' or '2' ``` @@ -67,6 +67,7 @@ metadata: # ... annotations: vault.io/secrets-path: path/to/secrets + vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ``` 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: @@ -86,9 +87,24 @@ If the annotation is missing for a certain component, then the card will show so ![Screenshot of the vault plugin with missing annotation](images/annotation-missing.png) +In case you need to support different secret engines for entities of the catalog you can proivde optional annotion to the entity in `catalog-info.yaml`: + +```diff + apiVersion: backstage.io/v1alpha1 + kind: Component + metadata: + # ... + annotations: + vault.io/secrets-path: path/to/secrets ++ vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. +``` + +That will overwrite the default secret engine from the configuration. + ## Features - List the secrets present in a certain path +- Use different secret engines for different components - Open a link to view the secret - Open a link to edit the secret - Renew the token automatically with a defined periodicity diff --git a/plugins/vault/src/api.ts b/plugins/vault/src/api.ts index d924336a2e..fa6a65a77d 100644 --- a/plugins/vault/src/api.ts +++ b/plugins/vault/src/api.ts @@ -46,8 +46,12 @@ export interface VaultApi { /** * Returns a list of secrets used to show in a table. * @param secretPath - The path where the secrets are stored in Vault + * @param secretMount - The mount point of the secrets engine, optional, overrides default secret engine */ - listSecrets(secretPath: string): Promise; + listSecrets( + secretPath: string, + secretMount?: string | undefined, + ): Promise; } /** @@ -90,10 +94,18 @@ export class VaultClient implements VaultApi { throw await ResponseError.fromResponse(response); } - async listSecrets(secretPath: string): Promise { + async listSecrets( + secretPath: string, + secretMount?: string | undefined, + ): Promise { + const query: { [key in string]: any } = {}; + if (secretMount) { + query.engine = secretMount; + } + const result = await this.callApi<{ items: VaultSecret[] }>( `v1/secrets/${encodeURIComponent(secretPath)}`, - {}, + query, ); return result.items; } diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx index 9191d54b0b..c7fad01e0c 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx @@ -23,27 +23,33 @@ 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'; +import { + VAULT_SECRET_ENGINE_ANNOTATION, + VAULT_SECRET_PATH_ANNOTATION, +} from '../../constants'; -export const vaultSecretPath = (entity: Entity) => { +export const vaultSecretConfig = (entity: Entity) => { const secretPath = entity.metadata.annotations?.[VAULT_SECRET_PATH_ANNOTATION]; + const secretEngine = + entity.metadata.annotations?.[VAULT_SECRET_ENGINE_ANNOTATION]; - return { secretPath }; + return { secretPath, secretEngine }; }; export const EntityVaultTable = ({ entity }: { entity: Entity }) => { const vaultApi = useApi(vaultApiRef); - const { secretPath } = vaultSecretPath(entity); + const { secretPath, secretEngine } = vaultSecretConfig(entity); if (!secretPath) { throw Error( `The secret path is undefined. Please, define the annotation ${VAULT_SECRET_PATH_ANNOTATION}`, ); } + const { value, loading, error } = useAsync(async (): Promise< VaultSecret[] > => { - return vaultApi.listSecrets(secretPath); + return vaultApi.listSecrets(secretPath, secretEngine); }, []); const columns: TableColumn[] = [ diff --git a/plugins/vault/src/constants.ts b/plugins/vault/src/constants.ts index 0d99c261d4..7398bc59ee 100644 --- a/plugins/vault/src/constants.ts +++ b/plugins/vault/src/constants.ts @@ -17,4 +17,5 @@ /** * @public */ +export const VAULT_SECRET_ENGINE_ANNOTATION = 'vault.io/secrets-engine'; export const VAULT_SECRET_PATH_ANNOTATION = 'vault.io/secrets-path'; From 858a18800870dd44f16b8206a5c2c7a60d8980de Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 09:06:03 +0200 Subject: [PATCH 02/10] chore: add changeset record for vault plugin changes Signed-off-by: Ilya Katlinski --- .changeset/silent-numbers-retire.md | 6 ++++++ plugins/vault-backend/README.md | 2 +- plugins/vault/README.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/silent-numbers-retire.md diff --git a/.changeset/silent-numbers-retire.md b/.changeset/silent-numbers-retire.md new file mode 100644 index 0000000000..6f260c05ef --- /dev/null +++ b/.changeset/silent-numbers-retire.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-vault-backend': minor +'@backstage/plugin-vault': minor +--- + +Added ability to override vault secretEngine value on catalog entity level using annotation `vault.io/secrets-engine` diff --git a/plugins/vault-backend/README.md b/plugins/vault-backend/README.md index 753281c1dc..250bca1ffd 100644 --- a/plugins/vault-backend/README.md +++ b/plugins/vault-backend/README.md @@ -115,7 +115,7 @@ In case you need to support different secret engines for entities of the catalog # ... annotations: vault.io/secrets-path: path/to/secrets -+ vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ++ vault.io/secrets-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ``` That will overwrite the default secret engine from the configuration. diff --git a/plugins/vault/README.md b/plugins/vault/README.md index d128d15dd9..434227561d 100644 --- a/plugins/vault/README.md +++ b/plugins/vault/README.md @@ -96,7 +96,7 @@ In case you need to support different secret engines for entities of the catalog # ... annotations: vault.io/secrets-path: path/to/secrets -+ vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ++ vault.io/secrets-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ``` That will overwrite the default secret engine from the configuration. From 2e60250c7dbecaf6340597a4f4c700823a8d1aa8 Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 10:58:46 +0200 Subject: [PATCH 03/10] test: update unit test for vault plugin api and table Signed-off-by: Ilya Katlinski --- plugins/vault/src/api.test.ts | 34 +++++++++----- .../EntityVaultTable.test.tsx | 44 +++++++++++++++---- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/plugins/vault/src/api.test.ts b/plugins/vault/src/api.test.ts index ec88412303..47d33f2d99 100644 --- a/plugins/vault/src/api.test.ts +++ b/plugins/vault/src/api.test.ts @@ -21,6 +21,9 @@ import { VaultSecret, VaultClient } from './api'; import { UrlPatternDiscovery } from '@backstage/core-app-api'; describe('api', () => { + let api: VaultClient; + let fetchApiSpy: jest.SpyInstance>; + const server = setupServer(); setupRequestMockHandlers(server); @@ -64,37 +67,48 @@ describe('api', () => { ); }; - it('should return secrets', async () => { + beforeEach(() => { setupHandlers(); - const api = new VaultClient({ discoveryApi, fetchApi }); + + api = new VaultClient({ discoveryApi, fetchApi }); + fetchApiSpy = jest.spyOn(fetchApi, 'fetch'); + }); + + it('should return secrets', async () => { expect(await api.listSecrets('test/success')).toEqual( mockSecretsResult.items, ); + expect(fetchApiSpy).toHaveBeenCalledWith( + `${mockBaseUrl}/v1/secrets/test%2Fsuccess?`, + expect.anything(), + ); + }); + + it('should return secrets with custom engine', async () => { + expect(await api.listSecrets('test/success', 'kv')).toEqual( + mockSecretsResult.items, + ); + expect(fetchApiSpy).toHaveBeenCalledWith( + `${mockBaseUrl}/v1/secrets/test%2Fsuccess?engine=kv`, + expect.anything(), + ); }); it('should return empty secret list', async () => { - setupHandlers(); - const api = new VaultClient({ discoveryApi, fetchApi }); expect(await api.listSecrets('test/empty')).toEqual([]); }); it('should return all the secrets if no path defined', async () => { - setupHandlers(); - const api = new VaultClient({ discoveryApi, fetchApi }); expect(await api.listSecrets('')).toEqual(mockSecretsResult.items); }); it('should throw an error if the Vault API responds with an HTTP 404', async () => { - setupHandlers(); - const api = new VaultClient({ discoveryApi, fetchApi }); await expect(api.listSecrets('test/not-found')).rejects.toThrow( "No secrets found in path 'v1/secrets/test%2Fnot-found'", ); }); it('should throw an error if the Vault API responds with a non-successful HTTP status code', async () => { - setupHandlers(); - const api = new VaultClient({ discoveryApi, fetchApi }); await expect(api.listSecrets('test/error')).rejects.toThrow( 'Request failed with 400 Error', ); diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx index e5bd5b43de..4cf5b60026 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx @@ -29,23 +29,31 @@ import { VaultSecret, vaultApiRef, VaultClient } from '../../api'; import { rest } from 'msw'; describe('EntityVaultTable', () => { + let vaultClient: VaultClient; + let apis: TestApiRegistry; + let listSecretsSpy: jest.SpyInstance>; + const server = setupServer(); setupRequestMockHandlers(server); - let apis: TestApiRegistry; const mockBaseUrl = 'https://api-vault.com/api/vault'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); const fetchApi = new MockFetchApi(); - const entity = (secretPath: string) => { + const entity = (secretPath: string, secretEngine?: string | undefined) => { + const annotations: Record = { + 'vault.io/secrets-path': secretPath, + }; + if (secretEngine) { + annotations['vault.io/secrets-engine'] = secretEngine; + } + return { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'test', description: 'This is the description', - annotations: { - 'vault.io/secrets-path': secretPath, - }, + annotations, }, spec: { lifecycle: 'production', @@ -56,6 +64,7 @@ describe('EntityVaultTable', () => { }; const entityOk = entity('test/success'); + const entityOkWithEngine = entity('test/success', 'kv'); const entityEmpty = entity('test/empty'); const entityNotOk = entity('test/error'); @@ -91,10 +100,9 @@ describe('EntityVaultTable', () => { }; beforeEach(() => { - apis = TestApiRegistry.from([ - vaultApiRef, - new VaultClient({ discoveryApi, fetchApi }), - ]); + vaultClient = new VaultClient({ discoveryApi, fetchApi }); + apis = TestApiRegistry.from([vaultApiRef, vaultClient]); + listSecretsSpy = jest.spyOn(vaultClient, 'listSecrets'); }); it('should render secrets', async () => { @@ -107,6 +115,24 @@ describe('EntityVaultTable', () => { expect(await rendered.findAllByText(/secret::one/)).toBeDefined(); expect(await rendered.findAllByText(/secret::two/)).toBeDefined(); + + expect(listSecretsSpy).toHaveBeenCalledTimes(1); + expect(listSecretsSpy).toHaveBeenCalledWith('test/success', undefined); + }); + + it('should render secrets with custom engine', async () => { + setupHandlers(); + const rendered = await renderInTestApp( + + + , + ); + + expect(await rendered.findAllByText(/secret::one/)).toBeDefined(); + expect(await rendered.findAllByText(/secret::two/)).toBeDefined(); + + expect(listSecretsSpy).toHaveBeenCalledTimes(1); + expect(listSecretsSpy).toHaveBeenCalledWith('test/success', 'kv'); }); it('should render no secrets found', async () => { From ad9544c483a1490bb7c4debf717bde237a43c770 Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 12:10:19 +0200 Subject: [PATCH 04/10] test: update unit test for vault backend plugin api Signed-off-by: Ilya Katlinski --- .../src/service/vaultApi.test.ts | 56 +++++++++++-------- plugins/vault/src/api.test.ts | 2 + .../EntityVaultTable.test.tsx | 1 + 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/plugins/vault-backend/src/service/vaultApi.test.ts b/plugins/vault-backend/src/service/vaultApi.test.ts index 1fb869ef60..1d037bb77a 100644 --- a/plugins/vault-backend/src/service/vaultApi.test.ts +++ b/plugins/vault-backend/src/service/vaultApi.test.ts @@ -21,6 +21,8 @@ import { VaultSecret, VaultClient, VaultSecretList } from './vaultApi'; import { ConfigReader } from '@backstage/config'; describe('VaultApi', () => { + let api: VaultClient; + const server = setupServer(); setupRequestMockHandlers(server); @@ -43,20 +45,24 @@ describe('VaultApi', () => { }, }; - const mockSecretsResult: VaultSecret[] = [ - { - name: 'secret::one', - path: 'test/success', - 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', - path: 'test/success', - editUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/edit/test/success/secret::two`, - showUrl: `${mockBaseUrl}/ui/vault/secrets/secrets/show/test/success/secret::two`, - }, - ]; + const mockSecretsResult = ( + secretEngine: string = 'secrets', + ): VaultSecret[] => { + return [ + { + name: 'secret::one', + path: 'test/success', + editUrl: `${mockBaseUrl}/ui/vault/secrets/${secretEngine}/edit/test/success/secret::one`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/${secretEngine}/show/test/success/secret::one`, + }, + { + name: 'secret::two', + path: 'test/success', + editUrl: `${mockBaseUrl}/ui/vault/secrets/${secretEngine}/edit/test/success/secret::two`, + showUrl: `${mockBaseUrl}/ui/vault/secrets/${secretEngine}/show/test/success/secret::two`, + }, + ]; + }; const setupHandlers = () => { server.use( @@ -66,6 +72,9 @@ describe('VaultApi', () => { return res(ctx.json(mockListResult)); }, ), + rest.get(`${mockBaseUrl}/v1/kv/metadata/test/success`, (_, res, ctx) => { + return res(ctx.json(mockListResult)); + }), rest.get( `${mockBaseUrl}/v1/secrets/metadata/test/error`, (_, res, ctx) => { @@ -78,28 +87,31 @@ describe('VaultApi', () => { ); }; - it('should return secrets', async () => { + beforeEach(() => { setupHandlers(); - const api = new VaultClient({ config }); + api = new VaultClient({ config }); + }); + + it('should return secrets', async () => { const secrets = await api.listSecrets('test/success'); - expect(secrets).toEqual(mockSecretsResult); + expect(secrets).toEqual(mockSecretsResult()); + }); + + it('should return secrets for custom engine', async () => { + const secrets = await api.listSecrets('test/success', 'kv'); + expect(secrets).toEqual(mockSecretsResult('kv')); }); 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 }); expect(await api.renewToken()).toBe(undefined); }); 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/src/api.test.ts b/plugins/vault/src/api.test.ts index 47d33f2d99..03c75fbce3 100644 --- a/plugins/vault/src/api.test.ts +++ b/plugins/vault/src/api.test.ts @@ -68,6 +68,8 @@ describe('api', () => { }; beforeEach(() => { + jest.resetAllMocks(); + setupHandlers(); api = new VaultClient({ discoveryApi, fetchApi }); diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx index 4cf5b60026..69fa5fec4e 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx @@ -100,6 +100,7 @@ describe('EntityVaultTable', () => { }; beforeEach(() => { + jest.resetAllMocks(); vaultClient = new VaultClient({ discoveryApi, fetchApi }); apis = TestApiRegistry.from([vaultApiRef, vaultClient]); listSecretsSpy = jest.spyOn(vaultClient, 'listSecrets'); From 1dd90e437cf5ee60adfe4ee171275325380c2923 Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 12:23:43 +0200 Subject: [PATCH 05/10] doc: fix documentation types based on reviewdog checks Signed-off-by: Ilya Katlinski --- .changeset/silent-numbers-retire.md | 2 +- plugins/vault-backend/README.md | 2 +- plugins/vault/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/silent-numbers-retire.md b/.changeset/silent-numbers-retire.md index 6f260c05ef..b386d04b14 100644 --- a/.changeset/silent-numbers-retire.md +++ b/.changeset/silent-numbers-retire.md @@ -3,4 +3,4 @@ '@backstage/plugin-vault': minor --- -Added ability to override vault secretEngine value on catalog entity level using annotation `vault.io/secrets-engine` +Added ability to override vault secret engine value on catalog entity level using annotation `vault.io/secrets-engine` diff --git a/plugins/vault-backend/README.md b/plugins/vault-backend/README.md index 250bca1ffd..aac2b68f60 100644 --- a/plugins/vault-backend/README.md +++ b/plugins/vault-backend/README.md @@ -106,7 +106,7 @@ The path is relative to your secrets engine folder. So if you want to get the se 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. -In case you need to support different secret engines for entities of the catalog you can proivde optional annotion to the entity in `catalog-info.yaml`: +In case you need to support different secret engines for entities of the catalog you can provide optional annotation to the entity in `catalog-info.yaml`: ```diff apiVersion: backstage.io/v1alpha1 diff --git a/plugins/vault/README.md b/plugins/vault/README.md index 434227561d..3fe87398ce 100644 --- a/plugins/vault/README.md +++ b/plugins/vault/README.md @@ -87,7 +87,7 @@ If the annotation is missing for a certain component, then the card will show so ![Screenshot of the vault plugin with missing annotation](images/annotation-missing.png) -In case you need to support different secret engines for entities of the catalog you can proivde optional annotion to the entity in `catalog-info.yaml`: +In case you need to support different secret engines for entities of the catalog you can provide optional annotation to the entity in `catalog-info.yaml`: ```diff apiVersion: backstage.io/v1alpha1 From cfa3b6e9e0467e2813ec87845b7ec74ad32e568d Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Tue, 8 Aug 2023 12:49:41 +0200 Subject: [PATCH 06/10] doc: update api reports as public api changed Signed-off-by: Ilya Katlinski --- plugins/vault-backend/api-report.md | 10 ++++++++-- plugins/vault/api-report.md | 5 ++++- plugins/vault/src/constants.ts | 3 +++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/vault-backend/api-report.md b/plugins/vault-backend/api-report.md index 4890e585b4..47275693ec 100644 --- a/plugins/vault-backend/api-report.md +++ b/plugins/vault-backend/api-report.md @@ -25,7 +25,10 @@ export interface RouterOptions { // @public export interface VaultApi { getFrontendSecretsUrl(): string; - listSecrets(secretPath: string): Promise; + listSecrets( + secretPath: string, + secretMount?: string | undefined, + ): Promise; renewToken?(): Promise; } @@ -49,7 +52,10 @@ export class VaultClient implements VaultApi { // (undocumented) getFrontendSecretsUrl(): string; // (undocumented) - listSecrets(secretPath: string): Promise; + listSecrets( + secretPath: string, + secretEngine?: string | undefined, + ): Promise; // (undocumented) renewToken(): Promise; } diff --git a/plugins/vault/api-report.md b/plugins/vault/api-report.md index 8c4e5da829..ee85145202 100644 --- a/plugins/vault/api-report.md +++ b/plugins/vault/api-report.md @@ -20,7 +20,10 @@ export const VAULT_SECRET_PATH_ANNOTATION = 'vault.io/secrets-path'; // @public export interface VaultApi { - listSecrets(secretPath: string): Promise; + listSecrets( + secretPath: string, + secretMount?: string | undefined, + ): Promise; } // @public (undocumented) diff --git a/plugins/vault/src/constants.ts b/plugins/vault/src/constants.ts index 7398bc59ee..a71a1c104f 100644 --- a/plugins/vault/src/constants.ts +++ b/plugins/vault/src/constants.ts @@ -18,4 +18,7 @@ * @public */ export const VAULT_SECRET_ENGINE_ANNOTATION = 'vault.io/secrets-engine'; +/** + * @public + */ export const VAULT_SECRET_PATH_ANNOTATION = 'vault.io/secrets-path'; From 1bdcf6bb7a84ce628b8c8a146585e2b9bdcab0e5 Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Sun, 10 Sep 2023 07:20:10 +0200 Subject: [PATCH 07/10] feat: adopt vault api signature calls based on proposal Signed-off-by: Ilya Katlinski --- plugins/vault-backend/README.md | 4 ++-- .../vault-backend/src/service/VaultBuilder.ts | 4 +++- .../vault-backend/src/service/vaultApi.test.ts | 4 +++- plugins/vault-backend/src/service/vaultApi.ts | 16 +++++++++++----- plugins/vault/README.md | 2 +- plugins/vault/src/api.test.ts | 6 +++--- plugins/vault/src/api.ts | 15 ++++++++++----- .../EntityVaultTable/EntityVaultTable.tsx | 2 +- 8 files changed, 34 insertions(+), 19 deletions(-) diff --git a/plugins/vault-backend/README.md b/plugins/vault-backend/README.md index aac2b68f60..8c70cfd24a 100644 --- a/plugins/vault-backend/README.md +++ b/plugins/vault-backend/README.md @@ -115,7 +115,7 @@ In case you need to support different secret engines for entities of the catalog # ... annotations: vault.io/secrets-path: path/to/secrets -+ vault.io/secrets-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. ++ vault.io/secrets-engine: customSecretEngine # Optional. By default it uses the 'secretEngine' value from your app-config. ``` That will overwrite the default secret engine from the configuration. @@ -152,7 +152,7 @@ export default async function createPlugin( ## Features - List the secrets present in a certain path -- Use different secret engines for different components +- Use different secret engines for different entities - Open a link to view the secret - Open a link to edit the secret - Renew the token automatically with a defined periodicity diff --git a/plugins/vault-backend/src/service/VaultBuilder.ts b/plugins/vault-backend/src/service/VaultBuilder.ts index ec118d7648..9856476c20 100644 --- a/plugins/vault-backend/src/service/VaultBuilder.ts +++ b/plugins/vault-backend/src/service/VaultBuilder.ts @@ -149,7 +149,9 @@ export class VaultBuilder { throw new InputError(`Invalid engine: ${engine}`); } - const secrets = await vaultApi.listSecrets(path, engine); + const secrets = await vaultApi.listSecrets(path, { + secretEngine: engine, + }); res.json({ items: secrets }); }); diff --git a/plugins/vault-backend/src/service/vaultApi.test.ts b/plugins/vault-backend/src/service/vaultApi.test.ts index 1d037bb77a..47c7c5a252 100644 --- a/plugins/vault-backend/src/service/vaultApi.test.ts +++ b/plugins/vault-backend/src/service/vaultApi.test.ts @@ -98,7 +98,9 @@ describe('VaultApi', () => { }); it('should return secrets for custom engine', async () => { - const secrets = await api.listSecrets('test/success', 'kv'); + const secrets = await api.listSecrets('test/success', { + secretEngine: 'kv', + }); expect(secrets).toEqual(mockSecretsResult('kv')); }); diff --git a/plugins/vault-backend/src/service/vaultApi.ts b/plugins/vault-backend/src/service/vaultApi.ts index adcaf07d56..d785e135df 100644 --- a/plugins/vault-backend/src/service/vaultApi.ts +++ b/plugins/vault-backend/src/service/vaultApi.ts @@ -63,11 +63,13 @@ export interface VaultApi { /** * Returns a list of secrets used to show in a table. * @param secretPath - The path where the secrets are stored in Vault - * @param secretMount - The mount point of the secrets engine, optional, overrides default secret engine + * @param options - Additional options to be passed to the Vault API, allows to override vault default settings in app config file */ listSecrets( secretPath: string, - secretMount?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise; /** @@ -123,9 +125,11 @@ export class VaultClient implements VaultApi { async listSecrets( secretPath: string, - secretEngine?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise { - const mount = secretEngine || this.vaultConfig.secretEngine; + const mount = options?.secretEngine || this.vaultConfig.secretEngine; const listUrl = this.vaultConfig.kvVersion === 2 @@ -142,7 +146,9 @@ export class VaultClient implements VaultApi { if (secret.endsWith('/')) { secrets.push( ...(await this.limit(() => - this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`, mount), + this.listSecrets(`${secretPath}/${secret.slice(0, -1)}`, { + secretEngine: mount, + }), )), ); } else { diff --git a/plugins/vault/README.md b/plugins/vault/README.md index 3fe87398ce..ce2df6e47a 100644 --- a/plugins/vault/README.md +++ b/plugins/vault/README.md @@ -67,7 +67,7 @@ metadata: # ... annotations: vault.io/secrets-path: path/to/secrets - vault.io/secret-engine: customSecretEngine # Optional. By default it uses 'secertEngine' value from configuration. + vault.io/secrets-engine: customSecretEngine # Optional. By default it uses the 'secretEngine' value from your app-config. ``` 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: diff --git a/plugins/vault/src/api.test.ts b/plugins/vault/src/api.test.ts index 03c75fbce3..eb122a92d1 100644 --- a/plugins/vault/src/api.test.ts +++ b/plugins/vault/src/api.test.ts @@ -87,9 +87,9 @@ describe('api', () => { }); it('should return secrets with custom engine', async () => { - expect(await api.listSecrets('test/success', 'kv')).toEqual( - mockSecretsResult.items, - ); + expect( + await api.listSecrets('test/success', { secretEngine: 'kv' }), + ).toEqual(mockSecretsResult.items); expect(fetchApiSpy).toHaveBeenCalledWith( `${mockBaseUrl}/v1/secrets/test%2Fsuccess?engine=kv`, expect.anything(), diff --git a/plugins/vault/src/api.ts b/plugins/vault/src/api.ts index fa6a65a77d..f01823ff22 100644 --- a/plugins/vault/src/api.ts +++ b/plugins/vault/src/api.ts @@ -46,11 +46,13 @@ export interface VaultApi { /** * Returns a list of secrets used to show in a table. * @param secretPath - The path where the secrets are stored in Vault - * @param secretMount - The mount point of the secrets engine, optional, overrides default secret engine + * @param options - Additional options to be passed to the Vault API, allows to override vault default settings in app config file */ listSecrets( secretPath: string, - secretMount?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise; } @@ -96,11 +98,14 @@ export class VaultClient implements VaultApi { async listSecrets( secretPath: string, - secretMount?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise { const query: { [key in string]: any } = {}; - if (secretMount) { - query.engine = secretMount; + const { secretEngine } = options || {}; + if (secretEngine) { + query.engine = secretEngine; } const result = await this.callApi<{ items: VaultSecret[] }>( diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx index c7fad01e0c..4d169611ca 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.tsx @@ -49,7 +49,7 @@ export const EntityVaultTable = ({ entity }: { entity: Entity }) => { const { value, loading, error } = useAsync(async (): Promise< VaultSecret[] > => { - return vaultApi.listSecrets(secretPath, secretEngine); + return vaultApi.listSecrets(secretPath, { secretEngine }); }, []); const columns: TableColumn[] = [ From f840e5ca3f707ce5f2140d23ed8abbbbdfe2930e Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Sun, 10 Sep 2023 07:31:55 +0200 Subject: [PATCH 08/10] test: fix test for entity vault table Signed-off-by: Ilya Katlinski --- .../components/EntityVaultTable/EntityVaultTable.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx index 69fa5fec4e..5e191df9eb 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx @@ -118,7 +118,9 @@ describe('EntityVaultTable', () => { expect(await rendered.findAllByText(/secret::two/)).toBeDefined(); expect(listSecretsSpy).toHaveBeenCalledTimes(1); - expect(listSecretsSpy).toHaveBeenCalledWith('test/success', undefined); + expect(listSecretsSpy).toHaveBeenCalledWith('test/success', { + secretEngine: undefined, + }); }); it('should render secrets with custom engine', async () => { @@ -133,7 +135,9 @@ describe('EntityVaultTable', () => { expect(await rendered.findAllByText(/secret::two/)).toBeDefined(); expect(listSecretsSpy).toHaveBeenCalledTimes(1); - expect(listSecretsSpy).toHaveBeenCalledWith('test/success', 'kv'); + expect(listSecretsSpy).toHaveBeenCalledWith('test/success', { + secretEngine: 'kv', + }); }); it('should render no secrets found', async () => { From 2ab316ac7cf52e32f5ac972d0ef9150853931c80 Mon Sep 17 00:00:00 2001 From: Ilya Katlinski Date: Sun, 10 Sep 2023 07:43:48 +0200 Subject: [PATCH 09/10] doc: generate api reports for vault plugins Signed-off-by: Ilya Katlinski --- plugins/vault-backend/api-report.md | 8 ++++++-- plugins/vault/api-report.md | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/vault-backend/api-report.md b/plugins/vault-backend/api-report.md index 47275693ec..ebf9057b98 100644 --- a/plugins/vault-backend/api-report.md +++ b/plugins/vault-backend/api-report.md @@ -27,7 +27,9 @@ export interface VaultApi { getFrontendSecretsUrl(): string; listSecrets( secretPath: string, - secretMount?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise; renewToken?(): Promise; } @@ -54,7 +56,9 @@ export class VaultClient implements VaultApi { // (undocumented) listSecrets( secretPath: string, - secretEngine?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise; // (undocumented) renewToken(): Promise; diff --git a/plugins/vault/api-report.md b/plugins/vault/api-report.md index 8afa6285f4..da13e2092d 100644 --- a/plugins/vault/api-report.md +++ b/plugins/vault/api-report.md @@ -23,7 +23,9 @@ export const VAULT_SECRET_PATH_ANNOTATION = 'vault.io/secrets-path'; export interface VaultApi { listSecrets( secretPath: string, - secretMount?: string | undefined, + options?: { + secretEngine?: string; + }, ): Promise; } From befd1149c54294f24cb4f0f667d24ae46e22f689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 12 Sep 2023 11:28:32 +0200 Subject: [PATCH 10/10] fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/silent-numbers-retire.md | 4 ++-- plugins/vault-backend/src/service/vaultApi.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/silent-numbers-retire.md b/.changeset/silent-numbers-retire.md index b386d04b14..ff5f11ecee 100644 --- a/.changeset/silent-numbers-retire.md +++ b/.changeset/silent-numbers-retire.md @@ -1,6 +1,6 @@ --- -'@backstage/plugin-vault-backend': minor -'@backstage/plugin-vault': minor +'@backstage/plugin-vault-backend': patch +'@backstage/plugin-vault': patch --- Added ability to override vault secret engine value on catalog entity level using annotation `vault.io/secrets-engine` diff --git a/plugins/vault-backend/src/service/vaultApi.ts b/plugins/vault-backend/src/service/vaultApi.ts index d785e135df..7c3ccc1d00 100644 --- a/plugins/vault-backend/src/service/vaultApi.ts +++ b/plugins/vault-backend/src/service/vaultApi.ts @@ -133,8 +133,8 @@ export class VaultClient implements VaultApi { const listUrl = this.vaultConfig.kvVersion === 2 - ? `v1/${mount}/metadata/${secretPath}` - : `v1/${mount}/${secretPath}`; + ? `v1/${encodeURIComponent(mount)}/metadata/${secretPath}` + : `v1/${encodeURIComponent(mount)}/${secretPath}`; const result = await this.limit(() => this.callApi(listUrl, { list: true }), ); @@ -157,8 +157,12 @@ export class VaultClient implements VaultApi { secrets.push({ name: secret, path: secretPath, - editUrl: `${vaultUrl}/ui/vault/secrets/${mount}/edit/${secretPath}/${secret}`, - showUrl: `${vaultUrl}/ui/vault/secrets/${mount}/show/${secretPath}/${secret}`, + editUrl: `${vaultUrl}/ui/vault/secrets/${encodeURIComponent( + mount, + )}/edit/${secretPath}/${secret}`, + showUrl: `${vaultUrl}/ui/vault/secrets/${encodeURIComponent( + mount, + )}/show/${secretPath}/${secret}`, }); } }),