Skip to content

Commit

Permalink
UI: add copyable paths for CLI and API commands to kv v2 (#22551)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hellobontempo committed Aug 25, 2023
1 parent 0f5a39c commit 42a3374
Show file tree
Hide file tree
Showing 13 changed files with 323 additions and 2 deletions.
3 changes: 3 additions & 0 deletions changelog/22551.txt
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion ui/lib/core/addon/components/code-snippet.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}

<div class="code-snippet-container">
<div data-test-code-snippet class="code-snippet-container" ...attributes>
<code class="text-grey-lightest">
{{@codeBlock}}
</code>
Expand Down
2 changes: 1 addition & 1 deletion ui/lib/core/addon/components/info-table-row.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{{#if (or (has-block) this.isVisible)}}
<div class="info-table-row" data-test-component="info-table-row" ...attributes>
<div
class="column is-one-quarter {{if this.hasLabelOverflow 'label-overflow'}}"
class="column {{or @labelWidth 'is-one-quarter'}} {{if this.hasLabelOverflow 'label-overflow'}}"
data-test-label-div
{{did-insert this.calculateLabelOverflow}}
>
Expand Down
1 change: 1 addition & 0 deletions ui/lib/kv/addon/components/page/secret/details.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
<LinkTo @route="secret.paths" data-test-secrets-tab="Paths">Paths</LinkTo>
{{#if @secret.canReadMetadata}}
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
{{/if}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
<LinkTo @route="secret.paths" data-test-secrets-tab="Paths">Paths</LinkTo>
{{#if @secret.canReadMetadata}}
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
{{/if}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
<LinkTo @route="secret.paths" data-test-secrets-tab="Paths">Paths</LinkTo>
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
</:tabLinks>
</KvPageHeader>
Expand Down
63 changes: 63 additions & 0 deletions ui/lib/kv/addon/components/page/secret/paths.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
<LinkTo @route="secret.paths" data-test-secrets-tab="Paths">Paths</LinkTo>
{{#if @canReadMetadata}}
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
{{/if}}
</:tabLinks>
</KvPageHeader>

<h2 class="title is-5 has-top-margin-xl">
Paths
</h2>

<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.paths as |path|}}
<InfoTableRow @label={{path.label}} @labelWidth="is-one-third" @helperText={{path.text}}>
{{! replace with Hds::Copy::Snippet }}
<CopyButton
class="button is-compact is-transparent level-right"
@clipboardText={{path.snippet}}
@buttonType="button"
@success={{fn (set-flash-message (concat path.label " copied!"))}}
>
<Icon @name="clipboard-copy" aria-label="Copy" />
</CopyButton>
<code class="has-left-margin-s level-left">
{{path.snippet}}
</code>
</InfoTableRow>
{{/each}}
</div>

<h2 class="title is-5 has-top-margin-xl">
Commands
</h2>

<div class="box is-fullwidth is-sideless">
<h3 class="is-label">
CLI
<Hds::Badge @text="kv get" @color="neutral" />
</h3>
<p class="helper-text has-text-grey has-bottom-padding-s">
This command retrieves the value from KV secrets engine at the given key name. For other CLI commands,
<DocLink @path="/vault/docs/commands/kv">
learn more.
</DocLink>
</p>
<CodeSnippet data-test-commands="cli" @codeBlock={{this.commands.cli}} />

<h3 class="has-top-margin-l is-label">
API read secret version
</h3>
<p class="helper-text has-text-grey has-bottom-padding-s">
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,
<DocLink @path="/vault/api-docs/secret/kv/kv-v2">
learn more.
</DocLink>
</p>
<CodeSnippet data-test-commands="api" @clipboardCode={{this.commands.apiCopy}} @codeBlock={{this.commands.apiDisplay}} />
</div>
72 changes: 72 additions & 0 deletions ui/lib/kv/addon/components/page/secret/paths.js
Original file line number Diff line number Diff line change
@@ -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.
*
* <Page::Secret::Paths
* @path={{this.model.path}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadMetadata={{this.model.secret.canReadMetadata}}
* />
*
* @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}`,
};
}
}
1 change: 1 addition & 0 deletions ui/lib/kv/addon/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
20 changes: 20 additions & 0 deletions ui/lib/kv/addon/routes/secret/paths.js
Original file line number Diff line number Diff line change
@@ -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' },
];
}
}
6 changes: 6 additions & 0 deletions ui/lib/kv/addon/templates/secret/paths.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Page::Secret::Paths
@path={{this.model.path}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.secret.canReadMetadata}}
/>
5 changes: 5 additions & 0 deletions ui/tests/helpers/kv/kv-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js
Original file line number Diff line number Diff line change
@@ -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`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ 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`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ 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`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ 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`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ 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);
});
});

0 comments on commit 42a3374

Please sign in to comment.