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>
This commit is contained in:
Jordan Reimer
2022-04-20 12:40:27 -06:00
committed by GitHub
parent cf3868669e
commit 9750dcaa7d
51 changed files with 2436 additions and 56 deletions

View File

@@ -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');
}
}

View File

@@ -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;
},
};
}
}

View File

@@ -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
* <KeymgmtDistribute @backend="keymgmt" @key="my-key" @provider="my-kms" />
* ```
* @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));
}
}

View File

@@ -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
* <KeymgmtKeyEdit @model={model} @mode="show" @tab="versions" />
* ```
* @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);
});
}
}

View File

@@ -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
* <KeymgmtProviderEdit @model={model} @mode="show" />
* ```
* @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('. '));
}
}
}

View File

@@ -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;
}),

View File

@@ -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
* <PaginationControls @startPage={{1}} @total={{100}} @size={{15}} @onChange={{this.onPageChange}} />
* ```
* @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);
}
}

View File

@@ -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 () {

View File

@@ -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 () {

View File

@@ -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);

View File

@@ -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',

View File

@@ -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,

View File

@@ -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);

5
ui/app/helpers/sub.js Normal file
View File

@@ -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);
});

View File

@@ -11,6 +11,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'transit',
'kmip',
'transform',
'keymgmt',
];
export function supportedSecretBackends() {

View File

@@ -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.' },
];
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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 () {

View File

@@ -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' });
}

View File

@@ -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];

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -6,3 +6,12 @@
text-decoration: underline !important;
}
}
.doc-link-subtle {
color: inherit;
text-decoration: underline;
font-weight: inherit;
&:hover {
color: inherit;
}
}

View File

@@ -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;

View File

@@ -326,7 +326,8 @@ fieldset.form-fieldset {
border: none;
}
.has-error-border {
.has-error-border,
select.has-error-border {
border: 1px solid $red-500;
}

View File

@@ -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;

View File

@@ -15,6 +15,9 @@
color: $black;
text-decoration: none;
}
.has-font-weight-normal {
font-weight: $font-weight-normal;
}
}
.form-section .title {

View File

@@ -0,0 +1,155 @@
{{#if @backend}}
<form {{on "submit" this.createDistribution}} class="form-section" data-test-keymgmt-distribution-form>
{{#unless @key}}
<div class="field" data-test-keymgmt-dist-key>
<SearchSelect
@id="key"
@models={{array "keymgmt/key"}}
@onChange={{this.handleKeySelect}}
@passObject={{true}}
@inputValue={{this.formData.key}}
@subText="Type to use the name of an existing key that youd like to add to this provider, or to create one."
@wildcardLabel="key"
@label=""
@subLabel="Key name"
@fallbackComponent="string-list"
@selectLimit="1"
@backend={{@backend}}
@disallowNewItems={{false}}
>
{{#if (and this.validMatchError.key (not this.isNewKey))}}
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="key">
{{this.validMatchError.key}}
To check compatibility,
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
</AlertInline>
{{/if}}
</SearchSelect>
</div>
{{/unless}}
{{#if this.isNewKey}}
<div class="field">
<label class="is-label" for="keyType">Key Type</label>
<p class="sub-text">The type of cryptographic key that will be created.</p>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="keyType"
id="keyType"
{{on "change" this.handleKeyType}}
class={{if this.validMatchError.key "has-error-border"}}
data-test-keymgmt-dist-keytype
>
<option value="">
Select one
</option>
{{#each this.keyTypes as |val|}}
<option selected={{eq this.keyType val}} value={{val}}>
{{val}}
</option>
{{/each}}
</select>
</div>
{{#if this.validMatchError.key}}
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="new-key">
{{this.validMatchError.key}}
To check compatibility,
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
</AlertInline>
{{/if}}
</div>
</div>
{{/if}}
{{#unless @provider}}
<div class="field">
<label class="is-label" for="provider">Provider</label>
<p class="sub-text">Select a provider in Vault. If it doesnt exist yet, youll need to add it first.</p>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="provider"
id="provider"
{{on "change" this.handleProvider}}
class={{if this.validMatchError.provider "has-error-border"}}
data-test-keymgmt-dist-provider
>
<option value="">
Select provider
</option>
{{#each @providers as |val|}}
<option selected={{eq @model.provider val}} value={{val}}>
{{val}}
</option>
{{/each}}
</select>
</div>
</div>
{{#if this.validMatchError.provider}}
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="provider">
{{this.validMatchError.provider}}
To check compatibility,
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
</AlertInline>
{{/if}}
</div>
{{/unless}}
<fieldset
class="field form-fieldset"
id="operations"
disabled={{this.disableOperations}}
data-test-keymgmt-dist-operations
>
<legend class="is-label">Operations</legend>
<p class="sub-text">The types of operations this key can perform in the provider.</p>
{{#each this.operations as |op|}}
<div class="b-checkbox">
<Input @type="checkbox" id={{op}} class="styled" @checked={{false}} {{on "input" this.handleOperation}} />
<label for={{op}}>{{capitalize op}}</label>
</div>
{{/each}}
</fieldset>
<fieldset class="field form-fieldset" id="protection" data-test-keymgmt-dist-protections>
<legend class="is-label">Protection</legend>
<p class="sub-text">Specifies the protection of the key.</p>
<div>
<RadioButton
id="protection-hsm"
name="hsm"
class="radio"
@value="hsm"
@groupValue={{this.formData.protection}}
@onChange={{fn (mut this.formData.protection)}}
/>
<label for="protection-hsm">HSM</label>
</div>
<div>
<RadioButton
id="protection-software"
name="software"
class="radio"
@value="software"
@groupValue={{this.formData.protection}}
@onChange={{fn (mut this.formData.protection)}}
/>
<label for="protection-software">Software</label>
</div>
</fieldset>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{or this.validationErrorCount this.error}}
class="button is-primary"
data-test-secret-save={{true}}
>
Save
</button>
</div>
</div>
</form>
{{/if}}

View File

@@ -0,0 +1,215 @@
<PageHeader as |p|>
<p.top>
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{this.mode}} @root={{@root}} @showCurrent={{true}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-secret-header="true">
{{#if (eq @mode "create")}}
Create key
{{else if (eq @mode "edit")}}
Edit key
{{else}}
{{@model.id}}
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if (eq this.mode "show")}}
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless" data-test-keymgmt-key-toolbar>
<nav class="tabs">
<ul>
<li class={{if (not-eq @tab "versions") "is-active"}}>
<LinkTo
@route="vault.cluster.secrets.backend.show"
@model={{@model.id}}
@query={{hash tab=""}}
data-test-tab="Details"
>
Details
</LinkTo>
</li>
<li class={{if (eq @tab "versions") "is-active"}}>
<LinkTo
@route="vault.cluster.secrets.backend.show"
@model={{@model.id}}
@query={{hash tab="versions"}}
data-test-tab="Versions"
>
Versions
</LinkTo>
</li>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarActions>
<button
type="button"
class="toolbar-link"
disabled={{not @model.deletionAllowed}}
{{on "click" (fn (mut this.isDeleteModalOpen) true)}}
data-test-keymgmt-key-destroy
>
Destroy key
</button>
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{fn this.removeKey @model.id}}
@confirmTitle="Remove this key?"
@confirmMessage="This will remove all versions of the key from the KMS provider. The key will stay in Vault."
@confirmButtonText="Remove"
data-test-keymgmt-key-remove
>
Remove key
</ConfirmAction>
<div class="toolbar-separator"></div>
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{fn this.rotateKey @model.id}}
@confirmTitle="Rotate this key?"
@confirmMessage="After rotation, all key actions will default to using the newest version of the key."
@confirmButtonText="Rotate"
data-test-keymgmt-key-rotate
>
Rotate key
</ConfirmAction>
<ToolbarSecretLink
@secret={{@model.id}}
@mode="edit"
@replace={{true}}
@queryParams={{query-params itemType="key"}}
@data-test-edit-link={{true}}
>
Edit key
</ToolbarSecretLink>
</ToolbarActions>
</Toolbar>
{{/if}}
{{#if (eq this.mode "create")}}
<form {{on "submit" this.createKey}}>
{{#each @model.createFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} />
{{/each}}
<input type="submit" value="Create key" />
</form>
{{else if (eq this.mode "edit")}}
<form {{on "submit" this.updateKey}}>
{{#each @model.updateFields as |attr|}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
{{/each}}
<input type="submit" value="Update" />
</form>
{{else if (eq @tab "versions")}}
{{#each @model.versions as |version|}}
<div class="list-item-row" data-test-keymgmt-key-version>
<div class="columns is-mobile">
<div class="column is-3 has-text-weight-bold">
<Icon @name="history" class="has-text-grey-light" />
<span>Version {{version.id}}</span>
</div>
<div class="column is-3 has-text-grey">
{{date-from-now version.creation_time addSuffix=true}}
</div>
<div class="column is-6 is-flex-center">
{{#if (eq @model.minEnabledVersion version.id)}}
<Icon @name="check-circle-fill" class="has-text-success" />
<span data-test-keymgmt-key-current-min>Current mininum enabled version</span>
{{/if}}
</div>
</div>
</div>
{{/each}}
{{else}}
<div class="has-top-margin-xl has-bottom-margin-s">
<h2 class="title has-border-bottom-light is-5">Key Details</h2>
{{#each @model.showFields as |attr|}}
<InfoTableRow
@alwaysRender={{true}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
@defaultShown={{attr.options.defaultShown}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
/>
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-s">
<h2 class="title has-border-bottom-light is-5 {{if @model.provider.permissionsError 'is-borderless is-marginless'}}">
Distribution Details
</h2>
{{! TODO: Use capabilities to tell if it's not distributed vs no permissions }}
{{#if @model.provider.permissionsError}}
<EmptyState
@title="You are not authorized"
@subTitle="Error 403"
@message={{concat
"You must be granted permissions to see whether this key is distributed. Ask your administrator if you think you should have access to LIST /"
@model.backend
"/key/"
@model.name
"/kms."
}}
@icon="minus-circle"
/>
{{else if @model.provider}}
<InfoTableRow @label="Distributed" @value={{@model.provider}}>
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{concat "kms/" @model.provider}}>
<Icon @name="check-circle-fill" class="has-text-success" />{{@model.provider}}
</LinkTo>
</InfoTableRow>
{{#if @model.distribution}}
{{#each @model.distFields as |attr|}}
<InfoTableRow
@alwaysRender={{true}}
@label={{capitalize (or attr.label (humanize (dasherize attr.name)))}}
@value={{if
(eq attr.name "protection")
(uppercase (get @model.distribution attr.name))
(get @model.distribution attr.name)
}}
@defaultShown={{attr.defaultShown}}
@helperText={{attr.subText}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
/>
{{/each}}
{{else}}
<EmptyState
@title="You are not authorized"
@subTitle="Error 403"
@message="You must be granted permissions to view distribution details for this key. Ask your administrator if you think you should have access to GET /keymgmt/keymgmt/key/example."
@icon="minus-circle"
/>
{{/if}}
{{else}}
<EmptyState
@title="Key not distributed"
@message="When this key is distributed to a destination, those details will appear here."
data-test-keymgmt-dist-empty-state
>
{{! TODO: Distribute link
<LinkTo @route="vault.cluster.secrets.backend.distribute">
Distribute
</LinkTo> }}
</EmptyState>
{{/if}}
</div>
{{/if}}
<ConfirmationModal
@title="Destroy key?"
@onClose={{fn (mut this.isDeleteModalOpen) false}}
@isActive={{this.isDeleteModalOpen}}
@confirmText={{@model.name}}
@toConfirmMsg="deleting the key"
@onConfirm={{fn this.deleteKey @model.id}}
@testSelector="delete"
>
<p>
Destroying the
<strong>{{@model.name}}</strong>
key means that the underlying data will be lost and the key will become unusable for cryptographic operations. It is
unrecoverable.
</p>
<MessageError @model={{this.model}} @errorMessage={{this.error}} />
</ConfirmationModal>

View File

@@ -0,0 +1,190 @@
<PageHeader as |p|>
<p.top>
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{@mode}} @root={{@root}} @showCurrent={{true}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-kms-provider-header>
{{#if this.isShowing}}
Provider
<span class="has-font-weight-normal">{{@model.id}}</span>
{{else}}
{{if this.isCreating "Create provider" "Update credentials"}}
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if this.isShowing}}
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs">
<ul>
<li class={{unless this.viewingKeys "is-active"}} data-test-kms-provider-tab="details">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab=""}}>
Details
</LinkTo>
</li>
<li class={{if this.viewingKeys "is-active"}} data-test-kms-provider-tab="keys">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
Keys
</LinkTo>
</li>
</ul>
</nav>
</div>
{{#unless this.viewingKeys}}
<Toolbar data-test-kms-provider-details-actions>
<ToolbarActions>
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
<T.Trigger data-test-tooltip-trigger>
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{this.onDelete}}
@disabled={{@model.keys.length}}
data-test-kms-provider-delete={{true}}
>
Delete provider
</ConfirmAction>
</T.Trigger>
{{#if @model.keys.length}}
<T.Content class="tool-tip">
<div class="box" data-test-kms-provider-delete-tooltip>
This provider cannot be deleted until all 20 keys distributed to it are revoked. This can be done from the
Keys tab.
</div>
</T.Content>
{{/if}}
</ToolTip>
<div class="toolbar-separator"></div>
{{! Update once distribute route has been created }}
{{! <LinkTo @route="vault.cluster.secrets.backend.kms-distribute">
Distribute key
<Icon @name="chevron-right" />
</LinkTo> }}
<ToolbarSecretLink
@secret={{@model.id}}
@mode="edit"
@replace={{true}}
@queryParams={{query-params itemType="provider"}}
>
Update credentials
</ToolbarSecretLink>
</ToolbarActions>
</Toolbar>
{{/unless}}
{{else}}
<form aria-label="update credentials" {{on "submit" this.onSave}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{#if this.isCreating}}
{{#each @model.createFields as |attr index|}}
{{#if (eq index 2)}}
<div class="has-border-top-light">
<h2 class="title is-5 has-top-margin-l has-bottom-margin-m" data-test-kms-provider-config-title>
Provider configuration
</h2>
</div>
{{/if}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
{{/if}}
{{#unless this.isCreating}}
<h2 class="title is-5" data-test-kms-provider-creds-title>
New credentials
</h2>
<p class="sub-text has-bottom-margin-m">
Old credentials cannot be read and will be lost as soon as new ones are added. Do this carefully.
</p>
{{/unless}}
{{#each @model.credentialFields as |cred|}}
<FormField @attr={{cred}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{this.saveTask.isRunning}}
class="button is-primary {{if this.saveTask.isRunning 'is-loading'}}"
data-test-kms-provider-submit
>
{{if this.isCreating "Create provider" "Update"}}
</button>
</div>
<div class="control">
<LinkTo
@route={{if this.isCreating @root.path "vault.cluster.secrets.backend.show"}}
@model={{if this.isCreating @root.model @model.id}}
@query={{if this.isCreating (hash tab="provider") (hash itemType="provider")}}
@disabled={{this.saveTask.isRunning}}
class="button"
data-test-kms-provider-cancel
>
Cancel
</LinkTo>
</div>
</div>
</form>
{{/if}}
{{#if this.isShowing}}
<div class="has-bottom-margin-s">
{{#if this.viewingKeys}}
{{#let (options-for-backend "keymgmt" "key") as |options|}}
{{#if @model.keys.meta.total}}
{{#each @model.keys as |key|}}
<SecretList::Item
@item={{key}}
@backendModel={{@root}}
@backendType="keymgmt"
@delete={{fn this.onDeleteKey key}}
@itemPath={{concat options.modelPrefix key.id}}
@itemType={{options.item}}
@modelType={{@modelType}}
@options={{options}}
/>
{{/each}}
{{#if (gt @model.keys.meta.lastPage 1)}}
<PaginationControls
@total={{@model.keys.meta.total}}
@onChange={{perform this.fetchKeys}}
class="has-top-margin-xl has-bottom-margin-l"
/>
{{/if}}
{{else}}
<EmptyState
@title="No keys for this provider"
@message="Keys for this provider will be listed here. Add a key to get started."
>
<SecretLink @mode="create" @secret="" @queryParams={{query-params itemType="key"}} @class="link">
Create key
</SecretLink>
</EmptyState>
{{/if}}
{{/let}}
{{else}}
{{#each @model.showFields as |attr|}}
{{#if attr.hasBlock}}
<InfoTableRow @label={{attr.label}} @value={{attr.value}} data-test-kms-provider-field={{attr.name}}>
{{#if attr.icon}}
<Icon @name={{attr.icon}} class="icon" />
{{/if}}
{{#if attr.isLink}}
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
{{attr.value}}
</LinkTo>
{{else}}
{{attr.value}}
{{/if}}
</InfoTableRow>
{{else}}
<InfoTableRow
@alwaysRender={{true}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
@defaultShown={{attr.options.defaultShown}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
/>
{{/if}}
{{/each}}
{{/if}}
</div>
{{/if}}

View File

@@ -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."

View File

@@ -0,0 +1,44 @@
<div class="is-flex-between is-flex-center" ...attributes>
<div class="is-fullwidth is-flex-center">
<p class="has-text-grey has-left-margin-l" data-test-page-display-info>
{{this.displayInfo}}
</p>
</div>
<div class="is-fullwidth is-flex-v-centered">
<button
type="button"
class="button is-flat has-short-padding"
disabled={{eq this.page 1}}
data-test-previous-page
{{on "click" (fn this.changePage (sub this.page 1))}}
>
<Icon @name="chevron-left" />
Previous
</button>
{{#each this.pages as |page|}}
<button
type="button"
class="button is-flat has-left-margin-xxs {{if (eq this.page page) 'is-primary is-underlined is-active'}}"
data-test-page={{page}}
{{on "click" (fn this.changePage page)}}
>
{{page}}
</button>
{{/each}}
{{#if this.hasMorePages}}
<span class="has-text-grey has-left-margin-m" data-test-more-pages>...</span>
{{/if}}
<button
type="button"
class="button is-flat has-short-padding has-left-margin-l"
disabled={{eq this.page this.totalPages}}
data-test-next-page
{{on "click" (fn this.changePage (add this.page 1))}}
>
Next
<Icon @name="chevron-right" />
</button>
</div>
{{! intentionally empty to place buttons in the middle }}
<div class="is-fullwidth"></div>
</div>

View File

@@ -14,7 +14,7 @@
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name={{or @model.engineType "secrets"}} @size="24" class="has-text-grey-light" />
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
{{@model.id}}
{{#if this.isKV}}
<span class="tag" data-test-kv-version-badge>

View File

@@ -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}}
>
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode={{if @item.isFolder "list" "show"}}
@secret={{@item.id}}
@queryParams={{if (eq @backendModel.type "transit") (query-params tab="actions") ""}}
@queryParams={{secret-query-params @backendModel.type @item.type asQueryParams=true}}
class="has-text-black has-text-weight-semibold"
>
{{#if (eq @backendModel.type "transit")}}
<Icon @name="key" class="has-text-grey-light" />
{{else}}
<Icon @name={{if @item.isFolder "folder" "file"}} class="has-text-grey-light" />
<Icon @name={{if @item.isFolder "folder" (or @item.icon "file")}} class="has-text-grey-light" />
{{/if}}
{{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}}
</SecretLink>

View File

@@ -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}}
/>

View File

@@ -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;
}

View File

@@ -47,6 +47,8 @@
{{else}}
<Icon @name="minus" />
{{/if}}
{{else if @formatDate}}
{{date-format @value @formatDate}}
{{else}}
{{#if (eq @type "array")}}
<InfoTableItemArray

View File

@@ -21,6 +21,7 @@ import layout from '../templates/components/search-select';
* @param {string} fallbackComponent - name of component to be rendered if the API call 403s
* @param {string} [backend] - name of the backend if the query for options needs additional information (eg. secret backend)
* @param {boolean} [disallowNewItems=false] - Controls whether or not the user can add a new item if none found
* @param {boolean} [passObject=false] - When true, the onChange callback returns an array of objects with id (string) and isNew (boolean)
* @param {string} [helpText] - Text to be displayed in the info tooltip for this form field
* @param {number} [selectLimit] - A number that sets the limit to how many select options they can choose
* @param {string} [subText] - Text to be displayed below the label
@@ -54,6 +55,7 @@ export default Component.extend({
shouldUseFallback: false,
shouldRenderName: false,
disallowNewItems: false,
passObject: false,
init() {
this._super(...arguments);
@@ -133,7 +135,11 @@ export default Component.extend({
}).on('didInsertElement'),
handleChange() {
if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') {
this.onChange(Array.from(this.selectedOptions, (option) => 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);
}

View File

@@ -15,6 +15,7 @@ const POSSIBLE_FEATURES = [
'Namespaces',
'KMIP',
'Transform Secrets Engine',
'Key Management Secrets Engine',
];
export function hasFeature(featureName, features) {

View File

@@ -1,4 +1,8 @@
<Icon @name={{this.alertType.glyph}} class={{this.alertType.glyphClass}} />
<p class={{this.textClass}} data-test-inline-error-message>
{{@message}}
{{#if (has-block)}}
{{yield}}
{{else}}
{{@message}}
{{/if}}
</p>

View File

@@ -80,4 +80,7 @@
</li>
{{/each}}
</ul>
{{#if (has-block)}}
{{yield}}
{{/if}}
{{/if}}

View File

@@ -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 };

58
ui/mirage/handlers/kms.js Normal file
View File

@@ -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 } };
});
}

View File

@@ -0,0 +1,26 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/pagination-controls.js. To make changes, first edit that file and run "yarn gen-story-md pagination-controls" to re-generate the content.-->
## PaginationControls
PaginationControls components are used to paginate through item lists
**Params**
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| total | <code>number</code> | | total number of items |
| [startPage] | <code>number</code> | <code>1</code> | initial page number to select |
| [size] | <code>number</code> | <code>15</code> | number of items to display per page |
| onChange | <code>function</code> | | callback fired on page change |
**Example**
```js
<PaginationControls @startPage={{1}} @total={{100}} @size={{15}} @onChange={{this.onPageChange}} />
```
**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)
---

View File

@@ -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`<InfoTableRow
@label={{this.label}}
@value={{this.value}}
@formatDate={{'yyyy'}}
/>`);
assert.dom('[data-test-value-div]').hasText(yearString, 'Renders date with passed format');
});
});

View File

@@ -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`<Keymgmt::Distribute />`);
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} />`);
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} />`);
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`<Keymgmt::Distribute @backend="keymgmt" @provider="provider-azure" />`);
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @key="example-1" />`);
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');
});
});

View File

@@ -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`<Keymgmt::KeyEdit @model={{model}} @tab={{tab}} /><div id="modal-wormhole" />`);
assert.dom('[data-test-secret-header]').hasText('Unicorns', 'Shows key name');
assert.dom('[data-test-keymgmt-key-toolbar]').exists('Subnav toolbar exists');
// TODO: Add capabilities tests
assert.dom('[data-test-tab="Details"]').exists('Details tab exists');
assert.dom('[data-test-tab="Versions"]').exists('Versions tab exists');
assert.dom('[data-test-keymgmt-key-destroy]').isDisabled('Destroy button is disabled');
assert.dom('[data-test-keymgmt-dist-empty-state]').exists('Distribution empty state exists');
this.set('tab', 'versions');
assert.dom('[data-test-keymgmt-key-version]').exists({ count: 2 }, 'Renders two version list items');
assert
.dom('[data-test-keymgmt-key-current-min]')
.exists({ count: 1 }, 'Checks only one as current minimum');
});
test('it renders the correct elements on edit view', async function (assert) {
let model = EmberObject.create({
name: 'Unicorns',
id: 'Unicorns',
});
this.set('mode', 'edit');
this.set('model', model);
await render(hbs`<Keymgmt::KeyEdit @model={{model}} @mode={{mode}} /><div id="modal-wormhole" />`);
assert.dom('[data-test-secret-header]').hasText('Edit key', 'Shows edit header');
assert.dom('[data-test-keymgmt-key-toolbar]').doesNotExist('Subnav toolbar does not exist');
assert.dom('[data-test-tab="Details"]').doesNotExist('Details tab does not exist');
assert.dom('[data-test-tab="Versions"]').doesNotExist('Versions tab does not exist');
});
test('it renders the correct elements on create view', async function (assert) {
let model = EmberObject.create({});
this.set('mode', 'create');
this.set('model', model);
await render(hbs`<Keymgmt::KeyEdit @model={{model}} @mode={{mode}} /><div id="modal-wormhole" />`);
assert.dom('[data-test-secret-header]').hasText('Create key', 'Shows edit header');
assert.dom('[data-test-keymgmt-key-toolbar]').doesNotExist('Subnav toolbar does not exist');
assert.dom('[data-test-tab="Details"]').doesNotExist('Details tab does not exist');
assert.dom('[data-test-tab="Versions"]').doesNotExist('Versions tab does not exist');
});
});

View File

@@ -0,0 +1,209 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, triggerEvent, settled, fillIn } from '@ember/test-helpers';
import { format } from 'date-fns';
const ts = 'data-test-kms-provider';
const root = {
path: 'vault.cluster.secrets.backend.list-root',
model: 'keymgmt',
label: 'keymgmt',
text: 'keymgmt',
};
module('Integration | Component | keymgmt/provider-edit', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.store.push({
data: {
id: 'foo-bar',
type: 'keymgmt/provider',
attributes: {
name: 'foo-bar',
provider: 'azurekeyvault',
keyCollection: 'keyvault-1',
created: new Date(),
},
},
});
this.model = this.store.peekRecord('keymgmt/provider', 'foo-bar');
this.created = format(this.model.created, 'MMM d yyyy, h:mm:ss aaa');
this.root = root;
this.owner.lookup('service:router').reopen({
currentURL: '/ui/vault/secrets/keymgmt/show/foo-bar',
currentRouteName: 'secrets.keymgmt.provider.show',
urlFor() {
return '';
},
});
});
test('it should render show view', async function (assert) {
assert.expect(13);
this.server.get('/keymgmt/kms/foo-bar/key', () => {
return {
data: {
keys: ['testkey-1', 'testkey-2'],
},
};
});
this.server.delete('/keymgmt/kms/foo-bar', () => {
assert.ok(true, 'Request made to delete key');
return {};
});
this.owner.lookup('service:router').reopen({
transitionTo(path, model, { queryParams: { tab } }) {
assert.equal(path, root.path, 'Root path sent in transitionTo on delete');
assert.equal(model, root.model, 'Root model sent in transitionTo on delete');
assert.deepEqual(tab, 'provider', 'Correct query params sent in transitionTo on delete');
},
});
const changeTab = async (tab) => {
this.set('tab', tab);
await settled();
};
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@mode="show"
@tab={{this.tab}}
/>`);
assert.dom(`[${ts}-header]`).hasText('Provider foo-bar', 'Page header renders');
assert.dom(`[${ts}-tab="details"]`).hasClass('is-active', 'Details tab is active');
const infoRows = this.element.querySelectorAll('[data-test-component="info-table-row"]');
assert.dom(infoRows[0]).hasText('Provider name foo-bar', 'Provider name field renders');
assert.dom(infoRows[1]).hasText('Type Azure Key Vault', 'Type field renders');
assert.dom('svg', infoRows[1]).hasAttribute('data-test-icon', 'azure-color', 'Icon renders for type');
assert.dom(infoRows[2]).hasText(`Created ${this.created}`, 'Created field renders');
assert.dom(infoRows[3]).hasText('Key Vault instance name keyvault-1', 'Key collection field renders');
assert.dom(infoRows[4]).hasText('Keys 2 keys', 'Keys field renders');
await changeTab('keys');
assert.dom(`[${ts}-details-actions]`).doesNotExist('Toolbar is hidden on keys tab');
assert.dom('[data-test-secret-link]').exists({ count: 2 }, 'Keys list renders');
await changeTab('details');
assert.dom(`[${ts}-delete] button`).isDisabled('Delete action disabled when keys exist');
await triggerEvent(`[data-test-tooltip-trigger]`, 'mouseenter');
assert.dom(`[${ts}-delete-tooltip]`).exists('Tooltip is show when delete action is disabled');
this.model.keys = [];
await settled();
assert
.dom('[data-test-value-div="Keys"]')
.hasText('None', 'None is displayed when no keys exist for provider');
await click(`[${ts}-delete] button`);
await click('[data-test-confirm-button]');
});
test('it should render create view', async function (assert) {
assert.expect(10);
this.server.put('/keymgmt/kms/foo', (schema, req) => {
const params = {
name: 'foo',
provider: 'gcpckms',
key_collection: 'keyvault-1',
credentials: {
service_account_file: 'test',
},
};
assert.deepEqual(JSON.parse(req.requestBody), params, 'PUT request made with correct data');
return {};
});
this.owner.lookup('service:router').reopen({
transitionTo(path, model, { queryParams: { itemType } }) {
assert.equal(path, 'vault.cluster.secrets.backend.show', 'Show route sent in transitionTo on save');
assert.equal(model, 'foo', 'Model id sent in transitionTo on save');
assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
},
});
this.model = this.store.createRecord('keymgmt/provider');
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@mode="create"
/>`);
assert.dom(`[${ts}-header]`).hasText('Create provider', 'Page header renders');
assert.dom(`[${ts}-config-title]`).exists('Config header shown in create mode');
assert.dom(`[${ts}-creds-title]`).doesNotExist('New credentials header hidden in create mode');
await click(`[${ts}-submit]`);
assert
.dom('[data-test-inline-error-message]')
.exists({ count: 5 }, 'Required fields are shown on validation');
['client_id', 'client_secret', 'tenant_id'].forEach((prop) => {
assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`Azure ${prop} field renders`);
});
await fillIn('[data-test-input="provider"]', 'awskms');
['access_key', 'secret_key'].forEach((prop) => {
assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`AWS ${prop} field renders`);
});
await fillIn('[data-test-input="provider"]', 'gcpckms');
assert.dom(`[data-test-input="credentials.service_account_file"]`).exists(`GCP cred field renders`);
await fillIn('[data-test-input="name"]', 'foo');
await fillIn('[data-test-input="keyCollection"]', 'keyvault-1');
await fillIn('[data-test-input="credentials.service_account_file"]', 'test');
await click(`[${ts}-submit]`);
});
test('it should render edit view', async function (assert) {
assert.expect(3);
this.server.put('/keymgmt/kms/foo', (schema, req) => {
const params = {
name: 'foo-bar',
provider: 'azurekeyvault',
key_collection: 'keyvault-1',
credentials: {
client_id: 'client_id test',
client_secret: 'client_secret test',
tenant_id: 'tenant_id test',
},
};
assert.deepEqual(JSON.parse(req.requestBody), params, 'PUT request made with correct data');
return {};
});
this.owner.lookup('service:router').reopen({
transitionTo(path, model, { queryParams: { itemType } }) {
assert.equal(path, 'vault.cluster.secrets.backend.show', 'Show route sent in transitionTo on save');
assert.equal(model, 'foo', 'Model id sent in transitionTo on save');
assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
},
});
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@mode="edit"
/>`);
assert.dom(`[${ts}-header]`).hasText('Update credentials', 'Page header renders');
assert.dom(`[${ts}-config-title]`).doesNotExist('Config header hidden in edit mode');
assert.dom(`[${ts}-creds-title]`).exists('New credentials header shown in edit mode');
for (const prop of ['client_id', 'client_secret', 'tenant_id']) {
await fillIn(`[data-test-input="credentials.${prop}"]`, `${prop} test`);
}
await click(`[${ts}-submit]`);
});
});

View File

@@ -0,0 +1,74 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { click } from '@ember/test-helpers';
module('Integration | Component | pagination-controls', function (hooks) {
setupRenderingTest(hooks);
test('it renders correct number of pages', async function (assert) {
const totals = [
[10, 1],
[40, 3],
[100, 5],
];
for (const [total, count] of totals) {
this.total = total;
await render(hbs`<PaginationControls @total={{this.total}} />`);
assert
.dom('[data-test-page]')
.exists({ count }, `Correct page count of ${count} renders for ${total} total items`);
assert.dom('[data-test-more-pages]')[count === 5 ? 'exists' : 'doesNotExist']();
}
});
test('it changes pages', async function (assert) {
assert.expect(10);
let expectedPage = 2;
this.onChange = (page) => {
assert.equal(page, expectedPage, 'onChange callback is fired with correct page number');
};
await render(hbs`<PaginationControls @total={{75}} @onChange={{this.onChange}} />`);
const isActive = (page) => {
return this.element
.querySelector(`[data-test-page="${page}"]`)
.classList.value.includes('is-primary is-underlined is-active');
};
assert.ok(isActive(1), 'Page 1 is active by default');
assert.dom('[data-test-previous-page]').isDisabled('Previous page button is disabled on page 1');
await click('[data-test-next-page]');
assert.ok(isActive(2), 'Page 2 is active');
assert.dom('[data-test-previous-page]').isNotDisabled('Previous page button is disabled on page 1');
expectedPage = 5;
await click('[data-test-page="5"]');
assert.ok(isActive(5), 'Page 5 is active');
assert.dom('[data-test-next-page]').isDisabled('Next page button is disabled on last page');
expectedPage = 4;
await click('[data-test-previous-page]');
assert.ok(isActive(4), 'Page 4 is active');
});
test('it renders correct display info', async function (assert) {
this.onChange = () => {};
await render(hbs`<PaginationControls @total={{68}} @onChange={{this.onChange}} />`);
const ranges = ['1-15', '16-30', '31-45', '46-60', '61-68'];
for (const [i, range] of ranges.entries()) {
assert
.dom('[data-test-page-display-info]')
.hasText(`${range} of 68`, `Correct display info renders for page ${i + 1}`);
if (i < 4) {
await click(`[data-test-next-page]`);
}
}
});
});

View File

@@ -297,4 +297,36 @@ module('Integration | Component | search select', function (hooks) {
let err = await promise;
assert.ok(err.message.includes('internal server error'), 'it throws an internal server error');
});
test('it returns array with objects instead of strings if passObject=true', async function (assert) {
const models = ['identity/entity'];
this.set('models', models);
this.set('onChange', sinon.spy());
this.set('passObject', true);
await render(hbs`{{search-select label="foo" models=models onChange=onChange passObject=passObject}}`);
await clickTrigger();
await settled();
// First select existing option
await component.selectOption();
assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
assert.ok(this.onChange.calledOnce);
assert.ok(
this.onChange.calledWith([{ id: '7', isNew: false }]),
'onClick is called with array of single object with isNew false'
);
// Then create a new item and select it
await clickTrigger();
await settled();
await typeInSearch('newItem');
await component.selectOption();
await settled();
assert.ok(
this.onChange.calledWith([
{ id: '7', isNew: false },
{ id: 'newItem', isNew: true },
]),
'onClick is called with array of objects with isNew true on new item'
);
});
});