mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +00:00
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
This commit is contained in:
3
changelog/22551.txt
Normal file
3
changelog/22551.txt
Normal file
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
ui/lib/kv/addon/components/page/secret/paths.hbs
Normal file
63
ui/lib/kv/addon/components/page/secret/paths.hbs
Normal file
@@ -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
ui/lib/kv/addon/components/page/secret/paths.js
Normal file
72
ui/lib/kv/addon/components/page/secret/paths.js
Normal file
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
20
ui/lib/kv/addon/routes/secret/paths.js
Normal file
20
ui/lib/kv/addon/routes/secret/paths.js
Normal file
@@ -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
ui/lib/kv/addon/templates/secret/paths.hbs
Normal file
6
ui/lib/kv/addon/templates/secret/paths.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
<Page::Secret::Paths
|
||||
@path={{this.model.path}}
|
||||
@backend={{this.model.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@canReadMetadata={{this.model.secret.canReadMetadata}}
|
||||
/>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user