Skip to content

Commit

Permalink
UI: add subkey request to kv v2 adapter (#27804)
Browse files Browse the repository at this point in the history
* add subkey request to ui

* WIP kv subkey display

* revert subkey changes to see view in ui

* finish subkey component

* remove reamining user facing changes

* update jsdoc

* add subtext depending on toggle

* finish tests

* organize adapter tests into modules

* add adapter tests

* woops, make beforeEach

* encode paths and add wrap secret test

* reword subkey component

* extract subkey path logic into util

* extract subkey path logic into util

* rename yielded subtext block
  • Loading branch information
hellobontempo authored Jul 29, 2024
1 parent 7d093f4 commit fe18e6c
Show file tree
Hide file tree
Showing 13 changed files with 571 additions and 230 deletions.
17 changes: 16 additions & 1 deletion ui/app/adapters/kv/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
*/

import ApplicationAdapter from '../application';
import { kvDataPath, kvDeletePath, kvDestroyPath, kvMetadataPath, kvUndeletePath } from 'vault/utils/kv-path';
import {
kvDataPath,
kvDeletePath,
kvDestroyPath,
kvMetadataPath,
kvSubkeysPath,
kvUndeletePath,
} from 'vault/utils/kv-path';
import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error';

Expand All @@ -31,6 +38,14 @@ export default class KvDataAdapter extends ApplicationAdapter {
});
}

fetchSubkeys(query) {
const { backend, path, version, depth } = query;
const url = this._url(kvSubkeysPath(backend, path, depth, version));
// TODO subkeys response handles deleted records the same as queryRecord and returns a 404
// extrapolate error handling logic from queryRecord and share between these two methods
return this.ajax(url, 'GET').then((resp) => resp.data);
}

fetchWrapInfo(query) {
const { backend, path, version, wrapTTL } = query;
const id = kvDataPath(backend, path, version);
Expand Down
4 changes: 4 additions & 0 deletions ui/app/models/kv/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default class KvSecretDataModel extends Model {
@lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath;
@lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath;
@lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath;
@lazyCapabilities(apiPath`${'backend'}/subkeys/${'path'}`, 'backend', 'path') subkeysPath;

get canDeleteLatestVersion() {
return this.dataPath.get('canDelete') !== false;
Expand Down Expand Up @@ -119,4 +120,7 @@ export default class KvSecretDataModel extends Model {
get canDeleteMetadata() {
return this.metadataPath.get('canDelete') !== false;
}
get canReadSubkeys() {
return this.subkeysPath.get('canRead') !== false;
}
}
15 changes: 11 additions & 4 deletions ui/app/styles/helper-classes/spacing.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
* SPDX-License-Identifier: BUSL-1.1
*/

/* Helpers that define margin and padding in pixels */
/*
Helpers that define margin and padding in pixels
*New pattern* Use the numerical value in the name for class selectors
i.e. "has-padding-24" instead of "has-padding-l"
*/

.is-paddingless {
padding: 0 !important;
Expand Down Expand Up @@ -33,9 +37,6 @@
.has-padding-m {
padding: $spacing-16;
}
.has-padding-l {
padding: $spacing-24;
}

.has-padding-l {
padding: $spacing-24;
Expand Down Expand Up @@ -90,6 +91,7 @@
margin: 0 !important;
}

// spacing-18 is between medium + large
.has-top-bottom-margin {
margin: $spacing-18 0rem;
}
Expand All @@ -98,6 +100,11 @@
margin: $spacing-4 0;
}

// moving towards numerical class names (i.e. -12) and away from s/m/l etc.
.has-top-bottom-margin-12 {
margin: $spacing-12 0;
}

.has-top-margin-negative-m {
margin-top: -$spacing-16;
}
Expand Down
15 changes: 15 additions & 0 deletions ui/app/utils/kv-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,18 @@ export function kvDestroyPath(backend: string, path: string) {
export function kvUndeletePath(backend: string, path: string) {
return buildKvPath(backend, path, 'undelete');
}
// TODO use query-param-string util when https://github.com/hashicorp/vault/pull/27455 is merged
export function kvSubkeysPath(
backend: string,
path: string,
depth?: number | string,
version?: number | string
) {
const apiPath = buildKvPath(backend, path, 'subkeys');
// if no version, defaults to latest
const versionParam = version ? `&version=${version}` : '';
// depth specifies the deepest nesting level the API should return
// depth=0 returns all subkeys (no limit), depth=1 returns only top-level keys
const queryParams = `?depth=${depth || '0'}${versionParam}`;
return `${apiPath}${queryParams}`;
}
16 changes: 12 additions & 4 deletions ui/lib/core/addon/components/overview-card.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
data-test-overview-card-container={{@cardTitle}}
...attributes
>
<div class="flex row-wrap space-between has-bottom-margin-m" data-test-overview-card={{@cardTitle}}>
<div class="flex row-wrap space-between has-bottom-margin-s" data-test-overview-card={{@cardTitle}}>
<Hds::Text::Display @weight="bold" @size="300" data-test-overview-card-title={{@cardTitle}}>
{{@cardTitle}}
</Hds::Text::Display>
Expand All @@ -20,9 +20,17 @@
{{/if}}
</div>

<Hds::Text::Body @color="faint" data-test-overview-card-subtitle={{@cardTitle}}>
{{@subText}}
</Hds::Text::Body>
{{! Pass @subText for text only content to use default styling. }}
{{#if @subText}}
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle={{@cardTitle}}>
{{@subText}}
</Hds::Text::Body>
{{/if}}

{{! Use the "customSubtext" yield for stylized subtext or including elements like doc links. }}
{{#if (has-block "customSubtext")}}
{{yield to="customSubtext"}}
{{/if}}

{{#if (has-block "content")}}
{{yield to="content"}}
Expand Down
48 changes: 48 additions & 0 deletions ui/lib/kv/addon/components/kv-subkeys.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}

<OverviewCard @cardTitle="Subkeys">
<:customSubtext>
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle="Subkeys">
{{#if this.showJson}}
These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with
<code>null</code>
instead. Subkey
<Hds::Link::Inline
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys"}}
>API documentation</Hds::Link::Inline>.
{{else}}
The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth.
{{/if}}
</Hds::Text::Body>
</:customSubtext>
<:action>
<div>
<Toggle @name="kv-subkeys" @checked={{this.showJson}} @onChange={{fn (mut this.showJson)}}>
<p class="has-text-grey">JSON</p>
</Toggle>
</div>
</:action>
<:content>
<div class="has-top-margin-s" data-test-overview-card-content="Subkeys">
{{#if this.showJson}}
<Hds::CodeBlock @value={{stringify @subkeys}} @hasLineNumbers={{false}} />
{{else}}
<Hds::Text::Display @tag="p" @size="200" @weight="semibold" @color="faint" class="has-bottom-margin-s">
Keys
</Hds::Text::Display>
<hr class="has-background-gray-100 is-marginless" />
{{#each-in @subkeys as |key|}}
<Hds::Text::Display @tag="p" @size="200" @weight="semibold" class="has-top-bottom-margin-12">
{{key}}
</Hds::Text::Display>
<hr class="has-background-gray-100 is-marginless" />
{{/each-in}}
{{/if}}
</div>
</:content>
</OverviewCard>
41 changes: 41 additions & 0 deletions ui/lib/kv/addon/components/kv-subkeys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

/**
* @module KvSubkeys
* @description
sample secret data:
```
{
"foo": "abc",
"bar": {
"baz": "def"
},
"quux": {}
}
```
sample subkeys:
```
this.subkeys = {
"bar": {
"baz": null
},
"foo": null,
"quux": null
}
```
*
* @example
* <KvSubkeys @subkeys={{this.subkeys}} />
*
* @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null
*/

export default class KvSubkeys extends Component {
@tracked showJson = false;
}
1 change: 1 addition & 0 deletions ui/tests/helpers/kv/kv-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const PAGE = {
destroy: '[data-test-kv-delete="destroy"]',
undelete: '[data-test-kv-delete="undelete"]',
copy: '[data-test-copy-menu-trigger]',
wrap: '[data-test-wrap-button]',
deleteModal: '[data-test-delete-modal]',
deleteModalTitle: '[data-test-delete-modal] [data-test-modal-title]',
deleteOption: 'input#delete-version',
Expand Down
57 changes: 57 additions & 0 deletions ui/tests/integration/components/kv/kv-subkeys-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';

const { overviewCard } = GENERAL;
module('Integration | Component | kv | kv-subkeys', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
hooks.beforeEach(function () {
this.subkeys = {
foo: null,
bar: {
baz: null,
},
};
this.renderComponent = async () => {
return render(hbs`<KvSubkeys @subkeys={{this.subkeys}} />`, {
owner: this.engine,
});
};
});

test('it renders', async function (assert) {
assert.expect(4);
await this.renderComponent();

assert.dom(overviewCard.title('Subkeys')).exists();
assert
.dom(overviewCard.description('Subkeys'))
.hasText(
'The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth.'
);
assert.dom(overviewCard.content('Subkeys')).hasText('Keys foo bar');
assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked('JSON toggle is not checked by default');
});

test('it toggles to JSON', async function (assert) {
assert.expect(4);
await this.renderComponent();

assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked();
await click(GENERAL.toggleInput('kv-subkeys'));
assert.dom(GENERAL.toggleInput('kv-subkeys')).isChecked('JSON toggle is checked');
assert.dom(overviewCard.description('Subkeys')).hasText(
'These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with null instead. Subkey API documentation .' // space is intentional because a trailing icon renders after the inline link
);
assert.dom(overviewCard.content('Subkeys')).hasText(JSON.stringify(this.subkeys, null, 2));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { kvDataPath, kvMetadataPath } from 'vault/utils/kv-path';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { FORM, PAGE, parseJsonEditor } from 'vault/tests/helpers/kv/kv-selectors';
import { syncStatusResponse } from 'vault/mirage/handlers/sync';
import { encodePath } from 'vault/utils/path-encoding-helpers';

module('Integration | Component | kv-v2 | Page::Secret::Details', function (hooks) {
setupRenderingTest(hooks);
Expand Down Expand Up @@ -286,6 +287,39 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
await click(`${PAGE.detail.syncAlert()} button`);
});

test('it makes request to wrap a secret', async function (assert) {
assert.expect(2);
const url = `${encodePath(this.backend)}/data/${encodePath(this.path)}`;

this.server.get(url, (schema, { requestHeaders }) => {
assert.true(true, `GET request made to url: ${url}`);
assert.strictEqual(requestHeaders['X-Vault-Wrap-TTL'], '1800', 'request header includes wrap ttl');
return {
data: null,
token: 'hvs.token',
accessor: 'nTgqnw3S4GMz8NKHsOhTBhlk',
ttl: 1800,
creation_time: '2024-07-26T10:20:32.359107-07:00',
creation_path: `${this.backend}/data/${this.path}}`,
};
});

await render(
hbs`
<Page::Secret::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);

await click(PAGE.detail.copy);
await click(PAGE.detail.wrap);
});

test('it renders sync status page alert for multiple destinations', async function (assert) {
assert.expect(3); // assert count important because confirms request made to fetch sync status twice
this.server.create('sync-association', {
Expand Down
21 changes: 18 additions & 3 deletions ui/tests/integration/components/overview-card-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ const SELECTORS = {
title: '[data-test-overview-card-title]',
subtitle: '[data-test-overview-card-subtitle]',
action: '[data-test-action-text]',
customSubtext: '[data-test-custom-subtext]',
};

module('Integration | Component overview-card', function (hooks) {
module('Integration | Component | overview-card', function (hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function () {
Expand All @@ -31,11 +32,11 @@ module('Integration | Component overview-card', function (hooks) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}}/>`);
assert.dom(SELECTORS.title).hasText('Card title');
});
test('it returns card subtext, ', async function (assert) {
test('it renders card @subText arg, ', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}} @subText={{this.subText}} />`);
assert.dom(SELECTORS.subtitle).hasText('This is subtext for card');
});
test('it returns card action text', async function (assert) {
test('it renders card action text', async function (assert) {
await render(
hbs`
<OverviewCard @cardTitle={{this.cardTitle}}>
Expand All @@ -49,4 +50,18 @@ module('Integration | Component overview-card', function (hooks) {
);
assert.dom(SELECTORS.action).hasText('View card');
});
test('it renders custom subtext text', async function (assert) {
await render(
hbs`
<OverviewCard @cardTitle={{this.cardTitle}}>
<:customSubtext>
<div data-test-custom-subtext>
Fancy yielded subtext
</div>
</:customSubtext>
</OverviewCard>
`
);
assert.dom(SELECTORS.customSubtext).hasText('Fancy yielded subtext');
});
});
Loading

0 comments on commit fe18e6c

Please sign in to comment.