diff --git a/ui/app/components/secret-create-or-update.js b/ui/app/components/secret-create-or-update.js index 37d9ac4274..532e04efff 100644 --- a/ui/app/components/secret-create-or-update.js +++ b/ui/app/components/secret-create-or-update.js @@ -27,7 +27,6 @@ */ import Component from '@glimmer/component'; -import ControlGroupError from 'vault/lib/control-group-error'; import Ember from 'ember'; import keys from 'core/utils/keys'; import { action, set } from '@ember/object'; @@ -124,10 +123,10 @@ export default class SecretCreateOrUpdate extends Component { } }) .catch((error) => { - if (error instanceof ControlGroupError) { + if (error.isControlGroupError) { + this.controlGroup.saveTokenFromError(error); const errorMessage = this.controlGroup.logFromError(error); this.error = errorMessage.content; - this.controlGroup.saveTokenFromError(error); } throw error; }); diff --git a/ui/app/forms/secrets/kv.ts b/ui/app/forms/secrets/kv.ts new file mode 100644 index 0000000000..fce3429241 --- /dev/null +++ b/ui/app/forms/secrets/kv.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; + +import type { Validations } from 'vault/app-types'; + +type KvFormData = { + path: string; + secretData: { [key: string]: string }; + custom_metadata?: { [key: string]: string }; + max_versions?: number; + cas_required?: boolean; + delete_version_after?: string; + // readonly options when editing an existing secret + options?: { + cas: number; + }; +}; + +export default class KvForm extends Form { + fieldProps = ['secretFields', 'metadataFields']; + + validations: Validations = { + path: [ + { type: 'presence', message: `Path can't be blank.` }, + { type: 'endsInSlash', message: `Path can't end in forward slash '/'.` }, + { + type: 'containsWhiteSpace', + message: WHITESPACE_WARNING('path'), + level: 'warn', + }, + ], + secretData: [ + { + validator: ({ secretData }: KvForm['data']) => + secretData !== undefined && typeof secretData !== 'object' ? false : true, + message: 'Vault expects data to be formatted as an JSON object.', + }, + ], + max_versions: [ + { type: 'number', message: 'Maximum versions must be a number.' }, + { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, + ], + }; + + secretFields = [ + new FormField('path', 'string', { + label: 'Path for this secret', + subText: 'Names with forward slashes define hierarchical path structures.', + }), + ]; + + metadataFields = [ + new FormField('custom_metadata', 'object', { + editType: 'kv', + isSectionHeader: true, + subText: + 'An optional set of informational key-value pairs that will be stored with all secret versions.', + }), + new FormField('max_versions', 'number', { + defaultValue: 0, + label: 'Maximum number of versions', + subText: + 'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.', + }), + new FormField('cas_required', 'boolean', { + defaultValue: false, + label: 'Require Check and Set', + subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`, + }), + new FormField('delete_version_after', 'boolean', { + defaultValue: '0s', + editType: 'ttl', + label: 'Automate secret deletion', + helperTextDisabled: `A secret's version must be manually deleted.`, + helperTextEnabled: 'Delete all new versions of this secret after:', + }), + ]; +} diff --git a/ui/app/models/kv/data.js b/ui/app/models/kv/data.js index 17d83d4cb5..7721876dee 100644 --- a/ui/app/models/kv/data.js +++ b/ui/app/models/kv/data.js @@ -7,7 +7,7 @@ import Model, { attr } from '@ember-data/model'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import { withModelValidations } from 'vault/decorators/model-validations'; import { withFormFields } from 'vault/decorators/model-form-fields'; -import { isDeleted } from 'kv/utils/kv-deleted'; +import { isDeleted } from 'kv/helpers/is-deleted'; import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; /* sample response diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js index 447c5fa072..44fe62dd35 100644 --- a/ui/app/models/kv/metadata.js +++ b/ui/app/models/kv/metadata.js @@ -8,7 +8,7 @@ import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import { withModelValidations } from 'vault/decorators/model-validations'; import { withFormFields } from 'vault/decorators/model-form-fields'; import { keyIsFolder } from 'core/utils/key-utils'; -import { isDeleted } from 'kv/utils/kv-deleted'; +import { isDeleted } from 'kv/helpers/is-deleted'; const validations = { maxVersions: [ diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts index 12318a4450..51497585f3 100644 --- a/ui/app/services/api.ts +++ b/ui/app/services/api.ts @@ -99,13 +99,18 @@ export default class ApiService extends Service { }); checkControlGroup = waitFor(async (context: ResponseContext) => { - const { url } = context; const response = context.response.clone(); const { headers } = response; - const controlGroupToken = this.controlGroup.tokenForUrl(url); - if (controlGroupToken) { - this.controlGroup.deleteControlGroupToken(controlGroupToken.accessor); + // since control group requests are forwarded to /v1/sys/wrapping/unwrap we cannot use controlGroup.tokenForUrl here + // instead, we can check if tokenToUnwrap exists on the service and compare the token value with the request header value + if (this.controlGroup.tokenToUnwrap) { + const { token, accessor } = this.controlGroup.tokenToUnwrap || {}; + const requestHeaders = context.init.headers as Headers; + + if (requestHeaders.get('X-Vault-Token') === token) { + this.controlGroup.deleteControlGroupToken(accessor); + } } // if the requested path is locked by a control group we need to create a new error response if (headers.get('Content-Length')) { @@ -182,7 +187,7 @@ export default class ApiService extends Service { // accepts an error response and returns { status, message, response, path } // message is built as error.errors joined with a comma, error.message or a fallback message // path is the url of the request, minus the origin -> /v1/sys/wrapping/unwrap - async parseError(e: unknown, fallbackMessage = 'An error occurred, please try again') { + parseError = waitFor(async (e: unknown, fallbackMessage = 'An error occurred, please try again') => { if (e instanceof ResponseError) { const { status, url } = e.response; // instances where an error is thrown multiple times could result in the body already being read @@ -197,7 +202,7 @@ export default class ApiService extends Service { return { message: message || fallbackMessage, status, - path: url.replace(document.location.origin, ''), + path: decodeURIComponent(url.replace(document.location.origin, '')), response: error, }; } @@ -210,7 +215,7 @@ export default class ApiService extends Service { return { message: (e as Error)?.message || fallbackMessage, }; - } + }); // accepts a list response as { keyInfo, keys } and returns a flat array of the keyInfo datum // to preserve the keys (unique identifiers) the value will be set on the datum as id diff --git a/ui/app/services/control-group.js b/ui/app/services/control-group.js index c355c0608c..22fa05690c 100644 --- a/ui/app/services/control-group.js +++ b/ui/app/services/control-group.js @@ -77,7 +77,7 @@ export default Service.extend({ return null; } let pathForUrl = parseURL(url).pathname; - pathForUrl = pathForUrl.replace('/v1/', ''); + pathForUrl = decodeURIComponent(pathForUrl.replace('/v1/', '')); const tokenInfo = this.tokenToUnwrap; if (tokenInfo && tokenInfo.creation_path === pathForUrl) { const { token, accessor, creation_time } = tokenInfo; diff --git a/ui/app/utils/forms/field.ts b/ui/app/utils/forms/field.ts index 44b38d6caa..060b03a5f9 100644 --- a/ui/app/utils/forms/field.ts +++ b/ui/app/utils/forms/field.ts @@ -23,6 +23,7 @@ export interface FieldOptions { helperTextEnabled?: string; placeholder?: string; noDefault?: boolean; + isSectionHeader?: boolean; } export default class FormField { diff --git a/ui/lib/kv/addon/components/kv-create-edit-form.hbs b/ui/lib/kv/addon/components/kv-create-edit-form.hbs new file mode 100644 index 0000000000..01b1cadd40 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-create-edit-form.hbs @@ -0,0 +1,83 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} +
+
+ + + + {{#each @form.secretFields as |field|}} + {{! path is readonly when editing a secret }} + {{#if (and (not @form.isNew) (eq field.name "path"))}} + + {{else}} + + {{/if}} + {{/each}} + +
+ {{#if @showJson}} + + {{#if (or this.modelValidations.secretData.errors this.lintingErrors)}} + + {{/if}} + {{else}} + + {{/if}} + + {{! edit page renders version diff and create page renders metadata form }} + {{yield this.modelValidations}} +
+ +
+
+ + +
+ {{#if this.invalidFormAlert}} + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-create-edit-form.js b/ui/lib/kv/addon/components/kv-create-edit-form.js new file mode 100644 index 0000000000..e6732a864f --- /dev/null +++ b/ui/lib/kv/addon/components/kv-create-edit-form.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { pathIsFromDirectory } from 'kv/utils/kv-breadcrumbs'; +import { waitFor } from '@ember/test-waiters'; + +/** + * @module KvCreateEditForm is used for creating and editing kv secret data and metadata, it hides/shows a json editor and renders validation errors for the json editor + * + * + * + * @param {Form} form - kv form + * @param {string} path - secret path + * @param {string} backend - secret mount path + * @param {boolean} showJson - boolean passed from parent to hide/show json editor + * @param {function} onSecretDataChange - function passed from parent to handle secret data change side effects + */ + +export default class KvCreateEditForm extends Component { + @service api; + @service controlGroup; + @service flashMessages; + @service('app-router') router; + + @tracked lintingErrors; + @tracked modelValidations; + @tracked invalidFormAlert; + @tracked errorMessage; + + @action + onJsonChange(value) { + try { + const json = JSON.parse(value); + this.args.form.data.secretData = json; + this.lintingErrors = false; + this.args.onChange?.(json); + } catch { + this.lintingErrors = true; + } + } + + @action + onKvObjectChange(value) { + this.args.form.data.secretData = value; + this.args.onChange?.(value); + } + + @action + pathValidations() { + // check path attribute warnings on key up for new secrets + const { state } = this.args.form.toJSON(); + if (state?.path?.warnings) { + // only set model validations if warnings exist + this.modelValidations = state; + } + } + + @action + onCancel() { + const { form, path } = this.args; + if (form.isNew) { + pathIsFromDirectory(path) + ? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', path) + : this.router.transitionTo('vault.cluster.secrets.backend.kv.list'); + } else { + this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index'); + } + } + + hasMetadata(metadata) { + try { + const { custom_metadata = {}, max_versions, cas_required, delete_version_after = '0s' } = metadata; + return ( + Object.keys(custom_metadata).length || max_versions || cas_required || delete_version_after !== '0s' + ); + } catch (e) { + return false; + } + } + + save = task( + waitFor(async (event) => { + event.preventDefault(); + + const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = invalidFormMessage; + this.errorMessage = null; + + if (isValid) { + const { path, secretData, options, ...metadata } = data; + try { + // try saving secret data first + const payload = options ? { data: secretData, options } : { data: secretData }; + await this.api.secrets.kvV2Write(path, this.args.backend, payload); + this.flashMessages.success(`Successfully saved secret data for: ${path}.`); + + // users must have permission to create secret data to create metadata in the UI + // only attempt to save metadata if secret data saves successfully and metadata is added + if (this.hasMetadata(metadata)) { + try { + await this.api.secrets.kvV2WriteMetadata(path, this.args.backend, metadata); + this.flashMessages.success(`Successfully saved metadata.`); + } catch (error) { + const { message } = await this.api.parseError(error); + this.flashMessages.danger(`Secret data was saved but metadata was not: ${message}`, { + sticky: true, + }); + } + } + } catch (error) { + const { message, response } = await this.api.parseError(error); + if (response.isControlGroupError) { + this.controlGroup.saveTokenFromError(response); + const err = this.controlGroup.logFromError(response); + this.errorMessage = err.content; + } else { + this.errorMessage = message; + } + } + + // prevent transition if there are errors with secret data + if (this.errorMessage) { + this.invalidFormAlert = 'There was an error submitting this form.'; + } else { + this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', path); + } + } + }) + ); +} diff --git a/ui/lib/kv/addon/components/kv-data-fields.hbs b/ui/lib/kv/addon/components/kv-data-fields.hbs deleted file mode 100644 index 4f2cb19711..0000000000 --- a/ui/lib/kv/addon/components/kv-data-fields.hbs +++ /dev/null @@ -1,64 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -}} - -{{#let (find-by "name" "path" @secret.allFields) as |attr|}} - {{#if (eq @type "edit")}} - - {{else if (eq @type "create")}} - - {{/if}} -{{/let}} - -
-{{#if @showJson}} - {{#if (eq @type "details")}} - - - Version data - - - {{else}} - - {{/if}} - {{#if (or @modelValidations.secretData.errors this.lintingErrors)}} - - {{/if}} -{{else if (eq @type "details")}} - {{#each-in @secret.secretData as |key value|}} - - - - {{else}} - - {{/each-in}} -{{else}} - -{{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-data-fields.js b/ui/lib/kv/addon/components/kv-data-fields.js deleted file mode 100644 index d42a99fe9a..0000000000 --- a/ui/lib/kv/addon/components/kv-data-fields.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -import { stringify } from 'core/helpers/stringify'; - -/** - * @module KvDataFields is used for rendering the fields associated with kv secret data, it hides/shows a json editor and renders validation errors for the json editor - * - * - * - * @param {model} secret - Ember data model: 'kv/data', the new record saved by the form - * @param {boolean} showJson - boolean passed from parent to hide/show json editor - * @param {object} [modelValidations] - object of errors. If attr.name is in object and has error message display in AlertInline. - * @param {callback} [pathValidations] - callback function fired for the path input on key up - * @param {boolean} [type=null] - can be edit, create, or details. Used to change text for some form labels - */ - -export default class KvDataFields extends Component { - @tracked lintingErrors; - - get startingValue() { - // must pass the third param called "space" in JSON.stringify to structure object with whitespace - // otherwise the following codemirror modifier check will pass `this._editor.getValue() !== namedArgs.content` and _setValue will be called. - // the method _setValue moves the cursor to the beginning of the text field. - // the effect is that the cursor jumps after the first key input. - return JSON.stringify({ '': '' }, null, 2); - } - - get stringifiedSecretData() { - return this.args.secret?.secretData ? stringify([this.args.secret.secretData], {}) : this.startingValue; - } - - @action - handleJson(value) { - this.lintingErrors = false; - - try { - this.args.secret.secretData = JSON.parse(value); - } catch { - this.lintingErrors = true; - } - } -} diff --git a/ui/lib/kv/addon/components/kv-delete-modal.js b/ui/lib/kv/addon/components/kv-delete-modal.js index ac34bf90c2..71f160447e 100644 --- a/ui/lib/kv/addon/components/kv-delete-modal.js +++ b/ui/lib/kv/addon/components/kv-delete-modal.js @@ -7,6 +7,7 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { assert } from '@ember/debug'; +import currentSecret from 'kv/helpers/current-secret'; /** * @module KvDeleteModal displays a button for a delete type and launches a modal. Undelete is the only mode that does not launch the modal and is not handled in this component. @@ -15,13 +16,15 @@ import { assert } from '@ember/debug'; * @mode="destroy" * @secret={{this.model.secret}} * @metadata={{this.model.metadata}} + * @capabilities={{this.model.capabilities}} * @onDelete={{this.handleDestruction}} * /> * * @param {string} mode - delete, delete-metadata, or destroy. - * @param {object} secret - The kv/data model. - * @param {object} [metadata] - The kv/metadata model. It is only required when mode is "delete". + * @param {object} secret - secret data. + * @param {object} [metadata] - secret metadata. It is only required when mode is "delete". * @param {string} [text] - Button text that renders in KV v2 toolbar, defaults to capitalize @mode + * @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths * @param {callback} onDelete - callback function fired to handle delete event. */ @@ -57,24 +60,29 @@ export default class KvDeleteModal extends Component { } } + get currentSecret() { + return currentSecret(this.args.metadata); + } + get deleteOptions() { - const { secret, metadata, version } = this.args; - const isDeactivated = secret.canReadMetadata ? metadata?.currentSecret?.isDeactivated : false; + const { capabilities, secret, version } = this.args; + const { canDeleteVersion, canDeleteLatestVersion } = capabilities; + const isDeactivated = this.currentSecret?.isDeactivated || false; return [ { key: 'delete-version', label: 'Delete this version', description: `This deletes ${version ? `Version ${version}` : `a specific version`} of the secret.`, - disabled: !secret.canDeleteVersion, + disabled: !canDeleteVersion, tooltipMessage: `Deleting a specific version requires "update" capabilities to ${secret.backend}/delete/${secret.path}.`, }, { key: 'delete-latest-version', label: 'Delete latest version', description: 'This deletes the most recent version of the secret.', - disabled: !secret.canDeleteLatestVersion || isDeactivated, + disabled: !canDeleteLatestVersion || isDeactivated, tooltipMessage: isDeactivated - ? `The latest version of the secret is already ${metadata.currentSecret.state}.` + ? `The latest version of the secret is already ${this.currentSecret.state}.` : `Deleting the latest version of this secret requires "delete" capabilities to ${secret.backend}/data/${secret.path}.`, }, ]; diff --git a/ui/lib/kv/addon/components/kv-metadata-fields.hbs b/ui/lib/kv/addon/components/kv-metadata-fields.hbs index 685a8ce1ad..0d390d4147 100644 --- a/ui/lib/kv/addon/components/kv-metadata-fields.hbs +++ b/ui/lib/kv/addon/components/kv-metadata-fields.hbs @@ -3,13 +3,11 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#each @metadata.formFields as |attr|}} - {{#if (eq attr.name "customMetadata")}} - +{{#each @form.metadataFields as |field|}} + + {{#if (eq field.name "custom_metadata")}} - {{else}} - {{/if}} {{/each}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-version-dropdown.hbs b/ui/lib/kv/addon/components/kv-version-dropdown.hbs index 606aac2fbb..c1bd809640 100644 --- a/ui/lib/kv/addon/components/kv-version-dropdown.hbs +++ b/ui/lib/kv/addon/components/kv-version-dropdown.hbs @@ -12,12 +12,12 @@