Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: add subkey request to kv v2 adapter #27804

Merged
merged 17 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 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 {
buildKvPath,
kvDataPath,
kvDeletePath,
kvDestroyPath,
kvMetadataPath,
kvUndeletePath,
} from 'vault/utils/kv-path';
import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error';

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

// TODO use query-param-string util when https://github.com/hashicorp/vault/pull/27455 is merged
fetchSubkeys(query) {
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved
const { backend, path, version, depth } = query;
const apiPath = buildKvPath(backend, path, 'subkeys'); // encodes mount and secret paths
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason you didn't export a kvSubkeyPath method instead of using the base helper? Then the version and query params logic could be in there as well, and easy to unit test

Copy link
Contributor Author

@hellobontempo hellobontempo Jul 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this endpoint isn't implemented yet, it was easier for my brain to fiddle with and update the logic in one place. I can move this into a kvSubkeyPath helper. The adapter unit test felt sufficient for testing, but I can add another test for the util.

Also the default args for this endpoint work a little differently since we always send a depth param but not always a version param, so I can't use the version logic out of the box from buildKvPath

// if no version, defaults to latest
const versionParam = version ? `&version=${version}` : '';
// depth=1 returns just top-level keys, depth=0 returns all subkeys
const queryParams = `?depth=${depth || '0'}${versionParam}`;
// 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(this._url(`${apiPath}${queryParams}`), '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;
}
}
5 changes: 5 additions & 0 deletions ui/app/styles/helper-classes/spacing.scss
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
margin: 0 !important;
}

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

.has-top-bottom-margin-12 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is a new class, I opted to use the updated -12 number naming which is a pattern we had discussed moving towards (and away from s, m, l) happy to revert and make this -s if folks prefer

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just add a comment here to that affect? That this is the pattern we want to move toward, away from s/m/l so that future code-browsers will not be confused by the different types of measurements

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea! Will add

margin: $spacing-12 0;
}

.has-top-margin-negative-m {
margin-top: -$spacing-16;
}
Expand Down
1 change: 0 additions & 1 deletion ui/app/utils/kv-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import { sanitizeStart } from 'core/utils/sanitize-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';

// only exported for testing
export function buildKvPath(backend: string, path: string, type: string, version?: number | string) {
const sanitizedPath = sanitizeStart(path); // removing leading slashes
const url = `${encodePath(backend)}/${type}/${encodePath(sanitizedPath)}`;
Expand Down
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}}>
Copy link
Contributor Author

@hellobontempo hellobontempo Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this affects overview cards everywhere, it's a very small change and I'd rather the spacing be consistent across the board than have different padding situations.
Screenshot 2024-07-23 at 1 05 16 PM

<div class="flex row-wrap space-between has-bottom-margin-s" data-test-overview-card={{@cardTitle}}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-07-23 at 1 05 02 PM

<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 "subtext" yield for stylized subtext or including elements like doc links. }}
{{#if (has-block "subtext")}}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to update @subText for all OverviewCards, I felt like it made sense to keep both potential use cases. 1) just text subtext and 2) more stylized content, such as inline links

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are @subtext and <:subtext> mutually exclusive? Right now passing both with yield both

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally made them not mutually exclusive as I didn't see a reason for them to be. Someone could add subtext in the default styling and then add a second stylized block. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! I think the most confusing part is that they are named the same thing, so it felt like maybe they should be mutually exclusive. But, naming is hard 🤷

{{yield to="subtext"}}
{{/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
~}}

Copy link
Contributor Author

@hellobontempo hellobontempo Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-07-24 at 4 16 58 PM Screenshot 2024-07-24 at 4 17 09 PM

<OverviewCard @cardTitle="Subkeys">
<:subtext>
<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 instead are
replaced with
<code>null</code>. 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>
</:subtext>
<: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 instead are replaced with null. 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
Loading
Loading