From 9750dcaa7d515c1e310278fdc8c0f58ad87d8fa3 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 20 Apr 2022 12:40:27 -0600 Subject: [PATCH] Key Management Secrets Engine Phase 1 (#15036) * KMSE: Key Model / Adapter / Serializer setup (#13638) * First pass model * KMS key adapter (create/update), serializer, model * Add last rotated and provider to key * KeyEdit secret-edit component, and more key model stuff * add formatDate param support to infotablerow * Add keymgmt key to routes and options-for-backend * Rename keymgmt-key to keymgmt/key * Add test, cleanup * Add mirage handler for kms * Address PR comments * KMS Providers (#13797) * adds pagination-controls component * adds kms provider model, adapter and serializer * adds kms provider-edit component * updates secrets routes to handle itemType query param for kms * updates kms key adapter to query by provider * adds tests for provider-edit component * refactors kms provider adapter to account for dynamic path * adds model-validations-helper util * removes keymgmt from supported-secret-backends * fixes issue generating url for fetching keys for a provider * updates modelType method on secret-edit route to accept options object as arg rather than transition * adds additional checks to ensure queryParams are defined in options object for modelType method * UI/keymgmt distribute key (#13840) * Add distribution details on key page, and empty states if no permissions * Allow search-select component to return object so parent can tell when new item was created * Add stringarray transform * Distribute component first pass * Refactor distribute component for use with internal object rather than ember-data model * Specific permission denied errors on key edit * Allow inline errors on search-select component * Style updates for form errors * Styling and error messages on distribute component * Allow block template on inline alert so we can add doc links * Add distribute action, flash messages, cleanup * Cleanup & Add tests * More cleanup * Address PR comments * Move disable operations logic to commponent class * KMSE Enable/Config (#14835) * adds keymgmt secrets engine as supported backend * adds comment to check on keymgmt as member of adp module * updates kms provider to use model-validations decorator * fixes lint errors and tests Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com> --- ui/app/adapters/keymgmt/key.js | 152 +++++++++++ ui/app/adapters/keymgmt/provider.js | 63 +++++ ui/app/components/keymgmt/distribute.js | 247 ++++++++++++++++++ ui/app/components/keymgmt/key-edit.js | 98 +++++++ ui/app/components/keymgmt/provider-edit.js | 97 +++++++ ui/app/components/mount-backend-form.js | 4 +- ui/app/components/pagination-controls.js | 57 ++++ .../vault/cluster/secrets/backend/edit.js | 5 +- .../vault/cluster/secrets/backend/show.js | 4 +- ui/app/decorators/model-validations.js | 41 ++- ui/app/helpers/mountable-secret-engines.js | 9 + ui/app/helpers/options-for-backend.js | 25 ++ ui/app/helpers/secret-query-params.js | 20 +- ui/app/helpers/sub.js | 5 + ui/app/helpers/supported-secret-backends.js | 1 + ui/app/models/keymgmt/key.js | 96 +++++++ ui/app/models/keymgmt/provider.js | 121 +++++++++ ui/app/models/secret-engine.js | 37 +-- .../cluster/secrets/backend/create-root.js | 2 +- .../vault/cluster/secrets/backend/list.js | 1 + .../cluster/secrets/backend/secret-edit.js | 16 +- ui/app/serializers/keymgmt/key.js | 34 +++ ui/app/serializers/keymgmt/provider.js | 13 + ui/app/styles/components/doc-link.scss | 9 + ui/app/styles/core/buttons.scss | 23 ++ ui/app/styles/core/forms.scss | 3 +- ui/app/styles/core/helpers.scss | 25 ++ ui/app/styles/core/title.scss | 3 + .../components/keymgmt/distribute.hbs | 155 +++++++++++ .../templates/components/keymgmt/key-edit.hbs | 215 +++++++++++++++ .../components/keymgmt/provider-edit.hbs | 190 ++++++++++++++ .../components/mount-backend-form.hbs | 3 +- .../components/pagination-controls.hbs | 44 ++++ .../components/secret-list-header.hbs | 2 +- .../templates/components/secret-list/item.hbs | 6 +- .../vault/cluster/secrets/backends.hbs | 2 +- ui/lib/core/addon/components/form-field.js | 4 +- .../core/addon/components/info-table-row.hbs | 2 + ui/lib/core/addon/components/search-select.js | 8 +- ui/lib/core/addon/helpers/has-feature.js | 1 + .../templates/components/alert-inline.hbs | 6 +- .../templates/components/search-select.hbs | 3 + ui/mirage/handlers/index.js | 3 +- ui/mirage/handlers/kms.js | 58 ++++ ui/stories/pagination-controls.md | 26 ++ .../components/info-table-row-test.js | 12 + .../components/keymgmt/distribute-test.js | 152 +++++++++++ .../components/keymgmt/key-edit-test.js | 74 ++++++ .../components/keymgmt/provider-edit-test.js | 209 +++++++++++++++ .../components/pagination-controls-test.js | 74 ++++++ .../components/search-select-test.js | 32 +++ 51 files changed, 2436 insertions(+), 56 deletions(-) create mode 100644 ui/app/adapters/keymgmt/key.js create mode 100644 ui/app/adapters/keymgmt/provider.js create mode 100644 ui/app/components/keymgmt/distribute.js create mode 100644 ui/app/components/keymgmt/key-edit.js create mode 100644 ui/app/components/keymgmt/provider-edit.js create mode 100644 ui/app/components/pagination-controls.js create mode 100644 ui/app/helpers/sub.js create mode 100644 ui/app/models/keymgmt/key.js create mode 100644 ui/app/models/keymgmt/provider.js create mode 100644 ui/app/serializers/keymgmt/key.js create mode 100644 ui/app/serializers/keymgmt/provider.js create mode 100644 ui/app/templates/components/keymgmt/distribute.hbs create mode 100644 ui/app/templates/components/keymgmt/key-edit.hbs create mode 100644 ui/app/templates/components/keymgmt/provider-edit.hbs create mode 100644 ui/app/templates/components/pagination-controls.hbs create mode 100644 ui/mirage/handlers/kms.js create mode 100644 ui/stories/pagination-controls.md create mode 100644 ui/tests/integration/components/keymgmt/distribute-test.js create mode 100644 ui/tests/integration/components/keymgmt/key-edit-test.js create mode 100644 ui/tests/integration/components/keymgmt/provider-edit-test.js create mode 100644 ui/tests/integration/components/pagination-controls-test.js diff --git a/ui/app/adapters/keymgmt/key.js b/ui/app/adapters/keymgmt/key.js new file mode 100644 index 0000000000..2046d096a7 --- /dev/null +++ b/ui/app/adapters/keymgmt/key.js @@ -0,0 +1,152 @@ +import ApplicationAdapter from '../application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +function pickKeys(obj, picklist) { + const data = {}; + Object.keys(obj).forEach((key) => { + if (picklist.indexOf(key) >= 0) { + data[key] = obj[key]; + } + }); + return data; +} +export default class KeymgmtKeyAdapter extends ApplicationAdapter { + namespace = 'v1'; + + url(backend, id, type) { + const url = `${this.buildURL()}/${backend}/key`; + if (id) { + if (type === 'ROTATE') { + return url + '/' + encodePath(id) + '/rotate'; + } else if (type === 'PROVIDERS') { + return url + '/' + encodePath(id) + '/kms'; + } + return url + '/' + encodePath(id); + } + return url; + } + + urlForDeleteRecord(store, type, snapshot) { + const name = snapshot.attr('name'); + const backend = snapshot.attr('backend'); + return this.url(backend, name); + } + + _updateKey(backend, name, serialized) { + // Only these two attributes are allowed to be updated + let data = pickKeys(serialized, ['deletion_allowed', 'min_enabled_version']); + return this.ajax(this.url(backend, name), 'PUT', { data }); + } + + _createKey(backend, name, serialized) { + // Only type is allowed on create + let data = pickKeys(serialized, ['type']); + return this.ajax(this.url(backend, name), 'POST', { data }); + } + + async createRecord(store, type, snapshot) { + const data = store.serializerFor(type.modelName).serialize(snapshot); + const name = snapshot.attr('name'); + const backend = snapshot.attr('backend'); + // Keys must be created and then updated + await this._createKey(backend, name, data); + if (snapshot.attr('deletionAllowed')) { + try { + await this._updateKey(backend, name, data); + } catch (e) { + // TODO: Test how this works with UI + throw new Error(`Key ${name} was created, but not all settings were saved`); + } + } + return { + data: { + ...data, + id: name, + backend, + }, + }; + } + + updateRecord(store, type, snapshot) { + const data = store.serializerFor(type.modelName).serialize(snapshot); + const name = snapshot.attr('name'); + const backend = snapshot.attr('backend'); + return this._updateKey(backend, name, data); + } + + distribute(backend, kms, key, data) { + return this.ajax(`${this.buildURL()}/${backend}/kms/${encodePath(kms)}/key/${encodePath(key)}`, 'PUT', { + data: { ...data }, + }); + } + + async getProvider(backend, name) { + try { + const resp = await this.ajax(this.url(backend, name, 'PROVIDERS'), 'GET', { + data: { + list: true, + }, + }); + return resp.data.keys ? resp.data.keys[0] : null; + } catch (e) { + if (e.httpStatus === 404) { + // No results, not distributed yet + return null; + } else if (e.httpStatus === 403) { + return { permissionsError: true }; + } + // TODO: handle control group + throw e; + } + } + + getDistribution(backend, kms, key) { + const url = `${this.buildURL()}/${backend}/kms/${kms}/key/${key}`; + return this.ajax(url, 'GET') + .then((res) => { + return { + ...res.data, + purposeArray: res.data.purpose.split(','), + }; + }) + .catch(() => { + // TODO: handle control group + return null; + }); + } + + async queryRecord(store, type, query) { + const { id, backend, recordOnly = false } = query; + const keyData = await this.ajax(this.url(backend, id), 'GET'); + keyData.data.id = id; + keyData.data.backend = backend; + let provider, distribution; + if (!recordOnly) { + provider = await this.getProvider(backend, id); + if (provider) { + distribution = await this.getDistribution(backend, provider, id); + } + } + return { ...keyData, provider, distribution }; + } + + async query(store, type, query) { + const { backend, provider } = query; + const providerAdapter = store.adapterFor('keymgmt/provider'); + const url = provider ? providerAdapter.buildKeysURL(query) : this.url(backend); + + return this.ajax(url, 'GET', { + data: { + list: true, + }, + }).then((res) => { + res.backend = backend; + return res; + }); + } + + rotateKey(backend, id) { + // TODO: re-fetch record data after + return this.ajax(this.url(backend, id, 'ROTATE'), 'PUT'); + } +} diff --git a/ui/app/adapters/keymgmt/provider.js b/ui/app/adapters/keymgmt/provider.js new file mode 100644 index 0000000000..8de168cce7 --- /dev/null +++ b/ui/app/adapters/keymgmt/provider.js @@ -0,0 +1,63 @@ +import ApplicationAdapter from '../application'; +import { all } from 'rsvp'; + +export default class KeymgmtKeyAdapter extends ApplicationAdapter { + namespace = 'v1'; + listPayload = { data: { list: true } }; + + pathForType() { + // backend name prepended in buildURL method + return 'kms'; + } + buildURL(modelName, id, snapshot, requestType, query) { + let url = super.buildURL(...arguments); + if (snapshot) { + url = url.replace('kms', `${snapshot.attr('backend')}/kms`); + } else if (query) { + url = url.replace('kms', `${query.backend}/kms`); + } + return url; + } + buildKeysURL(query) { + const url = this.buildURL('keymgmt/provider', null, null, 'query', query); + return `${url}/${query.provider}/key`; + } + async createRecord(store, { modelName }, snapshot) { + // create uses PUT instead of POST + const data = store.serializerFor(modelName).serialize(snapshot); + const url = this.buildURL(modelName, snapshot.attr('name'), snapshot, 'updateRecord'); + return this.ajax(url, 'PUT', { data }).then(() => data); + } + findRecord(store, type, name) { + return super.findRecord(...arguments).then((resp) => { + resp.data = { ...resp.data, name }; + return resp; + }); + } + async query(store, type, query) { + const url = this.buildURL(type.modelName, null, null, 'query', query); + return this.ajax(url, 'GET', this.listPayload).then(async (resp) => { + // additional data is needed to fullfil the list view requirements + // pull in full record for listed items + const records = await all( + resp.data.keys.map((name) => this.findRecord(store, type, name, this._mockSnapshot(query.backend))) + ); + resp.data.keys = records.map((record) => record.data); + return resp; + }); + } + async queryRecord(store, type, query) { + return this.findRecord(store, type, query.id, this._mockSnapshot(query.backend)); + } + + // when using find in query or queryRecord overrides snapshot is not available + // ultimately buildURL requires the snapshot to pull the backend name for the dynamic segment + // since we have the backend value from the query generate a mock snapshot + _mockSnapshot(backend) { + return { + attr(prop) { + return prop === 'backend' ? backend : null; + }, + }; + } +} diff --git a/ui/app/components/keymgmt/distribute.js b/ui/app/components/keymgmt/distribute.js new file mode 100644 index 0000000000..a138ad31d9 --- /dev/null +++ b/ui/app/components/keymgmt/distribute.js @@ -0,0 +1,247 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { KEY_TYPES } from '../../models/keymgmt/key'; + +/** + * @module KeymgmtDistribute + * KeymgmtDistribute components are used to provide a form to distribute Keymgmt keys to a provider. + * + * @example + * ```js + * + * ``` + * @param {string} backend - name of backend, which will be the basis of other store queries + * @param {string} [key] - key is the name of the existing key which is being distributed. Will hide the key field in UI + * @param {string} [provider] - provider is the name of the existing provider which is being distributed to. Will hide the provider field in UI + */ + +class DistributionData { + @tracked key; + @tracked provider; + @tracked operations; + @tracked protection; +} + +const VALID_TYPES_BY_PROVIDER = { + gcpckms: ['aes256-gcm96', 'rsa-2048', 'rsa-3072', 'rsa-4096', 'ecdsa-p256', 'ecdsa-p384', 'ecdsa-p521'], + awskms: ['aes256-gcm96'], + azurekeyvault: ['rsa-2048', 'rsa-3072', 'rsa-4096'], +}; +export default class KeymgmtDistribute extends Component { + @service store; + @service flashMessages; + @service router; + + @tracked keyModel; + @tracked isNewKey = false; + @tracked providerType; + @tracked formData; + + constructor() { + super(...arguments); + this.formData = new DistributionData(); + // Set initial values passed in + this.formData.key = this.args.key || ''; + this.formData.provider = this.args.provider || ''; + // Side effects to get types of key or provider passed in + if (this.args.provider) { + this.getProviderType(this.args.provider); + } + if (this.args.key) { + this.getKeyInfo(this.args.key); + } + this.formData.operations = []; + } + + get keyTypes() { + return KEY_TYPES; + } + + get validMatchError() { + if (!this.providerType || !this.keyModel?.type) { + return null; + } + const valid = VALID_TYPES_BY_PROVIDER[this.providerType]?.includes(this.keyModel.type); + if (valid) return null; + + // default to showing error on provider unless @provider (field hidden) + if (this.args.provider) { + return { + key: `This key type is incompatible with the ${this.providerType} provider. To distribute to this provider, change the key type or choose another key.`, + }; + } + + const message = `This provider is incompatible with the ${this.keyModel.type} key type. Please choose another provider`; + return { + provider: this.args.key ? `${message}.` : `${message} or change the key type.`, + }; + } + + get operations() { + const pt = this.providerType; + if (pt === 'awskms') { + return ['encrypt', 'decrypt']; + } else if (pt === 'gcpckms') { + const kt = this.keyModel?.type || ''; + switch (kt) { + case 'aes256-gcm96': + return ['encrypt', 'decrypt']; + case 'rsa-2048': + case 'rsa-3072': + case 'rsa-4096': + return ['decrypt', 'sign']; + case 'ecdsa-p256': + case 'ecdsa-p384': + return ['sign']; + default: + return ['encrypt', 'decrypt', 'sign', 'verify', 'wrap', 'unwrap']; + } + } + + return ['encrypt', 'decrypt', 'sign', 'verify', 'wrap', 'unwrap']; + } + + get disableOperations() { + return ( + this.validMatchError || + !this.formData.provider || + !this.formData.key || + (this.isNewKey && !this.keyModel.type) + ); + } + + async getKeyInfo(keyName, isNew = false) { + let key; + if (isNew) { + this.isNewKey = true; + key = this.store.createRecord(`keymgmt/key`, { + backend: this.args.backend, + id: keyName, + name: keyName, + }); + } else { + key = await this.store + .queryRecord(`keymgmt/key`, { + backend: this.args.backend, + id: keyName, + recordOnly: true, + }) + .catch(() => { + // Key type isn't essential for distributing, so if + // we can't read it for some reason swallow the error + // and allow the API to respond with any key/provider + // type matching errors + }); + } + this.keyModel = key; + } + + async getProviderType(id) { + if (!id) { + this.providerType = ''; + return; + } + + const provider = await this.store + .queryRecord('keymgmt/provider', { + backend: this.args.backend, + id, + }) + .catch(() => {}); + this.providerType = provider?.provider; + } + + destroyKey() { + if (this.isNewKey) { + // Delete record from store if it was created here + this.keyModel.destroyRecord().finally(() => { + this.keyModel = null; + }); + } + this.isNewKey = false; + this.keyModel = null; + } + + /** + * + * @param {DistributionData} rawData + * @returns POJO formatted how the distribution endpoint needs + */ + formatData(rawData) { + const { key, provider, operations, protection } = rawData; + if (!key || !provider || !operations || operations.length === 0) return null; + return { key, provider, purpose: operations.join(','), protection }; + } + + distributeKey(backend, kms, key, data) { + let adapter = this.store.adapterFor('keymgmt/key'); + return adapter + .distribute(backend, kms, key, data) + .then(() => { + this.flashMessages.success(`Successfully distributed key ${key} to ${kms}`); + this.router.transitionTo('vault.cluster.secrets.backend.show', key); + }) + .catch((e) => { + this.flashMessages.danger(`Error distributing key: ${e.errors}`); + }); + } + + @action + handleProvider(evt) { + this.formData.provider = evt.target.value; + if (evt.target.value) { + this.getProviderType(evt.target.value); + } + } + @action + handleKeyType(evt) { + this.keyModel.set('type', evt.target.value); + } + + @action + handleOperation(evt) { + const ops = [...this.formData.operations]; + if (evt.target.checked) { + ops.push(evt.target.id); + } else { + const idx = ops.indexOf(evt.target.id); + ops.splice(idx, 1); + } + this.formData.operations = ops; + } + + @action + async handleKeySelect(selected) { + const selectedKey = selected[0] || null; + if (!selectedKey) { + this.formData.key = null; + return this.destroyKey(); + } + this.formData.key = selectedKey.id; + return this.getKeyInfo(selectedKey.id, selectedKey.isNew); + } + + @action + async createDistribution(evt) { + evt.preventDefault(); + const { backend } = this.args; + const data = this.formatData(this.formData); + if (!data) { + this.flashMessages.danger(`Key, provider, and operations are all required`); + return; + } + if (this.isNewKey) { + this.keyModel + .save() + .then(() => { + this.flashMessages.success(`Successfully created key ${this.keyModel.name}`); + }) + .catch((e) => { + this.flashMessages.danger(`Error creating new key ${this.keyModel.name}: ${e.errors}`); + }); + } + this.distributeKey(backend, 'example-kms', 'example-key', this.formatData(this.formData)); + } +} diff --git a/ui/app/components/keymgmt/key-edit.js b/ui/app/components/keymgmt/key-edit.js new file mode 100644 index 0000000000..74531ba0ef --- /dev/null +++ b/ui/app/components/keymgmt/key-edit.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module KeymgmtKeyEdit + * KeymgmtKeyEdit components are used to display KeyMgmt Secrets engine UI for Key items + * + * @example + * ```js + * + * ``` + * @param {object} model - model is the data from the store + * @param {string} [mode=show] - mode controls which view is shown on the component + * @param {string} [tab=details] - Options are "details" or "versions" for the show mode only + */ + +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; +export default class KeymgmtKeyEdit extends Component { + @service store; + @service router; + @service flashMessages; + @tracked isDeleteModalOpen = false; + + get mode() { + return this.args.mode || 'show'; + } + + get keyAdapter() { + return this.store.adapterFor('keymgmt/key'); + } + + @action + toggleModal(bool) { + this.isDeleteModalOpen = bool; + } + + @action + createKey(evt) { + evt.preventDefault(); + this.args.model.save(); + } + + @action + updateKey(evt) { + evt.preventDefault(); + const name = this.args.model.name; + this.args.model + .save() + .then(() => { + this.router.transitionTo(SHOW_ROUTE, name); + }) + .catch((e) => { + this.flashMessages.danger(e.errors.join('. ')); + }); + } + + @action + removeKey(id) { + // TODO: remove action + console.log('remove', id); + } + + @action + deleteKey() { + const secret = this.args.model; + const backend = secret.backend; + console.log({ secret }); + secret + .destroyRecord() + .then(() => { + try { + this.router.transitionTo(LIST_ROOT_ROUTE, backend, { queryParams: { tab: 'key' } }); + } catch (e) { + console.debug(e); + } + }) + .catch((e) => { + this.flashMessages.danger(e.errors?.join('. ')); + }); + } + + @action + rotateKey(id) { + const backend = this.args.model.get('backend'); + const adapter = this.keyAdapter; + adapter + .rotateKey(backend, id) + .then(() => { + this.flashMessages.success(`Success: ${id} connection was rotated`); + }) + .catch((e) => { + this.flashMessages.danger(e.errors); + }); + } +} diff --git a/ui/app/components/keymgmt/provider-edit.js b/ui/app/components/keymgmt/provider-edit.js new file mode 100644 index 0000000000..35b6be8ae6 --- /dev/null +++ b/ui/app/components/keymgmt/provider-edit.js @@ -0,0 +1,97 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; + +/** + * @module KeymgmtProviderEdit + * ProviderKeyEdit components are used to display KeyMgmt Secrets engine UI for Key items + * + * @example + * ```js + * + * ``` + * @param {object} model - model is the data from the store + * @param {string} mode - mode controls which view is shown on the component - show | create | + * @param {string} [tab] - Options are "details" or "keys" for the show mode only + */ + +export default class KeymgmtProviderEdit extends Component { + @service router; + @service flashMessages; + + constructor() { + super(...arguments); + // key count displayed in details tab and keys are listed in keys tab + if (this.args.mode === 'show') { + this.fetchKeys.perform(); + } + } + + @tracked modelValidations; + + get isShowing() { + return this.args.mode === 'show'; + } + get isCreating() { + return this.args.mode === 'create'; + } + get viewingKeys() { + return this.args.tab === 'keys'; + } + + @task + @waitFor + *saveTask() { + const { model } = this.args; + try { + yield model.save(); + this.router.transitionTo('vault.cluster.secrets.backend.show', model.id, { + queryParams: { itemType: 'provider' }, + }); + } catch (error) { + this.flashMessages.danger(error.errors.join('. ')); + } + } + @task + @waitFor + *fetchKeys(page = 1) { + try { + yield this.args.model.fetchKeys(page); + } catch (error) { + this.flashMessages.danger(error.errors.join('. ')); + } + } + + @action + async onSave(event) { + event.preventDefault(); + const { isValid, state } = await this.args.model.validate(); + if (isValid) { + this.saveTask.perform(); + } else { + this.modelValidations = state; + } + } + @action + async onDelete() { + try { + const { model, root } = this.args; + await model.destroyRecord(); + this.router.transitionTo(root.path, root.model, { queryParams: { tab: 'provider' } }); + } catch (error) { + this.flashMessages.danger(error.errors.join('. ')); + } + } + @action + async onDeleteKey(model) { + try { + await model.destroyRecord(); + this.args.model.keys.removeObject(model); + } catch (error) { + this.flashMessages.danger(error.errors.join('. ')); + } + } +} diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index d91af0b93d..b23a7ffc5f 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -4,7 +4,7 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; import { task } from 'ember-concurrency'; import { methods } from 'vault/helpers/mountable-auth-methods'; -import { engines, KMIP, TRANSFORM } from 'vault/helpers/mountable-secret-engines'; +import { engines, KMIP, TRANSFORM, KEYMGMT } from 'vault/helpers/mountable-secret-engines'; import { waitFor } from '@ember/test-waiters'; const METHODS = methods(); @@ -65,7 +65,7 @@ export default Component.extend({ engines: computed('version.{features[],isEnterprise}', function () { if (this.version.isEnterprise) { - return ENGINES.concat([KMIP, TRANSFORM]); + return ENGINES.concat([KMIP, TRANSFORM, KEYMGMT]); } return ENGINES; }), diff --git a/ui/app/components/pagination-controls.js b/ui/app/components/pagination-controls.js new file mode 100644 index 0000000000..de52c5e8da --- /dev/null +++ b/ui/app/components/pagination-controls.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +/** + * @module PaginationControls + * PaginationControls components are used to paginate through item lists + * + * @example + * ```js + * + * ``` + * @param {number} total - total number of items + * @param {number} [startPage=1] - initial page number to select + * @param {number} [size=15] - number of items to display per page + * @param {function} onChange - callback fired on page change + */ + +export default class PaginationControls extends Component { + @tracked page; + + constructor() { + super(...arguments); + this.page = this.args.startPage || 1; + this.size = this.args.size || 15; // size selector may be added in future version + } + + get totalPages() { + return Math.ceil(this.args.total / this.size); + } + get displayInfo() { + const { total } = this.args; + const end = this.page * this.size; + return `${end - this.size + 1}-${end > total ? total : end} of ${total}`; + } + get pages() { + // show 5 pages with 2 on either side of the current page + let start = this.page - 2 >= 1 ? this.page - 2 : 1; + const incrementer = start + 4; + const end = incrementer <= this.totalPages ? incrementer : this.totalPages; + const pageNumbers = []; + while (start <= end) { + pageNumbers.push(start); + start++; + } + return pageNumbers; + } + get hasMorePages() { + return this.pages.lastObject !== this.totalPages; + } + + @action + changePage(page) { + this.page = page; + this.args.onChange(page); + } +} diff --git a/ui/app/controllers/vault/cluster/secrets/backend/edit.js b/ui/app/controllers/vault/cluster/secrets/backend/edit.js index f7eb5bd848..8f97308472 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/edit.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/edit.js @@ -3,10 +3,13 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb'; export default Controller.extend(BackendCrumbMixin, { backendController: controller('vault.cluster.secrets.backend'), - queryParams: ['version'], + queryParams: ['version', 'itemType'], version: '', + itemType: '', + reset() { this.set('version', ''); + this.set('itemType', ''); }, actions: { refresh: function () { diff --git a/ui/app/controllers/vault/cluster/secrets/backend/show.js b/ui/app/controllers/vault/cluster/secrets/backend/show.js index b96ff32dc3..79925aa3be 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/show.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/show.js @@ -3,14 +3,16 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb'; export default Controller.extend(BackendCrumbMixin, { backendController: controller('vault.cluster.secrets.backend'), - queryParams: ['tab', 'version', 'type'], + queryParams: ['tab', 'version', 'type', 'itemType', 'page'], version: '', tab: '', type: '', + itemType: '', reset() { this.set('tab', ''); this.set('version', ''); this.set('type', ''); + this.set('itemType', ''); }, actions: { refresh: function () { diff --git a/ui/app/decorators/model-validations.js b/ui/app/decorators/model-validations.js index 4cf91310ee..fae6e8f70d 100644 --- a/ui/app/decorators/model-validations.js +++ b/ui/app/decorators/model-validations.js @@ -1,15 +1,19 @@ /* eslint-disable no-console */ import validators from 'vault/utils/validators'; +import { get } from '@ember/object'; /** * used to validate properties on a class * * decorator expects validations object with the following shape: - * { [propertyKeyName]: [{ type, options, message }] } + * { [propertyKeyName]: [{ type, options, message, validator }] } * each key in the validations object should refer to the property on the class to apply the validation to * type refers to the type of validation to apply -- must be exported from validators util for lookup * options is an optional object for given validator -- min, max, nullable etc. -- see validators in util * message is added to the errors array and returned from the validate method if validation fails + * validator may be used in place of type to provide a function that gets executed in the validate method + * validator is useful when specific validations are needed (dependent on other class properties etc.) + * validator must be passed as function that takes the class context (this) as the only argument and returns true or false * each property supports multiple validations provided as an array -- for example, presence and length for string * * validations must be invoked using the validate method which is added directly to the decorated class @@ -21,7 +25,7 @@ import validators from 'vault/utils/validators'; * errors will be populated with messages defined in the validations object when validations fail * since a property can have multiple validations, errors is always returned as an array * - * full example + *** basic example * * import Model from '@ember-data/model'; * import withModelValidations from 'vault/decorators/model-validations'; @@ -35,6 +39,18 @@ import validators from 'vault/utils/validators'; * -> isValid = false; * -> state.foo.isValid = false; * -> state.foo.errors = ['foo is a required field']; + * + *** example using custom validator + * + * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test' }] }; + * @withModelValidations(validations) + * class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; } + * + * const model = new SomeModel(); + * const { isValid, state } = model.validate(); + * -> isValid = false; + * -> state.foo.isValid = false; + * -> state.foo.errors = ['foo is required if bar includes test']; */ export function withModelValidations(validations) { @@ -67,16 +83,25 @@ export function withModelValidations(validations) { state[key] = { errors: [] }; for (const rule of rules) { - const { type, options, message } = rule; - if (!validators[type]) { + const { type, options, message, validator: customValidator } = rule; + // check for custom validator or lookup in validators util by type + const useCustomValidator = typeof customValidator === 'function'; + const validator = useCustomValidator ? customValidator : validators[type]; + if (!validator) { console.error( - `Validator type: "${type}" not found. Available validators: ${Object.keys(validators).join( - ', ' - )}` + !type + ? 'Validator not found. Define either type or pass custom validator function under "validator" key in validations object' + : `Validator type: "${type}" not found. Available validators: ${Object.keys( + validators + ).join(', ')}` ); continue; } - if (!validators[type](this[key], options)) { + const passedValidation = useCustomValidator + ? validator(this) + : validator(get(this, key), options); // dot notation may be used to define key for nested property + + if (!passedValidation) { // consider setting a prop like validationErrors directly on the model // for now return an errors object state[key].errors.push(message); diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index eb0bec23e8..94b2a8b72f 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -16,6 +16,15 @@ export const TRANSFORM = { requiredFeature: 'Transform Secrets Engine', }; +export const KEYMGMT = { + displayName: 'Key Management', + value: 'keymgmt', + type: 'keymgmt', + glyph: 'key', + category: 'generic', + requiredFeature: 'Key Management Secrets Engine', +}; + const MOUNTABLE_SECRET_ENGINES = [ { displayName: 'Active Directory', diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index e918e60c51..88e457b793 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -83,6 +83,31 @@ const SECRET_BACKENDS = { }, ], }, + keymgmt: { + displayName: 'Key Management', + navigateTree: false, + listItemPartial: 'secret-list/item', + tabs: [ + { + name: 'key', + label: 'Keys', + searchPlaceholder: 'Filter keys', + item: 'key', + create: 'Create key', + editComponent: 'keymgmt/key-edit', + }, + { + name: 'provider', + modelPrefix: 'provider/', + label: 'Providers', + searchPlaceholder: 'Filter providers', + item: 'provider', + create: 'Create provider', + tab: 'provider', + editComponent: 'keymgmt/provider-edit', + }, + ], + }, transform: { displayName: 'Transformation', navigateTree: false, diff --git a/ui/app/helpers/secret-query-params.js b/ui/app/helpers/secret-query-params.js index be7a873f65..f0a5b63d0d 100644 --- a/ui/app/helpers/secret-query-params.js +++ b/ui/app/helpers/secret-query-params.js @@ -1,13 +1,19 @@ import { helper } from '@ember/component/helper'; -export function secretQueryParams([backendType, type = '']) { - if (backendType === 'transit') { - return { tab: 'actions' }; +export function secretQueryParams([backendType, type = ''], { asQueryParams }) { + const values = { + transit: { tab: 'actions' }, + database: { type }, + keymgmt: { itemType: type || 'key' }, + }[backendType]; + // format required when using LinkTo with positional params + if (values && asQueryParams) { + return { + isQueryParams: true, + values, + }; } - if (backendType === 'database') { - return { type: type }; - } - return; + return values; } export default helper(secretQueryParams); diff --git a/ui/app/helpers/sub.js b/ui/app/helpers/sub.js new file mode 100644 index 0000000000..990f3bdbee --- /dev/null +++ b/ui/app/helpers/sub.js @@ -0,0 +1,5 @@ +import { helper } from '@ember/component/helper'; + +export default helper(function ([a, ...toSubtract]) { + return toSubtract.reduce((total, value) => total - parseInt(value, 0), a); +}); diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index 3adb3d32c2..806b90513b 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -11,6 +11,7 @@ const SUPPORTED_SECRET_BACKENDS = [ 'transit', 'kmip', 'transform', + 'keymgmt', ]; export function supportedSecretBackends() { diff --git a/ui/app/models/keymgmt/key.js b/ui/app/models/keymgmt/key.js new file mode 100644 index 0000000000..2c7ce63547 --- /dev/null +++ b/ui/app/models/keymgmt/key.js @@ -0,0 +1,96 @@ +import Model, { attr } from '@ember-data/model'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +export const KEY_TYPES = [ + 'aes256-gcm96', + 'rsa-2048', + 'rsa-3072', + 'rsa-4096', + 'ecdsa-p256', + 'ecdsa-p384', + 'ecdsa-p521', +]; +export default class KeymgmtKeyModel extends Model { + @attr('string') name; + @attr('string') backend; + + @attr('string', { + possibleValues: KEY_TYPES, + }) + type; + + @attr('boolean', { + defaultValue: false, + }) + deletionAllowed; + + @attr('number', { + label: 'Current version', + }) + latestVersion; + + @attr('number', { + defaultValue: 0, + defaultShown: 'All versions enabled', + }) + minEnabledVersion; + + @attr('array') + versions; + + // The following are calculated in serializer + @attr('date') + created; + + @attr('date', { + defaultShown: 'Not yet rotated', + }) + lastRotated; + + // The following are from endpoints other than the main read one + @attr() provider; // string, or object with permissions error + @attr() distribution; + + icon = 'key'; + + get hasVersions() { + return this.versions.length > 1; + } + + get createFields() { + const createFields = ['name', 'type', 'deletionAllowed']; + return expandAttributeMeta(this, createFields); + } + + get updateFields() { + return expandAttributeMeta(this, ['minEnabledVersion', 'deletionAllowed']); + } + get showFields() { + return expandAttributeMeta(this, [ + 'name', + 'created', + 'type', + 'deletionAllowed', + 'latestVersion', + 'minEnabledVersion', + 'lastRotated', + ]); + } + + get keyTypeOptions() { + return expandAttributeMeta(this, ['type'])[0]; + } + + get distFields() { + return [ + { + name: 'name', + type: 'string', + label: 'Distributed name', + subText: 'The name given to the key by the provider.', + }, + { name: 'purpose', type: 'string', label: 'Key Purpose' }, + { name: 'protection', type: 'string', subText: 'Where cryptographic operations are performed.' }, + ]; + } +} diff --git a/ui/app/models/keymgmt/provider.js b/ui/app/models/keymgmt/provider.js new file mode 100644 index 0000000000..1dbdda6421 --- /dev/null +++ b/ui/app/models/keymgmt/provider.js @@ -0,0 +1,121 @@ +import Model, { attr } from '@ember-data/model'; +import { tracked } from '@glimmer/tracking'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const CRED_PROPS = { + azurekeyvault: ['client_id', 'client_secret', 'tenant_id'], + awskms: ['access_key', 'secret_key', 'session_token', 'endpoint'], + gcpckms: ['service_account_file'], +}; +const OPTIONAL_CRED_PROPS = ['session_token', 'endpoint']; +// since we have dynamic credential attributes based on provider we need a dynamic presence validator +// add validators for all cred props and return true for value if not associated with selected provider +const credValidators = Object.keys(CRED_PROPS).reduce((obj, providerKey) => { + CRED_PROPS[providerKey].forEach((prop) => { + if (!OPTIONAL_CRED_PROPS.includes(prop)) { + obj[`credentials.${prop}`] = [ + { + message: `${prop} is required`, + validator(model) { + return model.credentialProps.includes(prop) ? model.credentials[prop] : true; + }, + }, + ]; + } + }); + return obj; +}, {}); +const validations = { + name: [{ type: 'presence', message: 'Provider name is required' }], + keyCollection: [{ type: 'presence', message: 'Key Vault instance name' }], + ...credValidators, +}; +@withModelValidations(validations) +export default class KeymgmtProviderModel extends Model { + @attr('string') backend; + @attr('string', { + label: 'Provider name', + subText: 'This is the name of the provider that will be displayed in Vault. This cannot be edited later.', + }) + name; + + @attr('string', { + label: 'Type', + subText: 'Choose the provider type.', + possibleValues: ['azurekeyvault', 'awskms', 'gcpckms'], + defaultValue: 'azurekeyvault', + }) + provider; + + @attr('string', { + label: 'Key Vault instance name', + subText: 'The name of a Key Vault instance must be supplied. This cannot be edited later.', + }) + keyCollection; + + @attr('date') created; + + idPrefix = 'provider/'; + type = 'provider'; + + @tracked keys = []; + @tracked credentials = null; // never returned from API -- set only during create/edit + + get icon() { + return { + azurekeyvault: 'azure-color', + awskms: 'aws-color', + gcpckms: 'gcp-color', + }[this.provider]; + } + get typeName() { + return { + azurekeyvault: 'Azure Key Vault', + awskms: 'AWS Key Management Service', + gcpckms: 'Google Cloud Key Management Service', + }[this.provider]; + } + get showFields() { + const attrs = expandAttributeMeta(this, ['name', 'created', 'keyCollection']); + attrs.splice(1, 0, { hasBlock: true, label: 'Type', value: this.typeName, icon: this.icon }); + const l = this.keys.length; + const value = l ? `${l} ${l > 1 ? 'keys' : 'key'}` : 'None'; + attrs.push({ hasBlock: true, isLink: l, label: 'Keys', value }); + return attrs; + } + get credentialProps() { + return CRED_PROPS[this.provider]; + } + get credentialFields() { + const [creds, fields] = this.credentialProps.reduce( + ([creds, fields], prop) => { + creds[prop] = null; + fields.push({ name: `credentials.${prop}`, type: 'string', options: { label: prop } }); + return [creds, fields]; + }, + [{}, []] + ); + this.credentials = creds; + return fields; + } + get createFields() { + return expandAttributeMeta(this, ['provider', 'name', 'keyCollection']); + } + + async fetchKeys(page) { + try { + this.keys = await this.store.lazyPaginatedQuery('keymgmt/key', { + backend: 'keymgmt', + provider: this.name, + responsePath: 'data.keys', + page, + }); + } catch (error) { + this.keys = []; + if (error.httpStatus !== 404) { + throw error; + } + } + } +} diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index b30b01b3d2..a51983fd8f 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -75,15 +75,10 @@ export default SecretEngineModel.extend({ formFields: computed('engineType', 'options.version', function () { let type = this.engineType; let version = this.options?.version; - let fields = [ - 'type', - 'path', - 'description', - 'accessor', - 'local', - 'sealWrap', - 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}', - ]; + let fields = ['type', 'path', 'description', 'accessor', 'local', 'sealWrap']; + // no ttl options for keymgmt + const ttl = type !== 'keymgmt' ? 'defaultLeaseTtl,maxLeaseTtl,' : ''; + fields.push(`config.{${ttl}auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}`); if (type === 'kv' || type === 'generic') { fields.push('options.{version}'); } @@ -104,14 +99,14 @@ export default SecretEngineModel.extend({ defaultGroup = { default: ['path'] }; } let optionsGroup = { - 'Method Options': [ - 'description', - 'config.listingVisibility', - 'local', - 'sealWrap', - 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}', - ], + 'Method Options': ['description', 'config.listingVisibility', 'local', 'sealWrap'], }; + // no ttl options for keymgmt + const ttl = type !== 'keymgmt' ? 'defaultLeaseTtl,maxLeaseTtl,' : ''; + optionsGroup['Method Options'].push( + `config.{${ttl}auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}` + ); + if (type === 'kv' || type === 'generic') { optionsGroup['Method Options'].unshift('options.{version}'); } @@ -142,6 +137,16 @@ export default SecretEngineModel.extend({ return fieldToAttrs(this, this.formFieldGroups); }), + icon: computed('engineType', function () { + if (!this.engineType || this.engineType === 'kmip') { + return 'secrets'; + } + if (this.engineType === 'keymgmt') { + return 'key'; + } + return this.engineType; + }), + // namespaces introduced types with a `ns_` prefix for built-in engines // so we need to strip that to normalize the type engineType: computed('type', function () { diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index 6dac9d9cdb..6362d30cc2 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -31,7 +31,7 @@ export default EditBase.extend({ wizard: service(), createModel(transition) { const { backend } = this.paramsFor('vault.cluster.secrets.backend'); - let modelType = this.modelType(backend); + let modelType = this.modelType(backend, null, { queryParams: transition.to.queryParams }); if (modelType === 'role-ssh') { return this.store.createRecord(modelType, { keyType: 'ca' }); } diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 6245fce39a..019d4b3ad3 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -84,6 +84,7 @@ export default Route.extend({ // secret or secret-v2 cubbyhole: 'secret', kv: secretEngine.get('modelTypeForKV'), + keymgmt: `keymgmt/${tab || 'key'}`, generic: secretEngine.get('modelTypeForKV'), }; return types[type]; 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 98d015b23f..10f0d9995f 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -66,9 +66,9 @@ export default Route.extend(UnloadModelRoute, { templateName: 'vault/cluster/secrets/backend/secretEditLayout', - beforeModel() { + beforeModel({ to: { queryParams } }) { let secret = this.secretParam(); - return this.buildModel(secret).then(() => { + return this.buildModel(secret, queryParams).then(() => { const parentKey = utils.parentKeyForKey(secret); const mode = this.routeName.split('.').pop(); if (mode === 'edit' && utils.keyIsFolder(secret)) { @@ -81,17 +81,16 @@ export default Route.extend(UnloadModelRoute, { }); }, - buildModel(secret) { + buildModel(secret, queryParams) { const backend = this.enginePathParam(); - - let modelType = this.modelType(backend, secret); + let modelType = this.modelType(backend, secret, { queryParams }); if (['secret', 'secret-v2'].includes(modelType)) { return resolve(); } return this.pathHelp.getNewModel(modelType, backend); }, - modelType(backend, secret) { + modelType(backend, secret, options = {}) { let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); let type = backendModel.get('engineType'); let types = { @@ -103,6 +102,7 @@ export default Route.extend(UnloadModelRoute, { pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki', cubbyhole: 'secret', kv: backendModel.get('modelTypeForKV'), + keymgmt: `keymgmt/${options.queryParams?.itemType || 'key'}`, generic: backendModel.get('modelTypeForKV'), }; return types[type]; @@ -212,10 +212,10 @@ export default Route.extend(UnloadModelRoute, { return secretModel; }, - async model(params) { + async model(params, { to: { queryParams } }) { let secret = this.secretParam(); let backend = this.enginePathParam(); - let modelType = this.modelType(backend, secret); + let modelType = this.modelType(backend, secret, { queryParams }); let type = params.type || ''; if (!secret) { secret = '\u0020'; diff --git a/ui/app/serializers/keymgmt/key.js b/ui/app/serializers/keymgmt/key.js new file mode 100644 index 0000000000..6c8dcba5f7 --- /dev/null +++ b/ui/app/serializers/keymgmt/key.js @@ -0,0 +1,34 @@ +import ApplicationSerializer from '../application'; + +export default class KeymgmtKeySerializer extends ApplicationSerializer { + normalizeItems(payload) { + let normalized = super.normalizeItems(payload); + // Transform versions from object with number keys to array with key ids + if (normalized.versions) { + let lastRotated; + let created; + let versions = []; + Object.keys(normalized.versions).forEach((key, i, arr) => { + versions.push({ + id: parseInt(key, 10), + ...normalized.versions[key], + }); + if (i === 0) { + created = normalized.versions[key].creation_time; + } else if (arr.length - 1 === i) { + // Set lastRotated to the last key + lastRotated = normalized.versions[key].creation_time; + } + }); + normalized.versions = versions; + return { ...normalized, last_rotated: lastRotated, created }; + } else if (Array.isArray(normalized)) { + return normalized.map((key) => ({ + id: key.id, + name: key.id, + backend: payload.backend, + })); + } + return normalized; + } +} diff --git a/ui/app/serializers/keymgmt/provider.js b/ui/app/serializers/keymgmt/provider.js new file mode 100644 index 0000000000..ac679c273f --- /dev/null +++ b/ui/app/serializers/keymgmt/provider.js @@ -0,0 +1,13 @@ +import ApplicationSerializer from '../application'; + +export default class KeymgmtProviderSerializer extends ApplicationSerializer { + primaryKey = 'name'; + + serialize(snapshot) { + const json = super.serialize(...arguments); + return { + ...json, + credentials: snapshot.record.credentials, + }; + } +} diff --git a/ui/app/styles/components/doc-link.scss b/ui/app/styles/components/doc-link.scss index d2a2f806a3..dd03711714 100644 --- a/ui/app/styles/components/doc-link.scss +++ b/ui/app/styles/components/doc-link.scss @@ -6,3 +6,12 @@ text-decoration: underline !important; } } + +.doc-link-subtle { + color: inherit; + text-decoration: underline; + font-weight: inherit; + &:hover { + color: inherit; + } +} diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 871de5c9f3..123a0f594e 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -41,6 +41,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); min-width: auto; padding: 0; } + &.is-flat { + min-width: auto; + border: none; + box-shadow: none; + } @each $name, $pair in $colors { $color: nth($pair, 1); @@ -100,6 +105,16 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); } } + &.is-underlined { + &:active, + &.is-active { + background-color: transparent; + border-bottom: 2px solid darken($color, 10%); + border-radius: unset; + color: darken($color, 10%); + } + } + &.is-inverted.is-outlined { border-color: rgba($color-invert, 0.5); color: rgba($color-invert, 0.9); @@ -238,6 +253,14 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); width: 100%; } +a.button.disabled { + color: $white; + background-color: $grey-dark; + opacity: 0.5; + border-color: transparent; + box-shadow: none; + cursor: default; +} .icon-button { background: transparent; padding: 0; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index fb0b5fef92..55d4350df0 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -326,7 +326,8 @@ fieldset.form-fieldset { border: none; } -.has-error-border { +.has-error-border, +select.has-error-border { border: 1px solid $red-500; } diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 27cb0d2440..ffb4c16a01 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -194,6 +194,31 @@ .has-top-margin-xxl { margin-top: $spacing-xxl; } +.has-left-margin-xxs { + margin-left: $spacing-xxs; +} +.has-left-margin-xs { + margin-left: $spacing-xs; +} +.has-left-margin-s { + margin-left: $spacing-s; +} +.has-left-margin-m { + margin-left: $spacing-m; +} +.has-left-margin-l { + margin-left: $spacing-l; +} +.has-left-margin-xl { + margin-left: $spacing-xl; +} +.has-right-margin-l { + margin-right: $spacing-l; +} +.has-border-top-light { + border-radius: 0; + border-top: 1px solid $grey-light; +} .has-border-bottom-light { border-radius: 0; border-bottom: 1px solid $grey-light; diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index 88c78ef528..0c6195e767 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -15,6 +15,9 @@ color: $black; text-decoration: none; } + .has-font-weight-normal { + font-weight: $font-weight-normal; + } } .form-section .title { diff --git a/ui/app/templates/components/keymgmt/distribute.hbs b/ui/app/templates/components/keymgmt/distribute.hbs new file mode 100644 index 0000000000..16716045a2 --- /dev/null +++ b/ui/app/templates/components/keymgmt/distribute.hbs @@ -0,0 +1,155 @@ +{{#if @backend}} +
+ {{#unless @key}} +
+ + {{#if (and this.validMatchError.key (not this.isNewKey))}} + + {{this.validMatchError.key}} + To check compatibility, + refer to this table. + + {{/if}} + +
+ {{/unless}} + + {{#if this.isNewKey}} +
+ +

The type of cryptographic key that will be created.

+
+
+ +
+ {{#if this.validMatchError.key}} + + {{this.validMatchError.key}} + To check compatibility, + refer to this table. + + {{/if}} +
+
+ {{/if}} + + {{#unless @provider}} +
+ +

Select a provider in Vault. If it doesn’t exist yet, you’ll need to add it first.

+
+
+ +
+
+ {{#if this.validMatchError.provider}} + + {{this.validMatchError.provider}} + To check compatibility, + refer to this table. + + {{/if}} +
+ {{/unless}} + +
+ Operations +

The types of operations this key can perform in the provider.

+ {{#each this.operations as |op|}} +
+ + +
+ {{/each}} +
+ +
+ Protection +

Specifies the protection of the key.

+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/keymgmt/key-edit.hbs b/ui/app/templates/components/keymgmt/key-edit.hbs new file mode 100644 index 0000000000..f09dc3aa0a --- /dev/null +++ b/ui/app/templates/components/keymgmt/key-edit.hbs @@ -0,0 +1,215 @@ + + + + + +

+ {{#if (eq @mode "create")}} + Create key + {{else if (eq @mode "edit")}} + Edit key + {{else}} + {{@model.id}} + {{/if}} +

+
+
+ +{{#if (eq this.mode "show")}} +
+ +
+ + + + + Remove key + +
+ + Rotate key + + + Edit key + +
+
+{{/if}} + +{{#if (eq this.mode "create")}} +
+ {{#each @model.createFields as |attr|}} + + {{/each}} + + +{{else if (eq this.mode "edit")}} +
+ {{#each @model.updateFields as |attr|}} + + {{/each}} + + +{{else if (eq @tab "versions")}} + {{#each @model.versions as |version|}} +
+
+
+ + Version {{version.id}} +
+
+ {{date-from-now version.creation_time addSuffix=true}} +
+
+ {{#if (eq @model.minEnabledVersion version.id)}} + + Current mininum enabled version + {{/if}} +
+
+
+ {{/each}} +{{else}} +
+

Key Details

+ {{#each @model.showFields as |attr|}} + + {{/each}} +
+
+

+ Distribution Details +

+ {{! TODO: Use capabilities to tell if it's not distributed vs no permissions }} + {{#if @model.provider.permissionsError}} + + {{else if @model.provider}} + + + {{@model.provider}} + + + {{#if @model.distribution}} + {{#each @model.distFields as |attr|}} + + {{/each}} + {{else}} + + {{/if}} + {{else}} + + {{! TODO: Distribute link + + Distribute + }} + + {{/if}} +
+{{/if}} + + +

+ Destroying the + {{@model.name}} + key means that the underlying data will be lost and the key will become unusable for cryptographic operations. It is + unrecoverable. +

+ +
\ No newline at end of file diff --git a/ui/app/templates/components/keymgmt/provider-edit.hbs b/ui/app/templates/components/keymgmt/provider-edit.hbs new file mode 100644 index 0000000000..0beee9ce98 --- /dev/null +++ b/ui/app/templates/components/keymgmt/provider-edit.hbs @@ -0,0 +1,190 @@ + + + + + +

+ {{#if this.isShowing}} + Provider + {{@model.id}} + {{else}} + {{if this.isCreating "Create provider" "Update credentials"}} + {{/if}} +

+
+
+ +{{#if this.isShowing}} +
+ +
+ {{#unless this.viewingKeys}} + + + + + + Delete provider + + + {{#if @model.keys.length}} + +
+ This provider cannot be deleted until all 20 keys distributed to it are revoked. This can be done from the + Keys tab. +
+
+ {{/if}} +
+
+ {{! Update once distribute route has been created }} + {{! + Distribute key + + }} + + Update credentials + +
+
+ {{/unless}} +{{else}} +
+
+ {{#if this.isCreating}} + {{#each @model.createFields as |attr index|}} + {{#if (eq index 2)}} +
+

+ Provider configuration +

+
+ {{/if}} + + {{/each}} + {{/if}} + {{#unless this.isCreating}} +

+ New credentials +

+

+ Old credentials cannot be read and will be lost as soon as new ones are added. Do this carefully. +

+ {{/unless}} + {{#each @model.credentialFields as |cred|}} + + {{/each}} +
+
+
+ +
+
+ + Cancel + +
+
+
+{{/if}} + +{{#if this.isShowing}} +
+ {{#if this.viewingKeys}} + {{#let (options-for-backend "keymgmt" "key") as |options|}} + {{#if @model.keys.meta.total}} + {{#each @model.keys as |key|}} + + {{/each}} + {{#if (gt @model.keys.meta.lastPage 1)}} + + {{/if}} + {{else}} + + + Create key + + + {{/if}} + {{/let}} + {{else}} + {{#each @model.showFields as |attr|}} + {{#if attr.hasBlock}} + + {{#if attr.icon}} + + {{/if}} + {{#if attr.isLink}} + + {{attr.value}} + + {{else}} + {{attr.value}} + {{/if}} + + {{else}} + + {{/if}} + {{/each}} + {{/if}} +
+{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs index 852c0be663..b6f003793f 100644 --- a/ui/app/templates/components/mount-backend-form.hbs +++ b/ui/app/templates/components/mount-backend-form.hbs @@ -46,8 +46,9 @@ @groupName="mount-type" @onRadioChange={{queue (action (mut this.mountModel.type)) (action "onTypeChange" "type")}} @disabled={{if type.requiredFeature (not (has-feature type.requiredFeature)) false}} + {{! TODO: verify that keymgmt is in the ADP module }} @tooltipMessage={{if - (or (eq type.type "transform") (eq type.type "kmip")) + (or (eq type.type "transform") (eq type.type "kmip") (eq type.type "keymgmt")) (concat type.displayName " is part of the Advanced Data Protection module, which is not included in your enterprise license." diff --git a/ui/app/templates/components/pagination-controls.hbs b/ui/app/templates/components/pagination-controls.hbs new file mode 100644 index 0000000000..d29300d150 --- /dev/null +++ b/ui/app/templates/components/pagination-controls.hbs @@ -0,0 +1,44 @@ +
+
+

+ {{this.displayInfo}} +

+
+
+ + {{#each this.pages as |page|}} + + {{/each}} + {{#if this.hasMorePages}} + ... + {{/if}} + +
+ {{! intentionally empty to place buttons in the middle }} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/secret-list-header.hbs b/ui/app/templates/components/secret-list-header.hbs index 0e1041d936..dbaeeaf43a 100644 --- a/ui/app/templates/components/secret-list-header.hbs +++ b/ui/app/templates/components/secret-list-header.hbs @@ -14,7 +14,7 @@

- + {{@model.id}} {{#if this.isKV}} diff --git a/ui/app/templates/components/secret-list/item.hbs b/ui/app/templates/components/secret-list/item.hbs index 2b9184bf0c..eac1994553 100644 --- a/ui/app/templates/components/secret-list/item.hbs +++ b/ui/app/templates/components/secret-list/item.hbs @@ -6,20 +6,20 @@ class="list-item-row" data-test-secret-link={{@item.id}} @encode={{true}} - @queryParams={{secret-query-params @backendModel.type}} + @queryParams={{secret-query-params @backendModel.type @item.type}} >
{{#if (eq @backendModel.type "transit")}} {{else}} - + {{/if}} {{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}} diff --git a/ui/app/templates/vault/cluster/secrets/backends.hbs b/ui/app/templates/vault/cluster/secrets/backends.hbs index 53a1fab8c3..f829155309 100644 --- a/ui/app/templates/vault/cluster/secrets/backends.hbs +++ b/ui/app/templates/vault/cluster/secrets/backends.hbs @@ -34,7 +34,7 @@ @accessor={{if (eq backend.options.version 2) (concat "v2 " backend.accessor) backend.accessor}} @description={{backend.description}} @glyphText={{backend.engineType}} - @glyph={{or (if (eq backend.engineType "kmip") "secrets" backend.engineType) "secrets"}} + @glyph={{backend.icon}} @link={{hash route=backendLink model=backend.id}} @title={{backend.path}} /> diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index a23437695f..f85dcae049 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { action, get } from '@ember/object'; +import { action } from '@ember/object'; import { capitalize } from 'vault/helpers/capitalize'; import { humanize } from 'vault/helpers/humanize'; import { dasherize } from 'vault/helpers/dasherize'; @@ -84,7 +84,7 @@ export default class FormFieldComponent extends Component { } get validationError() { const validations = this.args.modelValidations || {}; - const state = get(validations, this.valuePath); + const state = validations[this.valuePath]; return state && !state.isValid ? state.errors.join('. ') : null; } diff --git a/ui/lib/core/addon/components/info-table-row.hbs b/ui/lib/core/addon/components/info-table-row.hbs index 9b296d0727..da508d676e 100644 --- a/ui/lib/core/addon/components/info-table-row.hbs +++ b/ui/lib/core/addon/components/info-table-row.hbs @@ -47,6 +47,8 @@ {{else}} {{/if}} + {{else if @formatDate}} + {{date-format @value @formatDate}} {{else}} {{#if (eq @type "array")}} option.id)); + if (this.passObject) { + this.onChange(Array.from(this.selectedOptions, (option) => ({ id: option.id, isNew: !!option.new }))); + } else { + this.onChange(Array.from(this.selectedOptions, (option) => option.id)); + } } else { this.onChange(this.selectedOptions); } diff --git a/ui/lib/core/addon/helpers/has-feature.js b/ui/lib/core/addon/helpers/has-feature.js index 86e1be61a5..02afb86aef 100644 --- a/ui/lib/core/addon/helpers/has-feature.js +++ b/ui/lib/core/addon/helpers/has-feature.js @@ -15,6 +15,7 @@ const POSSIBLE_FEATURES = [ 'Namespaces', 'KMIP', 'Transform Secrets Engine', + 'Key Management Secrets Engine', ]; export function hasFeature(featureName, features) { diff --git a/ui/lib/core/addon/templates/components/alert-inline.hbs b/ui/lib/core/addon/templates/components/alert-inline.hbs index fcaa2af4b3..57ead04eaf 100644 --- a/ui/lib/core/addon/templates/components/alert-inline.hbs +++ b/ui/lib/core/addon/templates/components/alert-inline.hbs @@ -1,4 +1,8 @@

- {{@message}} + {{#if (has-block)}} + {{yield}} + {{else}} + {{@message}} + {{/if}}

\ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs index d0795af7da..dbbc5a666e 100644 --- a/ui/lib/core/addon/templates/components/search-select.hbs +++ b/ui/lib/core/addon/templates/components/search-select.hbs @@ -80,4 +80,7 @@ {{/each}} + {{#if (has-block)}} + {{yield}} + {{/if}} {{/if}} \ No newline at end of file diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js index 58b1357380..edf6bfb1a8 100644 --- a/ui/mirage/handlers/index.js +++ b/ui/mirage/handlers/index.js @@ -5,5 +5,6 @@ import mfa from './mfa'; import activity from './activity'; import clients from './clients'; import db from './db'; +import kms from './kms'; -export { base, activity, mfa, clients, db }; +export { base, activity, mfa, clients, db, kms }; diff --git a/ui/mirage/handlers/kms.js b/ui/mirage/handlers/kms.js new file mode 100644 index 0000000000..5bad5ede7a --- /dev/null +++ b/ui/mirage/handlers/kms.js @@ -0,0 +1,58 @@ +export default function (server) { + server.get('keymgmt/key?list=true', function () { + return { + data: { + keys: ['example-1', 'example-2', 'example-3'], + }, + }; + }); + + server.get('keymgmt/key/:name', function (_, request) { + let name = request.params.name; + return { + data: { + name, + deletion_allowed: false, + keys: { + 1: { + creation_time: '2020-11-02T15:54:58.768473-08:00', + public_key: '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----', + }, + 2: { + creation_time: '2020-11-04T16:58:47.591718-08:00', + public_key: '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----', + }, + }, + latest_version: 2, + min_enabled_version: 1, + type: 'rsa-2048', + }, + }; + }); + + server.get('keymgmt/key/:name/kms', function () { + return { + data: { + keys: ['example-kms'], + }, + }; + }); + + server.post('keymgmt/key/:name', function () { + return {}; + }); + + server.put('keymgmt/key/:name', function () { + return {}; + }); + + server.get('/keymgmt/kms/:provider/key', () => { + const keys = []; + let i = 1; + while (i <= 75) { + keys.push(`testkey-${i}`); + i++; + } + return { data: { keys } }; + }); +} diff --git a/ui/stories/pagination-controls.md b/ui/stories/pagination-controls.md new file mode 100644 index 0000000000..0d59633e95 --- /dev/null +++ b/ui/stories/pagination-controls.md @@ -0,0 +1,26 @@ + + +## PaginationControls +PaginationControls components are used to paginate through item lists + +**Params** + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| total | number | | total number of items | +| [startPage] | number | 1 | initial page number to select | +| [size] | number | 15 | number of items to display per page | +| onChange | function | | callback fired on page change | + +**Example** + +```js + +``` + +**See** + +- [Uses of PaginationControls](https://github.com/hashicorp/vault/search?l=Handlebars&q=PaginationControls+OR+pagination-controls) +- [PaginationControls Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/pagination-controls.js) + +--- diff --git a/ui/tests/integration/components/info-table-row-test.js b/ui/tests/integration/components/info-table-row-test.js index 55fcc71cd6..b0aded09d4 100644 --- a/ui/tests/integration/components/info-table-row-test.js +++ b/ui/tests/integration/components/info-table-row-test.js @@ -248,4 +248,16 @@ module('Integration | Component | InfoTableRow', function (hooks) { assert.dom('[data-test-foo-bar]').exists(); }); + + test('Formats the value as date when formatDate present', async function (assert) { + let yearString = new Date().getFullYear().toString(); + this.set('value', new Date()); + await render(hbs``); + + assert.dom('[data-test-value-div]').hasText(yearString, 'Renders date with passed format'); + }); }); diff --git a/ui/tests/integration/components/keymgmt/distribute-test.js b/ui/tests/integration/components/keymgmt/distribute-test.js new file mode 100644 index 0000000000..0220558f8f --- /dev/null +++ b/ui/tests/integration/components/keymgmt/distribute-test.js @@ -0,0 +1,152 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import Pretender from 'pretender'; +import { render, settled, select } from '@ember/test-helpers'; +import { create } from 'ember-cli-page-object'; +import { hbs } from 'ember-cli-htmlbars'; +import { typeInSearch, clickTrigger } from 'ember-power-select/test-support/helpers'; +import searchSelect from '../../../pages/components/search-select'; + +const SELECTORS = { + form: '[data-test-keymgmt-distribution-form]', + keySection: '[data-test-keymgmt-dist-key]', + keyTypeSection: '[data-test-keymgmt-dist-keytype]', + providerInput: '[data-test-keymgmt-dist-provider]', + operationsSection: '[data-test-keymgmt-dist-operations]', + protectionsSection: '[data-test-keymgmt-dist-protections]', + errorKey: '[data-test-keymgmt-error="key"]', + errorNewKey: '[data-test-keymgmt-error="new-key"]', + errorProvider: '[data-test-keymgmt-error="provider"]', + inlineError: '[data-test-keymgmt-error]', +}; + +const ssComponent = create(searchSelect); + +module('Integration | Component | keymgmt/distribute', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set('backend', 'keymgmt'); + this.set('providers', ['provider-aws', 'provider-gcp', 'provider-azure']); + this.server = new Pretender(function () { + this.get('/v1/keymgmt/key', (response) => { + return [ + response, + { 'Content-Type': 'application/json' }, + JSON.stringify({ + data: { + keys: ['example-1', 'example-2', 'example-3'], + }, + }), + ]; + }); + this.get('/v1/keymgmt/key/:name', (response) => { + const name = response.params.name; + return [ + response, + { 'Content-Type': 'application/json' }, + JSON.stringify({ + data: { + name, + type: 'aes256-gcm96', // incompatible with azurekeyvault only + }, + }), + ]; + }); + this.get('/v1/keymgmt/kms/:name', (response) => { + const name = response.params.name; + let provider; + switch (name) { + case 'provider-aws': + provider = 'awskms'; + break; + case 'provider-azure': + provider = 'azurekeyvault'; + break; + default: + provider = 'gcpckms'; + break; + } + return [ + response, + { 'Content-Type': 'application/json' }, + JSON.stringify({ + data: { + name, + provider, + }, + }), + ]; + }); + }); + }); + + hooks.afterEach(function () { + this.server.shutdown(); + }); + + test('it does not render without @backend attr', async function (assert) { + await render(hbs``); + assert.dom(SELECTORS.form).doesNotExist('Form does not exist'); + }); + + test('it does not allow operation selection until valid key and provider selected', async function (assert) { + await render(hbs``); + assert.dom(SELECTORS.operationsSection).hasAttribute('disabled'); + await clickTrigger(); + await settled(); + assert.equal(ssComponent.options.length, 3, 'shows all key options'); + await ssComponent.selectOption(); + await settled(); + assert.dom(SELECTORS.operationsSection).hasAttribute('disabled'); + await select(SELECTORS.providerInput, 'provider-aws'); + await settled(); + assert.dom(SELECTORS.operationsSection).doesNotHaveAttribute('disabled'); + await select(SELECTORS.providerInput, 'provider-azure'); + assert.dom(SELECTORS.operationsSection).hasAttribute('disabled'); + assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); + assert.dom(SELECTORS.errorProvider).exists('Shows key/provider match error on provider'); + }); + test('it shows key type select field if new key created', async function (assert) { + await render(hbs``); + assert.dom(SELECTORS.keyTypeSection).doesNotExist('Key Type section is not rendered by default'); + // Add new item on search-select + await clickTrigger(); + await settled(); + await typeInSearch('new-key'); + await ssComponent.selectOption(); + assert.dom(SELECTORS.keyTypeSection).exists('Key Type selector is shown'); + }); + test('it hides the provider field if passed from the parent', async function (assert) { + await render(hbs``); + assert.dom(SELECTORS.providerInput).doesNotExist('Provider input is hidden'); + // Select existing key + await clickTrigger(); + await settled(); + await ssComponent.selectOption(); + await settled(); + assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); + assert.dom(SELECTORS.errorKey).exists('Shows error on key selector when key/provider mismatch'); + // Remove selection + await ssComponent.deleteButtons.objectAt(0).click(); + await settled(); + // Select new key + await clickTrigger(); + await settled(); + await typeInSearch('new-key'); + await ssComponent.selectOption(); + await select(SELECTORS.keyTypeSection, 'ecdsa-p256'); + assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); + assert.dom(SELECTORS.errorNewKey).exists('Shows error on key type'); + }); + test('it hides the key field if passed from the parent', async function (assert) { + await render(hbs``); + assert.dom(SELECTORS.providerInput).exists('Provider input shown'); + assert.dom(SELECTORS.keySection).doesNotExist('Key input not shown'); + await select(SELECTORS.providerInput, 'provider-azure'); + assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); + assert.dom(SELECTORS.errorProvider).exists('Shows error due to key/provider mismatch'); + await select(SELECTORS.providerInput, 'provider-aws'); + assert.dom(SELECTORS.inlineError).doesNotExist('Error goes away when key/provider compatible'); + }); +}); diff --git a/ui/tests/integration/components/keymgmt/key-edit-test.js b/ui/tests/integration/components/keymgmt/key-edit-test.js new file mode 100644 index 0000000000..bb809bef45 --- /dev/null +++ b/ui/tests/integration/components/keymgmt/key-edit-test.js @@ -0,0 +1,74 @@ +import { module, test } from 'qunit'; +import EmberObject from '@ember/object'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | keymgmt/key-edit', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const now = new Date().toString(); + let model = EmberObject.create({ + name: 'Unicorns', + id: 'Unicorns', + minEnabledVersion: 1, + versions: [ + { + id: 1, + creation_time: now, + }, + { + id: 2, + creation_time: now, + }, + ], + }); + this.model = model; + this.tab = ''; + }); + + test('it renders show view as default', async function (assert) { + await render(hbs`