From 42a337410fb29692ebcfd54d55dace25d221d7c7 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Fri, 25 Aug 2023 09:03:46 -0700 Subject: [PATCH] UI: add copyable paths for CLI and API commands to kv v2 (#22551) * add paths route * WIP copy secret path component * wip component * ad v1 * use each-in to iterate over info table row * update copy * add commands to kv paths page * add comments * WIP tests * finish tests * remove version, address comments and use path arg directly remove secret * update copy * fix typo for perms * remove destructuring, that was confusing * add changelog * add secure protocal --- changelog/22551.txt | 3 + ui/lib/core/addon/components/code-snippet.hbs | 2 +- .../core/addon/components/info-table-row.hbs | 2 +- .../addon/components/page/secret/details.hbs | 1 + .../page/secret/metadata/details.hbs | 1 + .../page/secret/metadata/version-history.hbs | 1 + .../kv/addon/components/page/secret/paths.hbs | 63 ++++++++ .../kv/addon/components/page/secret/paths.js | 72 +++++++++ ui/lib/kv/addon/routes.js | 1 + ui/lib/kv/addon/routes/secret/paths.js | 20 +++ ui/lib/kv/addon/templates/secret/paths.hbs | 6 + ui/tests/helpers/kv/kv-selectors.js | 5 + .../kv/page/kv-page-secret-paths-test.js | 148 ++++++++++++++++++ 13 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 changelog/22551.txt create mode 100644 ui/lib/kv/addon/components/page/secret/paths.hbs create mode 100644 ui/lib/kv/addon/components/page/secret/paths.js create mode 100644 ui/lib/kv/addon/routes/secret/paths.js create mode 100644 ui/lib/kv/addon/templates/secret/paths.hbs create mode 100644 ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js diff --git a/changelog/22551.txt b/changelog/22551.txt new file mode 100644 index 0000000000..fa3c9483ae --- /dev/null +++ b/changelog/22551.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Copyable KV v2 paths in UI**: KV v2 secret paths are copyable for use in CLI commands or API calls +``` \ No newline at end of file diff --git a/ui/lib/core/addon/components/code-snippet.hbs b/ui/lib/core/addon/components/code-snippet.hbs index 05c85ea731..a0b50d2d07 100644 --- a/ui/lib/core/addon/components/code-snippet.hbs +++ b/ui/lib/core/addon/components/code-snippet.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
+
{{@codeBlock}} diff --git a/ui/lib/core/addon/components/info-table-row.hbs b/ui/lib/core/addon/components/info-table-row.hbs index 501ac6dac3..bcf527252f 100644 --- a/ui/lib/core/addon/components/info-table-row.hbs +++ b/ui/lib/core/addon/components/info-table-row.hbs @@ -6,7 +6,7 @@ {{#if (or (has-block) this.isVisible)}}
diff --git a/ui/lib/kv/addon/components/page/secret/details.hbs b/ui/lib/kv/addon/components/page/secret/details.hbs index 440aa4243e..23fd323c5b 100644 --- a/ui/lib/kv/addon/components/page/secret/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/details.hbs @@ -2,6 +2,7 @@ <:tabLinks> Secret Metadata + Paths {{#if @secret.canReadMetadata}} Version History {{/if}} diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs index 803449c80c..47d3338f1e 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs @@ -2,6 +2,7 @@ <:tabLinks> Secret Metadata + Paths {{#if @secret.canReadMetadata}} Version History {{/if}} diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs index f20379758e..03912fefa8 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs @@ -2,6 +2,7 @@ <:tabLinks> Secret Metadata + Paths Version History diff --git a/ui/lib/kv/addon/components/page/secret/paths.hbs b/ui/lib/kv/addon/components/page/secret/paths.hbs new file mode 100644 index 0000000000..f5273ea2ea --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/paths.hbs @@ -0,0 +1,63 @@ + + <:tabLinks> + Secret + Metadata + Paths + {{#if @canReadMetadata}} + Version History + {{/if}} + + + +

+ Paths +

+ +
+ {{#each this.paths as |path|}} + + {{! replace with Hds::Copy::Snippet }} + + + + + {{path.snippet}} + + + {{/each}} +
+ +

+ Commands +

+ +
+

+ CLI + +

+

+ This command retrieves the value from KV secrets engine at the given key name. For other CLI commands, + + learn more. + +

+ + +

+ API read secret version +

+

+ This command obtains data and metadata for the latest version of this secret. In this example, Vault is located at + https://127.0.0.1:8200. For other API commands, + + learn more. + +

+ +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/paths.js b/ui/lib/kv/addon/components/page/secret/paths.js new file mode 100644 index 0000000000..9a68d3da6b --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/paths.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path'; + +/** + * @module KvSecretPaths is used to display copyable secret paths for KV v2 for CLI and API use. + * This view is permission agnostic because args come from the views mount path and url params. + * + * + * + * @param {string} path - kv secret path for building the CLI and API paths + * @param {string} backend - the secret engine mount path, comes from the secretMountPath service defined in the route + * @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component + * @param {boolean} [canReadMetadata=true] - if true, displays tab for Version History + */ + +export default class KvSecretPaths extends Component { + @service namespace; + + get paths() { + const { backend, path } = this.args; + const namespace = this.namespace.path; + const cli = `-mount="${backend}" "${path}"`; + const data = kvDataPath(backend, path); + const metadata = kvMetadataPath(backend, path); + + return [ + { + label: 'API path', + snippet: namespace ? `/v1/${namespace}/${data}` : `/v1/${data}`, + text: 'Use this path when referring to this secret in the API.', + }, + { + label: 'CLI path', + snippet: namespace ? `-namespace=${namespace} ${cli}` : cli, + text: 'Use this path when referring to this secret in the CLI.', + }, + { + label: 'API path for metadata', + snippet: namespace ? `/v1/${namespace}/${metadata}` : `/v1/${metadata}`, + text: `Use this path when referring to this secret's metadata in the API and permanent secret deletion.`, + }, + ]; + } + + get commands() { + const cliPath = this.paths.findBy('label', 'CLI path').snippet; + const apiPath = this.paths.findBy('label', 'API path').snippet; + // as a future improvement, it might be nice to use window.location.protocol here: + const url = `https://127.0.0.1:8200${apiPath}`; + + return { + cli: `vault kv get ${cliPath}`, + /* eslint-disable-next-line no-useless-escape */ + apiCopy: `curl \ --header "X-Vault-Token: ..." \ --request GET \ ${url}`, + apiDisplay: `curl \\ + --header "X-Vault-Token: ..." \\ + --request GET \\ + ${url}`, + }; + } +} diff --git a/ui/lib/kv/addon/routes.js b/ui/lib/kv/addon/routes.js index e356c0031b..529ecbe8d5 100644 --- a/ui/lib/kv/addon/routes.js +++ b/ui/lib/kv/addon/routes.js @@ -12,6 +12,7 @@ export default buildRoutes(function () { this.route('list-directory', { path: '/:path_to_secret/directory' }); this.route('create'); this.route('secret', { path: '/:name' }, function () { + this.route('paths'); this.route('details', function () { this.route('edit'); // route to create new version of a secret }); diff --git a/ui/lib/kv/addon/routes/secret/paths.js b/ui/lib/kv/addon/routes/secret/paths.js new file mode 100644 index 0000000000..a42eb8edeb --- /dev/null +++ b/ui/lib/kv/addon/routes/secret/paths.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs'; + +export default class KvSecretPathsRoute extends Route { + setupController(controller, resolvedModel) { + super.setupController(controller, resolvedModel); + + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: resolvedModel.backend, route: 'list' }, + ...breadcrumbsForSecret(resolvedModel.path), + { label: 'paths' }, + ]; + } +} diff --git a/ui/lib/kv/addon/templates/secret/paths.hbs b/ui/lib/kv/addon/templates/secret/paths.hbs new file mode 100644 index 0000000000..bc415b0c60 --- /dev/null +++ b/ui/lib/kv/addon/templates/secret/paths.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index e9230e7c23..a9bf7eedec 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -59,6 +59,11 @@ export const PAGE = { create: { metadataSection: '[data-test-metadata-section]', }, + paths: { + copyButton: (label) => `${PAGE.infoRowValue(label)} button`, + codeSnippet: (section) => `[data-test-code-snippet][data-test-commands="${section}"] code`, + snippetCopy: (section) => `[data-test-code-snippet][data-test-commands="${section}"] button`, + }, }; // Form/Interactive selectors that are common between pages and forms diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js new file mode 100644 index 0000000000..342e7e90e6 --- /dev/null +++ b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js @@ -0,0 +1,148 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupEngine } from 'ember-engines/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; +/* eslint-disable no-useless-escape */ + +module('Integration | Component | kv-v2 | Page::Secret::Paths', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'kv'); + + hooks.beforeEach(async function () { + this.backend = 'kv-engine'; + this.path = 'my-secret'; + this.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: this.backend, route: 'list' }, + { label: this.path }, + ]; + + this.assertClipboard = (assert, element, expected) => { + assert.dom(element).hasAttribute('data-clipboard-text', expected); + }; + }); + + test('it renders copyable paths', async function (assert) { + assert.expect(6); + + const paths = [ + { label: 'API path', expected: `/v1/${this.backend}/data/${this.path}` }, + { label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` }, + { label: 'API path for metadata', expected: `/v1/${this.backend}/metadata/${this.path}` }, + ]; + + await render( + hbs` + + `, + { owner: this.engine } + ); + + for (const path of paths) { + assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected); + this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected); + } + }); + + test('it renders copyable encoded mount and secret paths', async function (assert) { + assert.expect(6); + this.path = `my spacey!"secret`; + this.backend = `my fancy!"backend`; + const backend = encodeURIComponent(this.backend); + const path = encodeURIComponent(this.path); + const paths = [ + { + label: 'API path', + expected: `/v1/${backend}/data/${path}`, + }, + { label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` }, + { + label: 'API path for metadata', + expected: `/v1/${backend}/metadata/${path}`, + }, + ]; + + await render( + hbs` + + `, + { owner: this.engine } + ); + + for (const path of paths) { + assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected); + this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected); + } + }); + + test('it renders copyable commands', async function (assert) { + assert.expect(4); + const url = `https://127.0.0.1:8200/v1/${this.backend}/data/${this.path}`; + const expected = { + cli: `vault kv get -mount="${this.backend}" "${this.path}"`, + apiDisplay: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`, + apiCopy: `curl --header \"X-Vault-Token: ...\" --request GET \ ${url}`, + }; + await render( + hbs` + + `, + { owner: this.engine } + ); + + assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli); + assert.dom(PAGE.paths.snippetCopy('cli')).hasAttribute('data-clipboard-text', expected.cli); + assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.apiDisplay); + assert.dom(PAGE.paths.snippetCopy('api')).hasAttribute('data-clipboard-text', expected.apiCopy); + }); + + test('it renders copyable encoded mount and path commands', async function (assert) { + assert.expect(4); + this.path = `my spacey!"secret`; + this.backend = `my fancy!"backend`; + + const backend = encodeURIComponent(this.backend); + const path = encodeURIComponent(this.path); + const url = `https://127.0.0.1:8200/v1/${backend}/data/${path}`; + + const expected = { + cli: `vault kv get -mount="${this.backend}" "${this.path}"`, + apiDisplay: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`, + apiCopy: `curl --header \"X-Vault-Token: ...\" --request GET \ ${url}`, + }; + await render( + hbs` + + `, + { owner: this.engine } + ); + + assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli); + assert.dom(PAGE.paths.snippetCopy('cli')).hasAttribute('data-clipboard-text', expected.cli); + assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.apiDisplay); + assert.dom(PAGE.paths.snippetCopy('api')).hasAttribute('data-clipboard-text', expected.apiCopy); + }); +});