UI: improve control group UX (#28232)

* wip control group fix?

* dont rely on models for capabilities;

* Revert "wip control group fix?"

This reverts commit cf3e896ba05d2fdfe1f6287bba5c862df4e5d553.

* make explicit request for data

* remove dangerous triple curlies

* cleanup template logic and reuse each-in

* remove capability checks from model

* update tests to reflect new behavior

* add test coverage

* fix mirage factory, update details tests

* test control groups VAULT-29471

* finish patch test

* alphabetize!

* does await help?

* fix factory

* add conditionals for control group error
This commit is contained in:
claire bontempo 2024-09-03 10:49:41 -07:00 committed by GitHub
parent ff7309573f
commit 3a9db72792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 428 additions and 246 deletions

View File

@ -49,6 +49,8 @@ export default class KvSecretMetadataModel extends Model {
})
deleteVersionAfter;
// the API returns custom_metadata: null if empty but because the attr is an 'object' ember data transforms it to an empty object.
// this is important because we rely on the empty object as a truthy value in template conditionals
@attr('object', {
editType: 'kv',
isSectionHeader: true,

View File

@ -135,6 +135,10 @@ export default Service.extend({
return {
type: 'error-with-html',
content: lines.join('\n'),
href,
token,
accessor,
creation_path,
};
},
});

View File

@ -0,0 +1,35 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{! This display only template is for rendering control group errors when the error is thrown from a component }}
<Hds::Alert data-test-message-error @type="inline" @color="warning" ...attributes as |A|>
<A.Title>Control Group Error</A.Title>
{{#if (and @error.creation_path @error.token @error.accessor)}}
<A.Description>
A Control Group was encountered at
<code>{{@error.creation_path}}</code>. The Control Group Token is
<code>{{@error.token}}</code>. The Accessor is
<code>{{@error.accessor}}</code>.
</A.Description>
{{/if}}
{{#if (has-block "customMessage")}}
<A.Description>
{{yield to="customMessage"}}
</A.Description>
{{/if}}
{{#if @error.href}}
<A.Description>
Visit
<Hds::Link::Inline
@color="primary"
@href={{@error.href}}
@isHrefExternal={{false}}
data-test-control-error="href"
>{{@error.href}}</Hds::Link::Inline>
for more details.
</A.Description>
{{/if}}
</Hds::Alert>

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/control-group-inline-error';

View File

@ -64,11 +64,11 @@
<li>
<LinkTo @route="secret.paths" @models={{array @secret.backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li>
{{#if @secret.canReadMetadata}}
{{#if @canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@models={{array @secret.backend @path}}
@models={{array @backend @path}}
data-test-secrets-tab="Version History"
>Version History</LinkTo>
</li>
@ -104,10 +104,10 @@
{{#if this.showDestroy}}
<KvDeleteModal @mode="destroy" @secret={{@secret}} @onDelete={{this.handleDestruction}} @version={{this.version}} />
{{/if}}
{{#if (or @secret.canReadData @secret.canReadMetadata @secret.canEditData)}}
{{#if (or @canReadData @canReadMetadata @canUpdateData)}}
<div class="toolbar-separator"></div>
{{/if}}
{{#if (and @secret.canReadData (eq @secret.state "created"))}}
{{#if (and @canReadData (eq @secret.state "created"))}}
<CopySecretDropdown
@clipboardText={{stringify @secret.secretData}}
@onWrap={{perform this.wrapSecret}}
@ -116,7 +116,7 @@
@onClose={{this.clearWrappedData}}
/>
{{/if}}
{{#if @secret.canReadMetadata}}
{{#if @canReadMetadata}}
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
{{/if}}
{{! @isPatchAllowed is true if the version is enterprise AND a user has "patch" secret + "read" subkeys capabilities }}
@ -125,7 +125,7 @@
Patch latest version
</ToolbarLink>
{{/if}}
{{#if @secret.canEditData}}
{{#if @canUpdateData}}
<ToolbarLink
data-test-create-new-version
@route="secret.details.edit"

View File

@ -17,16 +17,26 @@ import { isAdvancedSecret } from 'core/utils/advanced-secret';
* @module KvSecretDetails renders the key/value data of a KV secret.
* It also renders a dropdown to display different versions of the secret.
* <Page::Secret::Details
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @breadcrumbs={{this.breadcrumbs}}
/>
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadData={{this.model.canReadData}}
* @canReadMetadata={{this.model.canReadMetadata}}
* @canUpdateData={{this.model.canUpdateData}}
* @isPatchAllowed={{this.model.isPatchAllowed}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* />
*
* @param {string} backend - path where kv engine is mounted
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canReadData - if true and the secret is not destroyed/deleted the copy secret dropdown renders
* @param {boolean} canReadMetadata - if true it renders the kv select version dropdown in the toolbar and "Version History" tab
* @param {boolean} canUpdateData - if true it renders "Create new version" toolbar action
* @param {boolean} isPatchAllowed - if true it renders "Patch latest version" toolbar action
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
* @param {model} secret - Ember data model: 'kv/data'
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
*/
export default class KvSecretDetails extends Component {
@ -188,7 +198,7 @@ export default class KvSecretDetails extends Component {
}
get emptyState() {
if (!this.args.secret.canReadData) {
if (!this.args.canReadData) {
return {
title: 'You do not have permission to read this secret',
message:
@ -201,7 +211,7 @@ export default class KvSecretDetails extends Component {
return {
title: `Version ${version} of this secret has been permanently destroyed`,
message: `A version that has been permanently deleted cannot be restored. ${
this.args.secret.canReadMetadata
this.args.canReadMetadata
? ' You can view other versions of this secret in the Version History tab above.'
: ''
}`,
@ -212,7 +222,7 @@ export default class KvSecretDetails extends Component {
return {
title: `Version ${version} of this secret has been deleted`,
message: `This version has been deleted but can be undeleted. ${
this.args.secret.canReadMetadata
this.args.canReadMetadata
? 'View other versions of this secret by clicking the Version History tab above.'
: ''
}`,

View File

@ -48,9 +48,9 @@
Custom metadata
</h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
{{! if the user had read permissions and there is no custom_metadata @customMetadata is an empty object, without read capabilities it's undefined }}
{{#if @customMetadata}}
{{#each-in @customMetadata as |key value|}}
{{! if the user had read permissions and there is no custom_metadata this is an empty object, without read capabilities it's falsy }}
{{#if this.customMetadata}}
{{#each-in this.customMetadata as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}}
<EmptyState
@ -69,6 +69,35 @@
{{/if}}
</EmptyState>
{{/each-in}}
{{else if @canReadData}}
{{! Offer opportunity to manually request /data/ for custom_metadata }}
{{#if this.error.isControlGroup}}
<ControlGroupInlineError @error={{this.error}} class="has-top-margin-s has-bottom-margin-s" />
{{else if this.error}}
<MessageError @errorMessage={{this.error}} />
{{/if}}
<EmptyState
@title="Request custom metadata?"
@bottomBorder={{true}}
@message="You do not have access to the metadata endpoint but you can retrieve custom metadata from the secret data endpoint."
>
<div class="is-block">
<Hds::Alert @type="compact" @color="critical" class="has-top-margin-xs" as |A|>
<A.Description>
Sensitive secret data will be retrieved.
</A.Description>
</Hds::Alert>
<Hds::Button
class="has-top-margin-xs"
@text="Request data"
@icon="reload"
@iconPosition="trailing"
@isFullWidth={{true}}
data-test-request-data
{{on "click" this.requestData}}
/>
</div>
</EmptyState>
{{else}}
<EmptyState
@title="You do not have access to read custom metadata"
@ -80,7 +109,7 @@
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
Secret metadata
</h2>
{{#if @canReadMetadata}}
{{#if @metadata}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
<InfoTableRow @alwaysRender={{true}} @label="Last updated">
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />

View File

@ -5,6 +5,7 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
@ -13,10 +14,10 @@ import errorMessage from 'vault/utils/error-message';
* <Page::Secret::Metadata::Details
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canDeleteMetadata={{this.model.permissions.metadata.canDelete}}
* @canReadMetadata={{this.model.permissions.metadata.canRead}}
* @canUpdateMetadata={{this.model.permissions.metadata.canUpdate}}
* @customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
* @canDeleteMetadata={{this.model.canDeleteMetadata}}
* @canReadData={{this.model.canReadData}}
* @canReadMetadata={{this.model.canReadMetadata}}
* @canUpdateMetadata={{this.model.canUpdateMetadata}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* />
@ -24,9 +25,9 @@ import errorMessage from 'vault/utils/error-message';
* @param {string} backend - The name of the kv secret engine.
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canDeleteMetadata - if true, "Permanently delete" action renders in the toolbar
* @param {boolean} canReadData - if true, user can make a request for custom_metadata if they don't have "read" permissions for metadata
* @param {boolean} canReadMetadata - if true, secret metadata renders below custom_metadata
* @param {boolean} canUpdateMetadata - if true, "Edit" action renders in the toolbar
* @param {object} customMetadata - comes from secret metadata or data endpoint. if undefined, user does not have "read" access, if an empty object then there is none
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
*
@ -34,10 +35,18 @@ import errorMessage from 'vault/utils/error-message';
*/
export default class KvSecretMetadataDetails extends Component {
@service controlGroup;
@service flashMessages;
@service router;
@service store;
@tracked error = null;
@tracked customMetadataFromData = null;
get customMetadata() {
return this.args.metadata?.customMetadata || this.customMetadataFromData;
}
@action
async onDelete() {
// The only delete option from this view is delete metadata and all versions
@ -54,4 +63,22 @@ export default class KvSecretMetadataDetails extends Component {
this.flashMessages.danger(`There was an issue deleting ${path} metadata. \n ${errorMessage(err)}`);
}
}
@action
async requestData() {
const { backend, path } = this.args;
try {
const secretData = await this.store.queryRecord('kv/data', { backend, path });
this.customMetadataFromData = secretData.customMetadata;
} catch (error) {
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
this.error = this.controlGroup.logFromError(error);
this.error.isControlGroup = true;
return;
}
this.error.isControlGroup = false;
this.error = errorMessage(error);
}
}
}

View File

@ -52,7 +52,7 @@
</Hds::Text::Display>
</:customTitle>
<:action>
{{#if @canUpdateSecret}}
{{#if @canUpdateData}}
<Hds::Link::Standalone
@text="Create new"
@route="secret.details.edit"

View File

@ -13,7 +13,7 @@ import { isDeleted } from 'kv/utils/kv-deleted';
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadMetadata={{true}}
* @canUpdateSecret={{true}}
* @canUpdateData={{true}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @subkeys={{this.model.subkeys}}
@ -22,7 +22,7 @@ import { isDeleted } from 'kv/utils/kv-deleted';
* @param {string} backend - kv secret mount to make network request
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canReadMetadata - permissions to read metadata
* @param {boolean} canUpdateSecret - permissions to create a new version of a secret
* @param {boolean} canUpdateData - permissions to create a new version of a secret
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path to request secret data for selected version
* @param {object} subkeys - API response from subkeys endpoint, object with "subkeys" and "metadata" keys. This arg is null for community edition

View File

@ -4,7 +4,15 @@
~}}
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Patch Secret to New Version" />
{{#if this.controlGroupError}}
<ControlGroupInlineError @error={{this.controlGroupError}} class="has-top-margin-s has-bottom-margin-s">
<:customMessage>
<strong>
You can re-submit the form once access is granted. Ask your authorizer when to attempt saving again.
</strong>
</:customMessage>
</ControlGroupInlineError>
{{/if}}
<MessageError @errorMessage={{this.errorMessage}} />
<div class="box is-sideless is-fullwidth is-bottomless">

View File

@ -40,6 +40,7 @@ export default class KvSecretPatch extends Component {
@service router;
@service store;
@tracked controlGroupError;
@tracked errorMessage;
@tracked invalidFormAlert;
@tracked patchMethod = 'UI';
@ -60,21 +61,19 @@ export default class KvSecretPatch extends Component {
const { backend, path, metadata, subkeysMeta } = this.args;
// if no metadata permission, use subkey metadata as backup
const version = metadata.currentVersion || subkeysMeta.version;
const version = metadata?.currentVersion || subkeysMeta?.version;
const adapter = this.store.adapterFor('kv/data');
try {
yield adapter.patchSecret(backend, path, patchData, version);
this.flashMessages.success(`Successfully patched new version of ${path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
} catch (error) {
// TODO test...this is copy pasta'd from the edit page
let message = errorMessage(error);
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
const err = this.controlGroup.logFromError(error);
message = err.content;
this.controlGroupError = this.controlGroup.logFromError(error);
return;
}
this.errorMessage = message;
this.errorMessage = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
}
}

View File

@ -35,33 +35,38 @@ export default class KvSecretRoute extends Route {
return null;
}
isPatchAllowed(backend, path) {
isPatchAllowed({ subkeys, data }) {
if (!this.version.isEnterprise) return false;
const capabilities = {
canPatch: this.capabilities.canPatch(`${backend}/data/${path}`),
canReadSubkeys: this.capabilities.canRead(`${backend}/subkeys/${path}`),
};
return hash(capabilities).then(
({ canPatch, canReadSubkeys }) => canPatch && canReadSubkeys,
// this callback fires if either promise is rejected
// since this feature is only client-side gated we return false (instead of default to true)
// for debugging you can pass an arg to log the failure reason
() => false
);
return subkeys.canRead && data.canPatch;
}
model() {
async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`;
const subkeysPath = `${backend}/subkeys/${path}`;
const perms = await this.capabilities.fetchMultiplePaths([metadataPath, dataPath, subkeysPath]);
return {
metadata: perms[metadataPath],
data: perms[dataPath],
subkeys: perms[subkeysPath],
};
}
async model() {
const backend = this.secretMountPath.currentPath;
const { name: path } = this.paramsFor('secret');
const capabilities = await this.fetchCapabilities(backend, path);
return hash({
path,
backend,
subkeys: this.fetchSubkeys(backend, path),
metadata: this.fetchSecretMetadata(backend, path),
isPatchAllowed: this.isPatchAllowed(backend, path),
// for creating a new secret version
canUpdateSecret: this.capabilities.canUpdate(`${backend}/data/${path}`),
isPatchAllowed: this.isPatchAllowed(capabilities),
canUpdateData: capabilities.data.canUpdate,
canReadData: capabilities.data.canRead,
canReadMetadata: capabilities.metadata.canRead,
canDeleteMetadata: capabilities.metadata.canDelete,
canUpdateMetadata: capabilities.metadata.canUpdate,
});
}

View File

@ -20,44 +20,22 @@ export default class KvSecretMetadataRoute extends Route {
});
}
async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`;
const capabilities = await this.capabilities.fetchMultiplePaths([metadataPath, dataPath]);
return {
metadata: capabilities[metadataPath],
data: capabilities[dataPath],
};
}
async model() {
const parentModel = this.modelFor('secret');
const { backend, path } = parentModel;
const permissions = await this.fetchCapabilities(backend, path);
const model = {
...parentModel,
permissions,
};
if (!parentModel.metadata) {
// metadata read on the secret root fails silently
// if there's no metadata, try again in case it's a control group
const metadata = await this.fetchMetadata(backend, path);
if (metadata) {
return {
...model,
...parentModel,
metadata,
};
}
// only fetch secret data if metadata is unavailable and user can read endpoint
if (permissions.data.canRead) {
// fail silently because this request is just for custom_metadata
const secret = await this.store.queryRecord('kv/data', { backend, path }).catch(() => {});
return {
...model,
secret,
};
}
}
return model;
// if users can read secret data they can make an explicit request
// to retrieve secret data in the component
return parentModel;
}
}

View File

@ -4,9 +4,13 @@
~}}
<Page::Secret::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
/>

View File

@ -7,8 +7,8 @@
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.metadata.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@canUpdateSecret={{this.model.canUpdateSecret}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@subkeys={{this.model.subkeys}}

View File

@ -6,10 +6,10 @@
<Page::Secret::Metadata::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.model.permissions.metadata.canDelete}}
@canReadMetadata={{this.model.permissions.metadata.canRead}}
@canUpdateMetadata={{this.model.permissions.metadata.canUpdate}}
@customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
@canDeleteMetadata={{this.model.canDeleteMetadata}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateMetadata={{this.model.canUpdateMetadata}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
/>

View File

@ -17,6 +17,9 @@ const data = {
max_versions: 15,
oldest_version: 0,
updated_time: '2023-07-21T03:11:58.095971Z',
// the API returns custom_metadata: null if empty but because the attr is an 'object' ember data transforms it to an empty object.
// this is important because we rely on the empty object as a truthy value in template conditionals
custom_metadata: null,
versions: {
1: {
created_time: '2018-03-22T02:24:06.945319214Z',
@ -45,10 +48,13 @@ export default Factory.extend({
data,
withCustomMetadata: trait({
custom_metadata: {
foo: 'abc',
bar: '123',
baz: '5c07d823-3810-48f6-a147-4c06b5219e84',
data: {
...data,
custom_metadata: {
foo: 'abc',
bar: '123',
baz: '5c07d823-3810-48f6-a147-4c06b5219e84',
},
},
}),

View File

@ -403,6 +403,10 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
// Metadata page
await click(PAGE.secretTab('Metadata'));
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('No custom metadata', 'No custom metadata empty state');
@ -548,6 +552,10 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
// Metadata page
await click(PAGE.secretTab('Metadata'));
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('No custom metadata', 'No custom metadata empty state');

View File

@ -122,7 +122,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(PAGE.list.item(secret));
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasText(`Current version Create new The current version of this secret. 1`);
.hasText(`Current version The current version of this secret. 1`);
// Secret details visible
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.title).hasText(this.fullSecretPath);

View File

@ -5,7 +5,17 @@
import { module, test } from 'qunit';
import { v4 as uuidv4 } from 'uuid';
import { click, currentRouteName, currentURL, findAll, typeIn, visit, waitUntil } from '@ember/test-helpers';
import {
click,
currentRouteName,
currentURL,
find,
findAll,
fillIn,
typeIn,
visit,
waitUntil,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth';
import {
@ -571,7 +581,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
});
test('versioned secret nav, tabs, breadcrumbs (dr)', async function (assert) {
assert.expect(31);
assert.expect(32);
const backend = this.backend;
await navToBackend(backend);
@ -614,6 +624,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath);
assert.dom(PAGE.toolbarAction).doesNotExist('no toolbar actions available on metadata');
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('No custom metadata');
@ -764,7 +778,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
});
test('versioned secret nav, tabs, breadcrumbs (dlr)', async function (assert) {
assert.expect(31);
assert.expect(32);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.list.item(secretPath));
@ -804,6 +818,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('No custom metadata');
@ -1291,11 +1309,11 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
// Set up control group scenario
const userPolicy = `
path "${this.backend}/data/*" {
capabilities = ["create", "read", "update", "delete", "list"]
capabilities = ["create", "read", "update", "delete", "list", "patch"]
control_group = {
max_ttl = "24h"
factor "ops_manager" {
controlled_capabilities = ["read"]
controlled_capabilities = ["read", "patch"]
identity {
group_names = ["managers"]
approvals = 1
@ -1307,6 +1325,10 @@ path "${this.backend}/data/*" {
path "${this.backend}/*" {
capabilities = ["list"]
}
path "${this.backend}/subkeys/*" {
capabilities = ["read"]
}
`;
const { userToken } = await setupControlGroup({ userPolicy, backend: this.backend });
this.userToken = userToken;
@ -1315,7 +1337,7 @@ path "${this.backend}/*" {
return;
});
test('can access nested secret (cg)', async function (assert) {
assert.expect(43);
assert.expect(44);
const backend = this.backend;
await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@ -1379,7 +1401,7 @@ path "${this.backend}/*" {
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path');
assertDetailsToolbar(assert, ['delete', 'copy', 'createNewVersion']);
assertDetailsToolbar(assert, ['delete', 'copy', 'createNewVersion', 'patchLatest']);
await click(PAGE.breadcrumbAtIdx(3));
assert.true(
@ -1397,7 +1419,7 @@ path "${this.backend}/*" {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
});
test('breadcrumbs & page titles are correct (cg)', async function (assert) {
assert.expect(43);
assert.expect(42);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.secretTab('Configuration'));
@ -1446,17 +1468,6 @@ path "${this.backend}/*" {
assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown');
await click(PAGE.secretTab('Secret'));
assert.true(
await waitUntil(() => currentRouteName() === 'vault.cluster.access.control-group-accessor'),
'redirects to access control group route'
);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/paths`,
userToken: this.userToken,
backend: this.backend,
});
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret details');
@ -1464,6 +1475,102 @@ path "${this.backend}/*" {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']);
assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit');
});
test('can request custom_metadata from data endpoint (cg)', async function (assert) {
// custom metadata is empty
assert.expect(3);
const backend = this.backend;
await visit(`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`);
await click(PAGE.secretTab('Metadata'));
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(GENERAL.messageError)
.hasTextContaining(
`Control Group Error A Control Group was encountered at ${backend}/data/${secretPath}.`
);
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
userToken: this.userToken,
backend: this.backend,
});
await click(PAGE.metadata.requestData);
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('No custom metadata', 'empty state updates when access is granted');
});
test('can patch a secret (cg)', async function (assert) {
assert.expect(3);
const backend = this.backend;
await visit(`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`);
await click(GENERAL.overviewCard.actionText('Patch secret'));
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert
.dom(GENERAL.messageError)
.hasTextContaining(
`Control Group Error A Control Group was encountered at ${backend}/data/${secretPath}.`
);
assert
.dom(GENERAL.messageError)
.hasTextContaining(
'You can re-submit the form once access is granted. Ask your authorizer when to attempt saving again.'
);
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/patch`,
userToken: this.userToken,
backend: this.backend,
});
// we have to refill the data because granting access reloads the form
// however in the real world it's likely access is authorized in a separate browser
// once granted, the user can click "submit" the form will save successfully.
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.container('Subkeys')).hasTextContaining('Keys foo newkey');
});
test('can read custom_metadata from data endpoint (cg)', async function (assert) {
assert.expect(3);
// login is root user and make custom metadata since console can't be used to pass an object
await authPage.login();
await visit(`/vault/secrets/${this.backend}/kv/${secretPathUrlEncoded}/metadata/edit`);
await fillIn(FORM.keyInput(), 'special');
await fillIn(FORM.valueInput(), 'secret');
await click(FORM.saveBtn);
await authPage.login(this.userToken);
const backend = this.backend;
await visit(`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`);
await click(PAGE.secretTab('Metadata'));
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
await click(PAGE.metadata.requestData);
assert
.dom(GENERAL.messageError)
.hasTextContaining(
`Control Group Error A Control Group was encountered at ${backend}/data/${secretPath}.`
);
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
userToken: this.userToken,
backend: this.backend,
});
await click(PAGE.metadata.requestData);
assert.dom(PAGE.infoRowValue('special')).hasText('secret', 'it renders custom metadata');
});
});
// patch is technically enterprise only but stubbing the version so they can run on both CE and enterprise

View File

@ -29,6 +29,7 @@ export const PAGE = {
link: (backend) => `[data-test-secrets-backend-link="${backend}"]`,
},
metadata: {
requestData: '[data-test-request-data]',
editBtn: '[data-test-edit-metadata]',
addCustomMetadataBtn: '[data-test-add-custom-metadata]',
customMetadataSection: '[data-test-kv-custom-metadata-section]',

View File

@ -221,7 +221,7 @@ module('Integration | Component | edit form kmip role', function (hooks) {
);
}
click('[data-test-edit-form-submit]');
await click('[data-test-edit-form-submit]');
later(() => cancelTimers(), 50);
await settled();

View File

@ -9,7 +9,6 @@ import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvDataPath } from 'vault/utils/kv-path';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { baseSetup, metadataModel } from 'vault/tests/helpers/kv/kv-run-commands';
import { dateFormat } from 'core/helpers/date-format';
@ -20,15 +19,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
setupMirage(hooks);
hooks.beforeEach(async function () {
// this.metadata is setup by baseSetup
baseSetup(this);
this.dataId = kvDataPath(this.backend, this.path);
// empty secret model always exists for permissions
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.dataId,
custom_metadata: null,
});
this.secret = this.store.peekRecord('kv/data', this.dataId);
// this is the route model, not an ember data model
this.model = {
@ -36,26 +28,28 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
path: this.path,
secret: this.secret,
metadata: this.metadata,
canDeleteMetadata: true,
canReadData: true,
canReadCustomMetadata: true,
canReadMetadata: true,
canUpdateMetadata: true,
};
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.model.backend, route: 'list' },
{ label: this.model.path },
];
this.canDeleteMetadata = true;
this.canReadCustomMetadata = true;
this.canReadMetadata = true;
this.canUpdateMetadata = true;
this.renderComponent = () => {
return render(
hbs`
<Page::Secret::Metadata::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.canDeleteMetadata}}
@canReadMetadata={{this.canReadMetadata}}
@canUpdateMetadata={{this.canReadMetadata}}
@customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
@canDeleteMetadata={{this.model.canDeleteMetadata}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateMetadata={{this.model.canUpdateMetadata}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
/>
@ -86,13 +80,12 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
.hasText('3 hours 25 minutes 19 seconds', 'correctly shows and formats the timestamp.');
});
test('it renders custom metadata from secret model', async function (assert) {
assert.expect(2);
this.secret.customMetadata = { hi: 'there' };
test('it renders empty state if cannot read metadata but can read data', async function (assert) {
this.model.metadata = null;
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).doesNotExist();
assert.dom(PAGE.infoRowValue('hi')).hasText('there', 'renders custom metadata from secret');
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
});
test('it renders custom metadata from metadata model', async function (assert) {
@ -107,26 +100,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
assert.dom(PAGE.infoRowValue('baz')).hasText('5c07d823-3810-48f6-a147-4c06b5219e84');
});
test('it renders custom metadata from metadata if secret data exists', async function (assert) {
assert.expect(4);
this.secret.customMetadata = { hi: 'there' };
this.model.metadata = metadataModel(this, { withCustom: true });
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).doesNotExist();
// Metadata details
assert.dom(PAGE.infoRowValue('foo')).hasText('abc');
assert.dom(PAGE.infoRowValue('bar')).hasText('123');
assert.dom(PAGE.infoRowValue('baz')).hasText('5c07d823-3810-48f6-a147-4c06b5219e84');
});
test('it hides delete modal when no permissions', async function (assert) {
this.canDeleteMetadata = false;
this.model.canDeleteMetadata = false;
assert.dom(PAGE.metadata.deleteMetadata).doesNotExist();
});
test('it hides edit action when no permissions', async function (assert) {
this.canUpdateMetadata = false;
this.model.canUpdateMetadata = false;
assert.dom(PAGE.metadata.editBtn).doesNotExist();
});
});

View File

@ -47,7 +47,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
},
};
this.canReadMetadata = true;
this.canUpdateSecret = true;
this.canUpdateData = true;
this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {});
this.renderComponent = async () => {
@ -57,7 +57,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.canReadMetadata}}
@canUpdateSecret={{this.canUpdateSecret}}
@canUpdateData={{this.canUpdateData}}
@metadata={{this.metadata}}
@path={{this.path}}
@subkeys={{this.subkeys}}
@ -116,7 +116,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
// creating a new version of a secret is updating a secret
// the overview only exists after an initial version is created
// which is why we just check for update and not also create
this.canUpdateSecret = false;
this.canUpdateData = false;
await this.renderComponent();
assert
.dom(`${overviewCard.container('Current version')} a`)

View File

@ -38,7 +38,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
},
quux: null,
};
this.subkeyMeta = {
this.subkeysMeta = {
created_time: '2021-12-14T20:28:00.773477Z',
custom_metadata: null,
deletion_time: '',
@ -244,6 +244,38 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
await codemirror().setValue('{ "foo": "" }');
await click(FORM.saveBtn);
});
test('patch data without metadata permissions', async function (assert) {
assert.expect(3);
this.metadata = null;
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { aKey: '1' },
options: {
cas: this.subkeysMeta.version,
},
};
assert.true(true, `PATCH request made to ${this.endpoint}`);
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
await this.renderComponent();
await fillIn(FORM.keyInput('new'), 'aKey');
await fillIn(FORM.valueInput('new'), '1');
await click(FORM.saveBtn);
const [route] = this.transitionStub.lastCall.args;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}`
);
});
});
module('it does not submit', function (hooks) {

View File

@ -26,7 +26,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
this.version = 2;
this.dataId = kvDataPath(this.backend, this.path);
this.dataIdComplex = kvDataPath(this.backend, this.pathComplex);
this.secretData = { foo: 'bar' };
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
@ -58,13 +57,17 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
});
this.secret = this.store.peekRecord('kv/data', this.dataId);
this.secretComplex = this.store.peekRecord('kv/data', this.dataIdComplex);
// this is the route model, not an ember data model
this.model = {
backend: this.backend,
// permissions are tested in navigation acceptance test, so just stub as all true here
canReadData: true,
canReadMetadata: true,
canUpdateData: true,
isPatchAllowed: true,
metadata: this.metadata,
path: this.path,
secret: this.secret,
metadata: this.metadata,
};
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
@ -77,6 +80,25 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
secret: this.secretComplex,
metadata: this.metadata,
};
this.renderComponent = (model) => {
this.model = model ? { ...this.model, ...model } : this.model;
return render(
hbs`
<Page::Secret::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@secret={{this.model.secret}}
/>
`,
{ owner: this.engine }
);
};
});
test('it renders secret details and toggles json view', async function (assert) {
@ -94,19 +116,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
// no records so response returns 404
return syncStatusResponse(schema, req);
});
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 this.renderComponent();
assert
.dom(PAGE.detail.syncAlert())
.doesNotExist('sync page alert banner does not render when sync status errors');
@ -125,16 +135,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
test('it renders json view when secret is complex', async function (assert) {
assert.expect(4);
await render(
hbs`
<Page::Secret::Details
@path={{this.modelComplex.path}}
@secret={{this.modelComplex.secret}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent(this.modelComplex);
assert.dom(PAGE.infoRowValue('foo')).doesNotExist('does not render rows of secret data');
assert.dom(FORM.toggleJson).isChecked();
assert.dom(FORM.toggleJson).isNotDisabled();
@ -144,17 +145,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
test('it renders deleted empty state', async function (assert) {
assert.expect(3);
this.secret.deletionTime = '2023-07-23T02:12:17.379762Z';
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 this.renderComponent();
assert.dom(PAGE.emptyStateTitle).hasText('Version 2 of this secret has been deleted');
assert
.dom(PAGE.emptyStateMessage)
@ -169,17 +161,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
test('it renders destroyed empty state', async function (assert) {
assert.expect(2);
this.secret.destroyed = true;
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 this.renderComponent();
assert.dom(PAGE.emptyStateTitle).hasText('Version 2 of this secret has been permanently destroyed');
assert
.dom(PAGE.emptyStateMessage)
@ -190,18 +173,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
test('it renders secret version dropdown', async function (assert) {
assert.expect(9);
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 this.renderComponent();
assert.dom(PAGE.detail.versionTimestamp).includesText(this.version, 'renders version');
assert.dom(PAGE.detail.versionDropdown).hasText(`Version ${this.secret.version}`);
@ -246,18 +218,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
return syncStatusResponse(schema, req);
});
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 this.renderComponent();
assert
.dom(PAGE.detail.syncAlert(destinationName))
.hasTextContaining(
@ -290,18 +251,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
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 this.renderComponent();
await click(PAGE.detail.copy);
await click(PAGE.detail.wrap);
@ -325,17 +275,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
return syncStatusResponse(schema, req);
});
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 this.renderComponent();
assert
.dom(PAGE.detail.syncAlert('aws-dest'))
.hasTextContaining('Synced aws-dest - last updated September', 'renders status for aws destination');