diff --git a/ui/app/adapters/kv/config.js b/ui/app/adapters/kv/config.js new file mode 100644 index 0000000000..398e6f2f33 --- /dev/null +++ b/ui/app/adapters/kv/config.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationAdapter from '../application'; +export default class KvConfigAdapter extends ApplicationAdapter { + namespace = 'v1'; + + urlForFindRecord(id) { + return `${this.buildURL()}/${id}/config`; + } +} diff --git a/ui/app/adapters/kv/data.js b/ui/app/adapters/kv/data.js new file mode 100644 index 0000000000..d6e46dc50e --- /dev/null +++ b/ui/app/adapters/kv/data.js @@ -0,0 +1,136 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationAdapter from '../application'; +import { kvDataPath, kvDeletePath, kvDestroyPath, kvUndeletePath } from 'vault/utils/kv-path'; +import { assert } from '@ember/debug'; +import ControlGroupError from 'vault/lib/control-group-error'; + +export default class KvDataAdapter extends ApplicationAdapter { + namespace = 'v1'; + + _url(fullPath) { + return `${this.buildURL()}/${fullPath}`; + } + + createRecord(store, type, snapshot) { + const { backend, path } = snapshot.record; + const url = this._url(kvDataPath(backend, path)); + + return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then((res) => { + return { + data: { + id: kvDataPath(backend, path, res.data.version), + backend, + path, + ...res.data, + }, + }; + }); + } + + fetchWrapInfo(query) { + const { backend, path, version, wrapTTL } = query; + const id = kvDataPath(backend, path, version); + return this.ajax(this._url(id), 'GET', { wrapTTL }).then((resp) => resp.wrap_info); + } + + queryRecord(store, type, query) { + const { backend, path, version } = query; + // ID is the full path for the data (including version) + let id = kvDataPath(backend, path, version); + return this.ajax(this._url(id), 'GET') + .then((resp) => { + // if no version is queried, add version from response to ID + // otherwise duplicate ember data models will exist in store + // (one with an ID that includes the version and one without) + if (!version) { + id = kvDataPath(backend, path, resp.data.metadata.version); + } + return { + ...resp, + data: { + id, + backend, + path, + ...resp.data, + }, + }; + }) + .catch((errorOrResponse) => { + const baseResponse = { id, backend, path, version }; + const errorCode = errorOrResponse.httpStatus; + // if it's a legitimate error - throw it! + if (errorOrResponse instanceof ControlGroupError) { + throw errorOrResponse; + } + + if (errorCode === 403) { + return { + data: { + ...baseResponse, + fail_read_error_code: errorCode, + }, + }; + } + + if (errorOrResponse.data) { + // in the case of a deleted/destroyed secret the API returns a 404 because { data: null } + // however, there could be a metadata block with important information like deletion_time + // handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists. + // we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught" + // the metadata that sneakily tried to hide from us + return { + ...errorOrResponse, + data: { + ...baseResponse, + ...errorOrResponse.data, // includes the { metadata } key we want + }, + }; + } + + // If we get here, it's probably a 404 because it doesn't exist + throw errorOrResponse; + }); + } + + /* Five types of delete operations (the 5th operation is on the kv/metadata adapter) */ + deleteRecord(store, type, snapshot) { + const { backend, path } = snapshot.record; + const { deleteType, deleteVersions } = snapshot.adapterOptions; + + if (!backend || !path) { + throw new Error('The request to delete or undelete is missing required attributes.'); + } + + switch (deleteType) { + case 'delete-latest-version': + return this.ajax(this._url(kvDataPath(backend, path)), 'DELETE'); + case 'delete-version': + return this.ajax(this._url(kvDeletePath(backend, path)), 'POST', { + data: { versions: deleteVersions }, + }); + case 'destroy': + return this.ajax(this._url(kvDestroyPath(backend, path)), 'PUT', { + data: { versions: deleteVersions }, + }); + case 'undelete': + return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', { + data: { versions: deleteVersions }, + }); + default: + assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.'); + } + } + + handleResponse(status, headers, payload, requestData) { + // after deleting a secret version, data is null and the API returns a 404 + // but there could be relevant metadata + if (status === 404 && payload.data?.metadata) { + return super.handleResponse(200, headers, payload, requestData); + } + return super.handleResponse(...arguments); + } +} diff --git a/ui/app/adapters/kv/metadata.js b/ui/app/adapters/kv/metadata.js new file mode 100644 index 0000000000..62c28008bb --- /dev/null +++ b/ui/app/adapters/kv/metadata.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationAdapter from '../application'; +import { kvMetadataPath } from 'vault/utils/kv-path'; + +export default class KvMetadataAdapter extends ApplicationAdapter { + namespace = 'v1'; + + _url(fullPath) { + return `${this.buildURL()}/${fullPath}`; + } + + createRecord(store, type, snapshot) { + const { backend, path } = snapshot.record; + const id = kvMetadataPath(backend, path); + const url = this._url(id); + const data = this.serialize(snapshot); + return this.ajax(url, 'POST', { data }).then(() => { + return { + id, + data, + }; + }); + } + + updateRecord(store, type, snapshot) { + const { backend, path } = snapshot.record; + const id = kvMetadataPath(backend, path); + const url = this._url(id); + const data = this.serialize(snapshot); + return this.ajax(url, 'POST', { data }).then(() => { + return { + id, + data, + }; + }); + } + + query(store, type, query) { + const { backend, pathToSecret } = query; + // example of pathToSecret: beep/boop/ + return this.ajax(this._url(kvMetadataPath(backend, pathToSecret)), 'GET', { + data: { list: true }, + }).then((resp) => { + resp.backend = backend; + resp.path = pathToSecret; + return resp; + }); + } + + queryRecord(store, type, query) { + const { backend, path } = query; + // ID is the full path for the metadata + const id = kvMetadataPath(backend, path); + return this.ajax(this._url(id), 'GET').then((resp) => { + return { + id, + ...resp, + data: { + backend, + path, + ...resp.data, + }, + }; + }); + } + + deleteRecord(store, type, snapshot) { + const { backend, path, fullSecretPath } = snapshot.record; + // fullSecretPath is used when deleting from the LIST view and is defined via the serializer + // path is used when deleting from the metadata details view. + return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE'); + } +} diff --git a/ui/app/adapters/secret-engine.js b/ui/app/adapters/secret-engine.js index c15a0bc6ef..ac5c9bb023 100644 --- a/ui/app/adapters/secret-engine.js +++ b/ui/app/adapters/secret-engine.js @@ -34,6 +34,7 @@ export default ApplicationAdapter.extend({ let mountModel, configModel; try { mountModel = await this.ajax(this.internalURL(query.path), 'GET'); + // TODO kv engine cleanup - this logic can be removed when KV exists in separate ember engine // if kv2 then add the config data to the mountModel // version comes in as a string if (mountModel?.data?.type === 'kv' && mountModel?.data?.options?.version === '2') { diff --git a/ui/app/app.js b/ui/app/app.js index edd6b5ec2f..5fbe6b5456 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -52,6 +52,14 @@ export default class App extends Application { }, }, }, + kv: { + dependencies: { + services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'], + externalRoutes: { + secrets: 'vault.cluster.secrets.backends', + }, + }, + }, pki: { dependencies: { services: [ diff --git a/ui/app/components/console/command-input.js b/ui/app/components/console/command-input.js index baeac1993a..5cbb860198 100644 --- a/ui/app/components/console/command-input.js +++ b/ui/app/components/console/command-input.js @@ -4,7 +4,7 @@ */ import Component from '@ember/component'; -import keys from 'vault/lib/keycodes'; +import keys from 'core/utils/key-codes'; export default Component.extend({ onExecuteCommand() {}, diff --git a/ui/app/templates/components/get-credentials-card.hbs b/ui/app/components/get-credentials-card.hbs similarity index 100% rename from ui/app/templates/components/get-credentials-card.hbs rename to ui/app/components/get-credentials-card.hbs diff --git a/ui/app/components/get-credentials-card.js b/ui/app/components/get-credentials-card.js index cd6015e9e7..9268c915ca 100644 --- a/ui/app/components/get-credentials-card.js +++ b/ui/app/components/get-credentials-card.js @@ -60,6 +60,7 @@ export default class GetCredentialsCard extends Component { if (role) { this.router.transitionTo('vault.cluster.secrets.backend.credentials', role); } + // TODO kv engine cleanup. Should be able to remove this component and replace with overview card for the role usage. KV engine has switched to overview card. if (secret) { if (secret.endsWith('/')) { this.router.transitionTo('vault.cluster.secrets.backend.list', secret); diff --git a/ui/app/components/role-edit.js b/ui/app/components/role-edit.js index a58d5611b3..8783c00579 100644 --- a/ui/app/components/role-edit.js +++ b/ui/app/components/role-edit.js @@ -10,7 +10,7 @@ import { task, waitForEvent } from 'ember-concurrency'; import Component from '@ember/component'; import { set } from '@ember/object'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; -import keys from 'vault/lib/keycodes'; +import keys from 'core/utils/key-codes'; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; diff --git a/ui/app/components/secret-create-or-update.js b/ui/app/components/secret-create-or-update.js index 7afc6b832c..aff341bc39 100644 --- a/ui/app/components/secret-create-or-update.js +++ b/ui/app/components/secret-create-or-update.js @@ -33,7 +33,7 @@ import Component from '@glimmer/component'; import ControlGroupError from 'vault/lib/control-group-error'; import Ember from 'ember'; -import keys from 'vault/lib/keycodes'; +import keys from 'core/utils/key-codes'; import { action, set } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; diff --git a/ui/app/components/secret-edit-toolbar.js b/ui/app/components/secret-edit-toolbar.js index 3f5f02ad6b..ce1c32a85a 100644 --- a/ui/app/components/secret-edit-toolbar.js +++ b/ui/app/components/secret-edit-toolbar.js @@ -18,8 +18,6 @@ * @showAdvancedMode={{showAdvancedMode}} * @modelForData={{this.modelForData}} * @canUpdateSecretData={{canUpdateSecretData}} - * @codemirrorString={{codemirrorString}} - * @wrappedData={{wrappedData}} * @editActions={{hash toggleAdvanced=(action "toggleAdvanced") refresh=(action "refresh") @@ -32,80 +30,45 @@ * @param {boolean} isV2 - KV type * @param {boolean} isWriteWithoutRead - boolean describing permissions * @param {boolean} secretDataIsAdvanced - used to determine if show JSON toggle - * @param {boolean} showAdvacnedMode - used for JSON toggle + * @param {boolean} showAdvancedMode - used for JSON toggle * @param {object} modelForData - a modified version of the model with secret data * @param {boolean} canUpdateSecretData - permissions that show the create new version button or not. - * @param {string} codemirrorString - used to copy the JSON - * @param {object} wrappedData - when copy the data it's the token of the secret returned. * @param {object} editActions - actions passed from parent to child */ /* eslint ember/no-computed-properties-in-native-classes: 'warn' */ import Component from '@glimmer/component'; import { action } from '@ember/object'; -import { not } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; export default class SecretEditToolbar extends Component { @service store; @service flashMessages; @tracked wrappedData = null; - @tracked isWrapping = false; - @not('wrappedData') showWrapButton; @action clearWrappedData() { this.wrappedData = null; } - @action - handleCopyError() { - this.flashMessages.danger('Could Not Copy Wrapped Data'); - this.send('clearWrappedData'); - } + @task + @waitFor + *wrapSecret() { + const { id } = this.args.modelForData; + const { backend } = this.args.model; + const wrapTTL = { wrapTTL: 1800 }; - @action - handleCopySuccess() { - this.flashMessages.success('Copied Wrapped Data!'); - this.send('clearWrappedData'); - } - - @action - handleWrapClick() { - this.isWrapping = true; - if (this.args.isV2) { - this.store - .adapterFor('secret-v2-version') - .queryRecord(this.args.modelForData.id, { wrapTTL: 1800 }) - .then((resp) => { - this.wrappedData = resp.wrap_info.token; - this.flashMessages.success('Secret Successfully Wrapped!'); - }) - .catch(() => { - this.flashMessages.danger('Could Not Wrap Secret'); - }) - .finally(() => { - this.isWrapping = false; - }); - } else { - this.store - .adapterFor('secret') - .queryRecord(null, null, { - backend: this.args.model.backend, - id: this.args.modelForData.id, - wrapTTL: 1800, - }) - .then((resp) => { - this.wrappedData = resp.wrap_info.token; - this.flashMessages.success('Secret Successfully Wrapped!'); - }) - .catch(() => { - this.flashMessages.danger('Could Not Wrap Secret'); - }) - .finally(() => { - this.isWrapping = false; - }); + try { + const resp = yield this.args.isV2 + ? this.store.adapterFor('secret-v2-version').queryRecord(id, wrapTTL) + : this.store.adapterFor('secret').queryRecord(null, null, { backend, id, ...wrapTTL }); + this.wrappedData = resp.wrap_info.token; + this.flashMessages.success('Secret successfully wrapped!'); + } catch (e) { + this.flashMessages.danger('Could not wrap secret.'); } } } diff --git a/ui/app/components/transit-edit.js b/ui/app/components/transit-edit.js index 45143294fa..05faf6cb0d 100644 --- a/ui/app/components/transit-edit.js +++ b/ui/app/components/transit-edit.js @@ -11,7 +11,7 @@ import { task, waitForEvent } from 'ember-concurrency'; import { set } from '@ember/object'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; -import keys from 'vault/lib/keycodes'; +import keys from 'core/utils/key-codes'; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; diff --git a/ui/app/lib/console-helpers.ts b/ui/app/lib/console-helpers.ts index ce547a5263..1a76f4775d 100644 --- a/ui/app/lib/console-helpers.ts +++ b/ui/app/lib/console-helpers.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import keys from 'vault/lib/keycodes'; +import keys from 'core/utils/key-codes'; import AdapterError from '@ember-data/adapter/error'; import { parse } from 'shell-quote'; diff --git a/ui/app/models/kv/config.js b/ui/app/models/kv/config.js new file mode 100644 index 0000000000..bbb27dfb94 --- /dev/null +++ b/ui/app/models/kv/config.js @@ -0,0 +1,31 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { duration } from 'core/helpers/format-duration'; + +// This model is used only for display only - configuration happens via secret-engine model when an engine is mounted +@withFormFields(['casRequired', 'deleteVersionAfter', 'maxVersions']) +export default class KvConfigModel extends Model { + @attr backend; + @attr('number', { label: 'Maximum number of versions' }) maxVersions; + + @attr('boolean', { label: 'Require check and set' }) casRequired; + + @attr({ label: 'Automate secret deletion' }) deleteVersionAfter; + + @lazyCapabilities(apiPath`${'backend'}/config`, 'backend') configPath; + + get canRead() { + return this.configPath.get('canRead') !== false; + } + + // used in template to render using this model instead of secret-engine (where these attrs also exist) + get displayFields() { + return ['casRequired', 'deleteVersionAfter', 'maxVersions']; + } + + get displayDeleteTtl() { + if (this.deleteVersionAfter === '0s') return 'Never delete'; + return duration([this.deleteVersionAfter]); + } +} diff --git a/ui/app/models/kv/data.js b/ui/app/models/kv/data.js new file mode 100644 index 0000000000..af91ddd51b --- /dev/null +++ b/ui/app/models/kv/data.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +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'; + +/* sample response +{ + "data": { + "data": { + "foo": "bar" + }, + "metadata": { + "created_time": "2018-03-22T02:24:06.945319214Z", + "custom_metadata": { + "owner": "jdoe", + "mission_critical": "false" + }, + "deletion_time": "", + "destroyed": false, + "version": 2 + } + } +} +*/ + +const validations = { + path: [ + { type: 'presence', message: `Path can't be blank.` }, + { type: 'endsInSlash', message: `Path can't end in forward slash '/'.` }, + { + type: 'containsWhiteSpace', + message: + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + level: 'warn', + }, + ], + secretData: [ + { + validator: (model) => + model.secretData !== undefined && typeof model.secretData !== 'object' ? false : true, + message: 'Vault expects data to be formatted as an JSON object.', + }, + ], +}; +@withModelValidations(validations) +@withFormFields() +export default class KvSecretDataModel extends Model { + @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord. + @attr('string', { label: 'Path for this secret' }) path; + @attr('object') secretData; // { key: value } data of the secret version + + // Params returned on the GET response. + @attr('string') createdTime; + @attr('object') customMetadata; + @attr('string') deletionTime; + @attr('boolean') destroyed; + @attr('number') version; + // Set in adapter if read failed + @attr('number') failReadErrorCode; + + // if creating a new version this value is set in the edit route's + // model hook from metadata or secret version, pending permissions + // if the value is not a number, don't send options.cas on payload + @attr('number') + casVersion; + + get state() { + if (this.destroyed) return 'destroyed'; + if (this.deletionTime) return 'deleted'; + if (this.createdTime) return 'created'; + return ''; + } + + // Permissions + @lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath; + @lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath; + @lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath; + @lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath; + @lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath; + + get canDeleteLatestVersion() { + return this.dataPath.get('canDelete') !== false; + } + get canDeleteVersion() { + return this.deletePath.get('canUpdate') !== false; + } + get canUndelete() { + return this.undeletePath.get('canUpdate') !== false; + } + get canDestroyVersion() { + return this.destroyPath.get('canUpdate') !== false; + } + get canEditData() { + return this.dataPath.get('canUpdate') !== false; + } + get canReadData() { + return this.dataPath.get('canRead') !== false; + } + get canReadMetadata() { + return this.metadataPath.get('canRead') !== false; + } + get canUpdateMetadata() { + return this.metadataPath.get('canUpdate') !== false; + } + get canListMetadata() { + return this.metadataPath.get('canList') !== false; + } +} diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js new file mode 100644 index 0000000000..c0ddc0e382 --- /dev/null +++ b/ui/app/models/kv/metadata.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +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 { keyIsFolder } from 'core/utils/key-utils'; + +const validations = { + maxVersions: [ + { type: 'number', message: 'Maximum versions must be a number.' }, + { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, + ], +}; +const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']; + +@withModelValidations(validations) +@withFormFields(formFieldProps) +export default class KvSecretMetadataModel extends Model { + @attr('string') backend; + @attr('string') path; + @attr('string') fullSecretPath; + + @attr('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.', + }) + maxVersions; + + @attr('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.`, + }) + casRequired; + + @attr('string', { + 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.', + }) + deleteVersionAfter; + + @attr('object', { + editType: 'kv', + subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', + }) + customMetadata; + + // Additional Params only returned on the GET response. + @attr('string') createdTime; + @attr('number') currentVersion; + @attr('number') oldestVersion; + @attr('string') updatedTime; + @attr('object') versions; + + // used for KV list and list-directory view + get pathIsDirectory() { + // ex: beep/ + return keyIsFolder(this.path); + } + + // turns version object into an array for version dropdown menu + get sortedVersions() { + const array = []; + for (const key in this.versions) { + array.push({ version: key, ...this.versions[key] }); + } + // version keys are in order created with 1 being the oldest, we want newest first + return array.reverse(); + } + + // helps in long logic statements for state of a currentVersion + get currentSecret() { + const data = this.versions[this.currentVersion]; + const state = data.destroyed ? 'destroyed' : data.deletion_time ? 'deleted' : 'created'; + return { + state, + isDeactivated: state !== 'created', + }; + } + + // permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups. + @lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath; + @lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath; + + get canDeleteMetadata() { + return this.metadataPath.get('canDelete') !== false; + } + get canReadMetadata() { + return this.metadataPath.get('canRead') !== false; + } + get canUpdateMetadata() { + return this.metadataPath.get('canUpdate') !== false; + } + get canCreateVersionData() { + return this.dataPath.get('canUpdate') !== false; + } +} diff --git a/ui/app/router.js b/ui/app/router.js index 78adeac841..5b1adc0468 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -159,6 +159,7 @@ Router.map(function () { this.route('backend', { path: '/:backend' }, function () { this.mount('kmip'); this.mount('kubernetes'); + this.mount('kv'); this.mount('pki'); this.route('index', { path: '/' }); this.route('configuration'); diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration.js b/ui/app/routes/vault/cluster/secrets/backend/configuration.js index 1e8401bb9a..a3619d313e 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration.js +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration.js @@ -10,6 +10,7 @@ export default Route.extend({ store: service(), async model() { const backend = this.modelFor('vault.cluster.secrets.backend'); + // TODO kv engine cleanup - this can be removed when KV has fully moved to separate ember engine and list view config details menu is refactored if (backend.isV2KV) { const canRead = await this.store .findRecord('capabilities', `${backend.id}/config`) diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index 4bbc16c852..e85d606e4f 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -320,7 +320,7 @@ export default Route.extend(UnloadModelRoute, { if (!model.hasDirtyAttributes || model.isDeleted) { return true; } - // TODO: below is KV v2 logic, remove with engine work + // TODO kv engine cleanup: below is KV v2 logic, remove with engine work const version = model.get('selectedVersion'); const changed = model.changedAttributes(); const changedKeys = Object.keys(changed); diff --git a/ui/app/serializers/kv/data.js b/ui/app/serializers/kv/data.js new file mode 100644 index 0000000000..de42ee0ec9 --- /dev/null +++ b/ui/app/serializers/kv/data.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from '../application'; + +export default class KvDataSerializer extends ApplicationSerializer { + serialize(snapshot) { + const { secretData, casVersion } = snapshot.record; + if (typeof casVersion === 'number') { + /* if this is a number it is set by one of the following: + A) user is creating initial version of a secret + -> 0 : default value set in route + B) user is creating a new version of a secret: + -> metadata.current_version : has metadata read permissions (data permissions are irrelevant) + -> secret.version : has data read permissions. without metadata read access a user is unable to navigate, + to older secret versions so we assume creation is from the latest version */ + return { data: secretData, options: { cas: casVersion } }; + } + // a non-number value means no read permission for both data and metadata + return { data: secretData }; + } + + normalizeKvData(payload) { + const { data, metadata } = payload.data; + return { + ...payload, + data: { + ...payload.data, + // Rename to secret_data so it doesn't get removed by normalizer + secret_data: data, + ...metadata, + }, + }; + } + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + if (requestType === 'queryRecord') { + const transformed = this.normalizeKvData(payload); + return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType); + } + return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); + } +} diff --git a/ui/app/serializers/kv/metadata.js b/ui/app/serializers/kv/metadata.js new file mode 100644 index 0000000000..87646a91a1 --- /dev/null +++ b/ui/app/serializers/kv/metadata.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { assert } from '@ember/debug'; +import ApplicationSerializer from '../application'; +import { kvMetadataPath } from 'vault/utils/kv-path'; + +export default class KvMetadataSerializer extends ApplicationSerializer { + attrs = { + backend: { serialize: false }, + path: { serialize: false }, + oldestVersion: { serialize: false }, + createdTime: { serialize: false }, + updatedTime: { serialize: false }, + currentVersion: { serialize: false }, + versions: { serialize: false }, + }; + + normalizeItems(payload) { + if (payload.data.keys) { + assert('payload.backend must be provided on kv/metadata list response', !!payload.backend); + return payload.data.keys.map((secret) => { + // If there is no payload.path then we're either on a "top level" secret or the first level directory of a nested secret. + // We set the path to the current secret or pathToSecret. e.g. my-secret or beep/boop/ + // We add a param called full_secret_path to the model which we use to navigate to the nested secret. e.g. beep/boop/bop. + const fullSecretPath = payload.path ? payload.path + secret : secret; + return { + id: kvMetadataPath(payload.backend, fullSecretPath), + path: secret, + backend: payload.backend, + full_secret_path: fullSecretPath, + }; + }); + } + return super.normalizeItems(payload); + } +} diff --git a/ui/app/services/store.js b/ui/app/services/store.js index 5f94c6ffa3..35280a8a6e 100644 --- a/ui/app/services/store.js +++ b/ui/app/services/store.js @@ -132,6 +132,7 @@ export default class StoreService extends Store { prevPage: clamp(currentPage - 1, 1, lastPage), total: dataset.length || 0, filteredTotal: data.length || 0, + pageSize: size, }; return resp; diff --git a/ui/app/styles/components/diff-version-selector.scss b/ui/app/styles/components/diff-version-selector.scss index 815018953a..ad505bdb72 100644 --- a/ui/app/styles/components/diff-version-selector.scss +++ b/ui/app/styles/components/diff-version-selector.scss @@ -2,6 +2,7 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ +// TODO kv engine cleanup safe to remove this, the new component does not rely on this css .visual-diff { background-color: black; diff --git a/ui/app/styles/components/modal-component.scss b/ui/app/styles/components/modal-component.scss index 5efc8f043f..addb2c2633 100644 --- a/ui/app/styles/components/modal-component.scss +++ b/ui/app/styles/components/modal-component.scss @@ -110,6 +110,16 @@ pre { } } +.is-danger { + .modal-card-head { + background: $red-010; + border: 1px solid $red-100; + } + .modal-card-title { + color: $red-dark; + } +} + .modal-confirm-section { margin: $spacing-xl 0 $spacing-m; } @@ -118,7 +128,7 @@ pre { background: $ui-gray-050; border-top: $base-border; } - +// kv engine clean up - can remove this style after you remove secret-delete-menu .modal-radio-button { display: flex; align-items: baseline; diff --git a/ui/app/styles/components/toolbar.scss b/ui/app/styles/components/toolbar.scss index 3956293b90..bb263c468d 100644 --- a/ui/app/styles/components/toolbar.scss +++ b/ui/app/styles/components/toolbar.scss @@ -133,6 +133,7 @@ a.disabled.toolbar-link { width: 0; } +// TODO kv engine cleanup .version-diff-toolbar { display: flex; align-items: baseline; diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index a18b9a0778..9716fda68e 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -29,6 +29,7 @@ @import './core/file'; @import './core/footer'; @import './core/inputs'; +@import './core/json-diff-patch'; @import './core/label'; @import './core/level'; @import './core/link'; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 93eafe3a3a..cc099350ed 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -92,6 +92,12 @@ color: $red-500; } + &.is-warning-outlined { + background-color: $yellow-010; + border: 1px solid $yellow-700; + color: $yellow-700; + } + &.is-flat { min-width: auto; border: none; diff --git a/ui/app/styles/core/json-diff-patch.scss b/ui/app/styles/core/json-diff-patch.scss new file mode 100644 index 0000000000..3e472d9ddb --- /dev/null +++ b/ui/app/styles/core/json-diff-patch.scss @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// used in KV version diff view. https://github.com/benjamine/jsondiffpatch/tree/master + +.jsondiffpatch-deleted .jsondiffpatch-property-name, +.jsondiffpatch-deleted pre, +.jsondiffpatch-modified .jsondiffpatch-left-value pre, +.jsondiffpatch-textdiff-deleted { + background: $red-500; +} +.jsondiffpatch-added .jsondiffpatch-property-name, +.jsondiffpatch-added .jsondiffpatch-value pre, +.jsondiffpatch-modified .jsondiffpatch-right-value pre, +.jsondiffpatch-textdiff-added { + background: $green-500; +} + +.jsondiffpatch-property-name { + color: $ui-gray-300; +} diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss index e0d4b81dbc..90add57ef7 100644 --- a/ui/app/styles/helper-classes/colors.scss +++ b/ui/app/styles/helper-classes/colors.scss @@ -18,6 +18,10 @@ background-color: $ui-gray-200; } +.background-color-black { + background-color: black; +} + // borders .has-border-top-light { border-radius: 0; @@ -40,6 +44,10 @@ select.has-error-border { } // text color +.text-grey-lightest { + color: $grey-lightest; +} + .has-text-grey-light { color: $ui-gray-300 !important; } diff --git a/ui/app/styles/helper-classes/flexbox-and-grid.scss b/ui/app/styles/helper-classes/flexbox-and-grid.scss index 20cbbe0cb0..3b9c4b4a77 100644 --- a/ui/app/styles/helper-classes/flexbox-and-grid.scss +++ b/ui/app/styles/helper-classes/flexbox-and-grid.scss @@ -118,6 +118,10 @@ grid-template-columns: repeat(3, 1fr); } +.align-self-center { + align-self: center; +} + .is-medium-height { height: 125px; } diff --git a/ui/app/styles/helper-classes/layout.scss b/ui/app/styles/helper-classes/layout.scss index 23e967903d..58717acc41 100644 --- a/ui/app/styles/helper-classes/layout.scss +++ b/ui/app/styles/helper-classes/layout.scss @@ -34,6 +34,10 @@ position: relative; } +.top-xxs { + top: $spacing-xxs; +} + // visibility .is-invisible { visibility: hidden; @@ -44,6 +48,10 @@ width: 100%; } +.is-three-fourths-width { + width: 75%; +} + .is-auto-width { width: auto; } diff --git a/ui/app/styles/helper-classes/typography.scss b/ui/app/styles/helper-classes/typography.scss index a2698c05ea..43b76fdb99 100644 --- a/ui/app/styles/helper-classes/typography.scss +++ b/ui/app/styles/helper-classes/typography.scss @@ -104,3 +104,7 @@ color: inherit; } } + +.opacity-060 { + opacity: 0.6; +} diff --git a/ui/app/templates/components/diff-version-selector.hbs b/ui/app/templates/components/diff-version-selector.hbs index 804ae29544..fe6ad3da8e 100644 --- a/ui/app/templates/components/diff-version-selector.hbs +++ b/ui/app/templates/components/diff-version-selector.hbs @@ -3,6 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} +{{! TODO kv engine cleanup }}
{{! Left side version }} diff --git a/ui/app/templates/components/secret-create-or-update.hbs b/ui/app/templates/components/secret-create-or-update.hbs index 7f91804d23..ce6d1e5f63 100644 --- a/ui/app/templates/components/secret-create-or-update.hbs +++ b/ui/app/templates/components/secret-create-or-update.hbs @@ -33,6 +33,7 @@ @isMarginless={{true}} /> {{/if}} + {{! TODO kv engine cleanup }} {{#if @modelForData.isFolder}}

The secret path may not end in @@ -172,7 +173,7 @@