diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js index c598651821..447c5fa072 100644 --- a/ui/app/models/kv/metadata.js +++ b/ui/app/models/kv/metadata.js @@ -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, diff --git a/ui/app/services/control-group.js b/ui/app/services/control-group.js index 7ea218fbfb..f0649e3aa2 100644 --- a/ui/app/services/control-group.js +++ b/ui/app/services/control-group.js @@ -135,6 +135,10 @@ export default Service.extend({ return { type: 'error-with-html', content: lines.join('\n'), + href, + token, + accessor, + creation_path, }; }, }); diff --git a/ui/lib/core/addon/components/control-group-inline-error.hbs b/ui/lib/core/addon/components/control-group-inline-error.hbs new file mode 100644 index 0000000000..b59f57ed02 --- /dev/null +++ b/ui/lib/core/addon/components/control-group-inline-error.hbs @@ -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 }} + + + Control Group Error + {{#if (and @error.creation_path @error.token @error.accessor)}} + + A Control Group was encountered at + {{@error.creation_path}}. The Control Group Token is + {{@error.token}}. The Accessor is + {{@error.accessor}}. + + {{/if}} + {{#if (has-block "customMessage")}} + + {{yield to="customMessage"}} + + {{/if}} + {{#if @error.href}} + + Visit + {{@error.href}} + for more details. + + {{/if}} + \ No newline at end of file diff --git a/ui/lib/core/app/components/control-group-inline-error.js b/ui/lib/core/app/components/control-group-inline-error.js new file mode 100644 index 0000000000..4e95bef7d4 --- /dev/null +++ b/ui/lib/core/app/components/control-group-inline-error.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/control-group-inline-error'; diff --git a/ui/lib/kv/addon/components/page/secret/details.hbs b/ui/lib/kv/addon/components/page/secret/details.hbs index 26602ce4b8..496eda16d4 100644 --- a/ui/lib/kv/addon/components/page/secret/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/details.hbs @@ -64,11 +64,11 @@
  • Paths
  • - {{#if @secret.canReadMetadata}} + {{#if @canReadMetadata}}
  • Version History
  • @@ -104,10 +104,10 @@ {{#if this.showDestroy}} {{/if}} - {{#if (or @secret.canReadData @secret.canReadMetadata @secret.canEditData)}} + {{#if (or @canReadData @canReadMetadata @canUpdateData)}}
    {{/if}} - {{#if (and @secret.canReadData (eq @secret.state "created"))}} + {{#if (and @canReadData (eq @secret.state "created"))}} {{/if}} - {{#if @secret.canReadMetadata}} + {{#if @canReadMetadata}} {{/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 {{/if}} - {{#if @secret.canEditData}} + {{#if @canUpdateData}} + * @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.' : '' }`, diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs index 2c5efa6e21..1dff3801d6 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs @@ -48,9 +48,9 @@ Custom metadata
    - {{! 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|}} {{else}} {{/each-in}} + {{else if @canReadData}} + {{! Offer opportunity to manually request /data/ for custom_metadata }} + {{#if this.error.isControlGroup}} + + {{else if this.error}} + + {{/if}} + +
    + + + Sensitive secret data will be retrieved. + + + +
    +
    {{else}} Secret metadata - {{#if @canReadMetadata}} + {{#if @metadata}}
    diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.js b/ui/lib/kv/addon/components/page/secret/metadata/details.js index 85a37fdebc..2769ae946a 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.js +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.js @@ -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'; * @@ -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); + } + } } diff --git a/ui/lib/kv/addon/components/page/secret/overview.hbs b/ui/lib/kv/addon/components/page/secret/overview.hbs index 4f3f003b95..6851658881 100644 --- a/ui/lib/kv/addon/components/page/secret/overview.hbs +++ b/ui/lib/kv/addon/components/page/secret/overview.hbs @@ -52,7 +52,7 @@ <:action> - {{#if @canUpdateSecret}} + {{#if @canUpdateData}} - +{{#if this.controlGroupError}} + + <:customMessage> + + You can re-submit the form once access is granted. Ask your authorizer when to attempt saving again. + + + +{{/if}}
    diff --git a/ui/lib/kv/addon/components/page/secret/patch.js b/ui/lib/kv/addon/components/page/secret/patch.js index b49ae842ef..fb1a4db152 100644 --- a/ui/lib/kv/addon/components/page/secret/patch.js +++ b/ui/lib/kv/addon/components/page/secret/patch.js @@ -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.'; } } diff --git a/ui/lib/kv/addon/routes/secret.js b/ui/lib/kv/addon/routes/secret.js index 16dd9410da..f06a9960c0 100644 --- a/ui/lib/kv/addon/routes/secret.js +++ b/ui/lib/kv/addon/routes/secret.js @@ -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, }); } diff --git a/ui/lib/kv/addon/routes/secret/metadata.js b/ui/lib/kv/addon/routes/secret/metadata.js index 1eb77bc269..0f15807bf2 100644 --- a/ui/lib/kv/addon/routes/secret/metadata.js +++ b/ui/lib/kv/addon/routes/secret/metadata.js @@ -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; } } diff --git a/ui/lib/kv/addon/templates/secret/details/index.hbs b/ui/lib/kv/addon/templates/secret/details/index.hbs index 0529c91697..46d53b2421 100644 --- a/ui/lib/kv/addon/templates/secret/details/index.hbs +++ b/ui/lib/kv/addon/templates/secret/details/index.hbs @@ -4,9 +4,13 @@ ~}} \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/secret/index.hbs b/ui/lib/kv/addon/templates/secret/index.hbs index e915cda8fb..f9cea69072 100644 --- a/ui/lib/kv/addon/templates/secret/index.hbs +++ b/ui/lib/kv/addon/templates/secret/index.hbs @@ -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}} diff --git a/ui/lib/kv/addon/templates/secret/metadata/index.hbs b/ui/lib/kv/addon/templates/secret/metadata/index.hbs index 90a626c2bf..02f51f4ec7 100644 --- a/ui/lib/kv/addon/templates/secret/metadata/index.hbs +++ b/ui/lib/kv/addon/templates/secret/metadata/index.hbs @@ -6,10 +6,10 @@ \ No newline at end of file diff --git a/ui/mirage/factories/kv-metadatum.js b/ui/mirage/factories/kv-metadatum.js index b4ea2d56d7..2ddcdc5ac6 100644 --- a/ui/mirage/factories/kv-metadatum.js +++ b/ui/mirage/factories/kv-metadatum.js @@ -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', + }, }, }), diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js index 0bc16b1016..606267b14f 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js @@ -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'); diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js index c56551b1c8..325ec9fc43 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js @@ -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); diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js index edf9986a64..dfd2e1eb3c 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js @@ -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 diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 1e0fddbf4e..d919a69c92 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -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]', diff --git a/ui/tests/integration/components/edit-form-kmip-role-test.js b/ui/tests/integration/components/edit-form-kmip-role-test.js index dac56709a4..8790571f15 100644 --- a/ui/tests/integration/components/edit-form-kmip-role-test.js +++ b/ui/tests/integration/components/edit-form-kmip-role-test.js @@ -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(); diff --git a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js index 9a946c283f..0bb92307b8 100644 --- a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js @@ -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` @@ -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(); }); }); diff --git a/ui/tests/integration/components/kv/page/kv-page-overview-test.js b/ui/tests/integration/components/kv/page/kv-page-overview-test.js index 9342d621fe..57f087dab3 100644 --- a/ui/tests/integration/components/kv/page/kv-page-overview-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-overview-test.js @@ -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`) diff --git a/ui/tests/integration/components/kv/page/kv-page-patch-test.js b/ui/tests/integration/components/kv/page/kv-page-patch-test.js index 06da592043..fc473d2f4f 100644 --- a/ui/tests/integration/components/kv/page/kv-page-patch-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-patch-test.js @@ -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) { diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js index 3046ab8f2f..91f00ce749 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js @@ -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` + + `, + { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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');