UI: keymgmt secret engine (#15523)

* No default provider on create, add subText to service_account_file field

* Show empty state if no provider selected -- sorry for all the conditionals

* Button and distribution title styling on key edit

* Fix key distribute empty state permissions

* Don't try to fetch distribution if provider is permissionError

* Use search-select component for provider on distribute component

* Show distribution form errors on page rather than popup

* Add id, label, subtext to input-search for search-select fallback

* Remove created field from provider, default to querying for keys unless capabilities is false

* Fix link to provider from key-edit

* Search select label styling and add subText to fallback

* Refetch model after key rotate

* Create distribution method is task so we can load and disable button

* Move keymgmt to cloud group on mount options

* Key actions are tasks, fix tab active class

* Add isRunning attr to confirm-action which disables confirm button and replaces text with loader

* Fix provider active tab class

* Handle control groups on distribution

* Correctly handle error message on key-edit

* Show loading state on distribute, reload key after distribute

* Clear old validation errors if valid

* Fix tests

* Fix delete url

* Add changelog

* Address PR comments

* kick circle-ci

* Format go file breaking fmt

* Rename old changelog

* Remove resolved TODO
This commit is contained in:
Chelsea Shaw 2022-05-20 10:41:24 -05:00 committed by GitHub
parent 892d4d1e37
commit 81105e6209
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 238 additions and 135 deletions

3
changelog/15523.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
**KeyMgmt UI**: Add UI support for managing the Key Management Secrets Engine
```

View File

@ -1,5 +1,6 @@
import ApplicationAdapter from '../application'; import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers'; import { encodePath } from 'vault/utils/path-encoding-helpers';
import ControlGroupError from '../../lib/control-group-error';
function pickKeys(obj, picklist) { function pickKeys(obj, picklist) {
const data = {}; const data = {};
@ -13,6 +14,21 @@ function pickKeys(obj, picklist) {
export default class KeymgmtKeyAdapter extends ApplicationAdapter { export default class KeymgmtKeyAdapter extends ApplicationAdapter {
namespace = 'v1'; namespace = 'v1';
pathForType() {
// backend name prepended in buildURL method
return 'key';
}
buildURL(modelName, id, snapshot, requestType, query) {
let url = super.buildURL(...arguments);
if (snapshot) {
url = url.replace('key', `${snapshot.attr('backend')}/key`);
} else if (query) {
url = url.replace('key', `${query.backend}/key`);
}
return url;
}
url(backend, id, type) { url(backend, id, type) {
const url = `${this.buildURL()}/${backend}/key`; const url = `${this.buildURL()}/${backend}/key`;
if (id) { if (id) {
@ -26,12 +42,6 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
return url; 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) { _updateKey(backend, name, serialized) {
// Only these two attributes are allowed to be updated // Only these two attributes are allowed to be updated
let data = pickKeys(serialized, ['deletion_allowed', 'min_enabled_version']); let data = pickKeys(serialized, ['deletion_allowed', 'min_enabled_version']);
@ -53,8 +63,7 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
if (snapshot.attr('deletionAllowed')) { if (snapshot.attr('deletionAllowed')) {
try { try {
await this._updateKey(backend, name, data); await this._updateKey(backend, name, data);
} catch (e) { } catch {
// TODO: Test how this works with UI
throw new Error(`Key ${name} was created, but not all settings were saved`); throw new Error(`Key ${name} was created, but not all settings were saved`);
} }
} }
@ -95,7 +104,6 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
} else if (e.httpStatus === 403) { } else if (e.httpStatus === 403) {
return { permissionsError: true }; return { permissionsError: true };
} }
// TODO: handle control group
throw e; throw e;
} }
} }
@ -109,8 +117,10 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
purposeArray: res.data.purpose.split(','), purposeArray: res.data.purpose.split(','),
}; };
}) })
.catch(() => { .catch((e) => {
// TODO: handle control group if (e instanceof ControlGroupError) {
throw e;
}
return null; return null;
}); });
} }
@ -123,7 +133,7 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
let provider, distribution; let provider, distribution;
if (!recordOnly) { if (!recordOnly) {
provider = await this.getProvider(backend, id); provider = await this.getProvider(backend, id);
if (provider) { if (provider && !provider.permissionsError) {
distribution = await this.getDistribution(backend, provider, id); distribution = await this.getDistribution(backend, provider, id);
} }
} }
@ -145,13 +155,15 @@ export default class KeymgmtKeyAdapter extends ApplicationAdapter {
}); });
} }
rotateKey(backend, id) { async rotateKey(backend, id) {
// TODO: re-fetch record data after let keyModel = this.store.peekRecord('keymgmt/key', id);
return this.ajax(this.url(backend, id, 'ROTATE'), 'PUT'); const result = await this.ajax(this.url(backend, id, 'ROTATE'), 'PUT');
await keyModel.reload();
return result;
} }
removeFromProvider(model) { removeFromProvider(model) {
const url = `${this.buildURL()}/${model.backend}/kms/${model.provider.name}/key/${model.name}`; const url = `${this.buildURL()}/${model.backend}/kms/${model.provider}/key/${model.name}`;
return this.ajax(url, 'DELETE').then(() => { return this.ajax(url, 'DELETE').then(() => {
model.provider = null; model.provider = null;
}); });

View File

@ -3,6 +3,8 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { KEY_TYPES } from '../../models/keymgmt/key'; import { KEY_TYPES } from '../../models/keymgmt/key';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
/** /**
* @module KeymgmtDistribute * @module KeymgmtDistribute
@ -39,6 +41,7 @@ export default class KeymgmtDistribute extends Component {
@tracked isNewKey = false; @tracked isNewKey = false;
@tracked providerType; @tracked providerType;
@tracked formData; @tracked formData;
@tracked formErrors;
constructor() { constructor() {
super(...arguments); super(...arguments);
@ -196,15 +199,20 @@ export default class KeymgmtDistribute extends Component {
this.args.onClose(); this.args.onClose();
}) })
.catch((e) => { .catch((e) => {
this.flashMessages.danger(`Error distributing key: ${e.errors}`); this.formErrors = `${e.errors}`;
}); });
} }
@action @action
handleProvider(evt) { handleProvider(selection) {
this.formData.provider = evt.target.value; let providerName = selection[0];
if (evt.target.value) { if (typeof selection === 'string') {
this.getProviderType(evt.target.value); // Handles case if no list permissions and fallback component is used
providerName = selection;
}
this.formData.provider = providerName;
if (providerName) {
this.getProviderType(providerName);
} }
} }
@action @action
@ -235,8 +243,9 @@ export default class KeymgmtDistribute extends Component {
return this.getKeyInfo(selectedKey.id, selectedKey.isNew); return this.getKeyInfo(selectedKey.id, selectedKey.isNew);
} }
@action @task
async createDistribution(evt) { @waitFor
*createDistribution(evt) {
evt.preventDefault(); evt.preventDefault();
const { backend } = this.args; const { backend } = this.args;
const data = this.formatData(this.formData); const data = this.formatData(this.formData);
@ -246,12 +255,18 @@ export default class KeymgmtDistribute extends Component {
} }
if (this.isNewKey) { if (this.isNewKey) {
try { try {
await this.keyModel.save(); yield this.keyModel.save();
this.flashMessages.success(`Successfully created key ${this.keyModel.name}`); this.flashMessages.success(`Successfully created key ${this.keyModel.name}`);
} catch (e) { } catch (e) {
this.flashMessages.danger(`Error creating new key ${this.keyModel.name}: ${e.errors}`); this.flashMessages.danger(`Error creating new key ${this.keyModel.name}: ${e.errors}`);
return;
} }
} }
this.distributeKey(backend, data); yield this.distributeKey(backend, data);
// Reload key to get dist info
yield this.store.queryRecord(`keymgmt/key`, {
backend: this.args.backend,
id: this.keyModel.name,
});
} }
} }

View File

@ -56,14 +56,26 @@ export default class KeymgmtKeyEdit extends Component {
yield model.save(); yield model.save();
this.router.transitionTo(SHOW_ROUTE, model.name); this.router.transitionTo(SHOW_ROUTE, model.name);
} catch (error) { } catch (error) {
this.flashMessages.danger(error.errors.join('. ')); let errorMessage = error;
if (error.errors) {
// if errors come directly from API they will be in this shape
errorMessage = error.errors.join('. ');
}
this.flashMessages.danger(errorMessage);
if (!error.errors) {
// If error was custom from save, only partial fail
// so it's safe to show the key
this.router.transitionTo(SHOW_ROUTE, model.name);
}
} }
} }
@action @task
async removeKey() { @waitFor
*removeKey() {
try { try {
await this.keyAdapter.removeFromProvider(this.args.model); yield this.keyAdapter.removeFromProvider(this.args.model);
yield this.args.model.reload();
this.flashMessages.success('Key has been successfully removed from provider'); this.flashMessages.success('Key has been successfully removed from provider');
} catch (error) { } catch (error) {
this.flashMessages.danger(error.errors?.join('. ')); this.flashMessages.danger(error.errors?.join('. '));
@ -84,11 +96,13 @@ export default class KeymgmtKeyEdit extends Component {
}); });
} }
@action @task
rotateKey(id) { @waitFor
const backend = this.args.model.get('backend'); *rotateKey() {
const id = this.args.model.name;
const backend = this.args.model.backend;
const adapter = this.keyAdapter; const adapter = this.keyAdapter;
adapter yield adapter
.rotateKey(backend, id) .rotateKey(backend, id)
.then(() => { .then(() => {
this.flashMessages.success(`Success: ${id} connection was rotated`); this.flashMessages.success(`Success: ${id} connection was rotated`);

View File

@ -70,6 +70,7 @@ export default class KeymgmtProviderEdit extends Component {
event.preventDefault(); event.preventDefault();
const { isValid, state } = await this.args.model.validate(); const { isValid, state } = await this.args.model.validate();
if (isValid) { if (isValid) {
this.modelValidations = null;
this.saveTask.perform(); this.saveTask.perform();
} else { } else {
this.modelValidations = state; this.modelValidations = state;

View File

@ -21,7 +21,7 @@ export const KEYMGMT = {
value: 'keymgmt', value: 'keymgmt',
type: 'keymgmt', type: 'keymgmt',
glyph: 'key', glyph: 'key',
category: 'generic', category: 'cloud',
requiredFeature: 'Key Management Secrets Engine', requiredFeature: 'Key Management Secrets Engine',
}; };

View File

@ -45,7 +45,7 @@ export default class KeymgmtProviderModel extends Model {
label: 'Type', label: 'Type',
subText: 'Choose the provider type.', subText: 'Choose the provider type.',
possibleValues: ['azurekeyvault', 'awskms', 'gcpckms'], possibleValues: ['azurekeyvault', 'awskms', 'gcpckms'],
defaultValue: 'azurekeyvault', noDefault: true,
}) })
provider; provider;
@ -55,8 +55,6 @@ export default class KeymgmtProviderModel extends Model {
}) })
keyCollection; keyCollection;
@attr('date') created;
idPrefix = 'provider/'; idPrefix = 'provider/';
type = 'provider'; type = 'provider';
@ -78,7 +76,7 @@ export default class KeymgmtProviderModel extends Model {
}[this.provider]; }[this.provider];
} }
get showFields() { get showFields() {
const attrs = expandAttributeMeta(this, ['name', 'created', 'keyCollection']); const attrs = expandAttributeMeta(this, ['name', 'keyCollection']);
attrs.splice(1, 0, { hasBlock: true, label: 'Type', value: this.typeName, icon: this.icon }); attrs.splice(1, 0, { hasBlock: true, label: 'Type', value: this.typeName, icon: this.icon });
const l = this.keys.length; const l = this.keys.length;
const value = l const value = l
@ -90,13 +88,18 @@ export default class KeymgmtProviderModel extends Model {
return attrs; return attrs;
} }
get credentialProps() { get credentialProps() {
if (!this.provider) return [];
return CRED_PROPS[this.provider]; return CRED_PROPS[this.provider];
} }
get credentialFields() { get credentialFields() {
const [creds, fields] = this.credentialProps.reduce( const [creds, fields] = this.credentialProps.reduce(
([creds, fields], prop) => { ([creds, fields], prop) => {
creds[prop] = null; creds[prop] = null;
fields.push({ name: `credentials.${prop}`, type: 'string', options: { label: prop } }); let field = { name: `credentials.${prop}`, type: 'string', options: { label: prop } };
if (prop === 'service_account_file') {
field.options.subText = 'The path to a Google service account key file, not the file itself.';
}
fields.push(field);
return [creds, fields]; return [creds, fields];
}, },
[{}, []] [{}, []]
@ -109,7 +112,10 @@ export default class KeymgmtProviderModel extends Model {
} }
async fetchKeys(page) { async fetchKeys(page) {
if (this.canListKeys) { if (this.canListKeys === false) {
this.keys = [];
} else {
// try unless capabilities returns false
try { try {
this.keys = await this.store.lazyPaginatedQuery('keymgmt/key', { this.keys = await this.store.lazyPaginatedQuery('keymgmt/key', {
backend: 'keymgmt', backend: 'keymgmt',
@ -123,8 +129,6 @@ export default class KeymgmtProviderModel extends Model {
throw error; throw error;
} }
} }
} else {
this.keys = [];
} }
} }

View File

@ -1,6 +1,13 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
{{#if @label}}
<label for={{@id}} class="is-label">{{@label}}</label>
{{/if}}
{{#if @subText}}
<p class="sub-text">{{@subText}}</p>
{{/if}}
<Input <Input
id={{@id}}
class="input" class="input"
@type="text" @type="text"
@value={{this.searchInput}} @value={{this.searchInput}}

View File

@ -1,5 +1,5 @@
{{#if @backend}} {{#if @backend}}
<form {{on "submit" this.createDistribution}} class="form-section" data-test-keymgmt-distribution-form> <form {{on "submit" (perform this.createDistribution)}} class="form-section" data-test-keymgmt-distribution-form>
{{#unless @key}} {{#unless @key}}
<div class="field" data-test-keymgmt-dist-key> <div class="field" data-test-keymgmt-dist-key>
<SearchSelect <SearchSelect
@ -64,35 +64,29 @@
{{#unless @provider}} {{#unless @provider}}
<div class="field"> <div class="field">
<label class="is-label" for="provider">Provider</label> <SearchSelect
<p class="sub-text">Select a provider in Vault. If it doesnt exist yet, youll need to add it first.</p> @id="provider"
<div class="control is-expanded"> @models={{array "keymgmt/provider"}}
<div class="select is-fullwidth"> @onChange={{this.handleProvider}}
<select @passObject={{false}}
name="provider" @inputValue={{this.formData.provider}}
id="provider" @subText="Select a provider in Vault. If it doesnt exist yet, youll need to add it first."
{{on "change" this.handleProvider}} @label=""
class={{if this.validMatchError.provider "has-error-border"}} @subLabel="Provider"
data-test-keymgmt-dist-provider @fallbackComponent="input-search"
> @selectLimit="1"
<option value=""> @backend={{@backend}}
Select provider @disallowNewItems={{true}}
</option> data-test-keymgmt-dist-provider
{{#each @providers as |val|}} >
<option selected={{eq @model.provider val}} value={{val}}> {{#if this.validMatchError.provider}}
{{val}} <AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="provider">
</option> {{this.validMatchError.provider}}
{{/each}} To check compatibility,
</select> <DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
</div> </AlertInline>
</div> {{/if}}
{{#if this.validMatchError.provider}} </SearchSelect>
<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> </div>
{{/unless}} {{/unless}}
@ -139,15 +133,22 @@
</div> </div>
</fieldset> </fieldset>
{{#if this.formErrors}}
<AlertBanner @type="danger" @message={{this.formErrors}} data-test-keymgmt-distribute-error />
{{/if}}
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
<button <button
type="submit" type="submit"
disabled={{or this.validationErrorCount this.error}} disabled={{or this.validationErrorCount this.createDistribution.isRunning}}
class="button is-primary" class="button is-primary"
data-test-secret-save={{true}} data-test-secret-save={{true}}
> >
{{if (or (not @key) this.isNewKey) "Add key" "Distribute key"}} {{#if this.createDistribution.isRunning}}
<span class="loader is-inline-block"></span>
{{else}}
{{if (or (not @key) this.isNewKey) "Add key" "Distribute key"}}
{{/if}}
</button> </button>
</div> </div>
<div class="control"> <div class="control">

View File

@ -24,7 +24,7 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless" data-test-keymgmt-key-toolbar> <div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless" data-test-keymgmt-key-toolbar>
<nav class="tabs"> <nav class="tabs">
<ul> <ul>
<li class={{if (not-eq @tab "versions") "is-active"}}> <li class={{if (not-eq @tab "versions") "active"}}>
<LinkTo <LinkTo
@route="vault.cluster.secrets.backend.show" @route="vault.cluster.secrets.backend.show"
@model={{@model.id}} @model={{@model.id}}
@ -34,7 +34,7 @@
Details Details
</LinkTo> </LinkTo>
</li> </li>
<li class={{if (eq @tab "versions") "is-active"}}> <li class={{if (eq @tab "versions") "active"}}>
<LinkTo <LinkTo
@route="vault.cluster.secrets.backend.show" @route="vault.cluster.secrets.backend.show"
@model={{@model.id}} @model={{@model.id}}
@ -49,6 +49,16 @@
</div> </div>
<Toolbar> <Toolbar>
<ToolbarActions> <ToolbarActions>
{{#unless @model.distribution}}
<button
type="button"
class="toolbar-link"
{{on "click" (fn (mut this.isDistributing) true)}}
data-test-keymgmt-key-distribute
>
Distribute key
</button>
{{/unless}}
{{#if @model.canDelete}} {{#if @model.canDelete}}
<button <button
type="button" type="button"
@ -63,10 +73,11 @@
{{#if @model.provider}} {{#if @model.provider}}
<ConfirmAction <ConfirmAction
@buttonClasses="toolbar-link" @buttonClasses="toolbar-link"
@onConfirmAction={{this.removeKey}} @onConfirmAction={{perform this.removeKey}}
@confirmTitle="Remove this key?" @confirmTitle="Remove this key?"
@confirmMessage="This will remove all versions of the key from the KMS provider. The key will stay in Vault." @confirmMessage="This will remove all versions of the key from the KMS provider. The key will stay in Vault."
@confirmButtonText="Remove" @confirmButtonText="Remove"
@isRunning={{this.removeKey.isRunning}}
data-test-keymgmt-key-remove data-test-keymgmt-key-remove
> >
Remove key Remove key
@ -77,10 +88,11 @@
{{/if}} {{/if}}
<ConfirmAction <ConfirmAction
@buttonClasses="toolbar-link" @buttonClasses="toolbar-link"
@onConfirmAction={{fn this.rotateKey @model.id}} @onConfirmAction={{perform this.rotateKey}}
@confirmTitle="Rotate this key?" @confirmTitle="Rotate this key?"
@confirmMessage="After rotation, all key actions will default to using the newest version of the key." @confirmMessage="After rotation, all key actions will default to using the newest version of the key."
@confirmButtonText="Rotate" @confirmButtonText="Rotate"
@isRunning={{this.rotateKey.isRunning}}
data-test-keymgmt-key-rotate data-test-keymgmt-key-rotate
> >
Rotate key Rotate key
@ -156,7 +168,7 @@
{{/each}} {{/each}}
{{else}} {{else}}
<div class="has-top-margin-xl has-bottom-margin-s"> <div class="has-top-margin-xl has-bottom-margin-s">
<h2 class="title has-border-bottom-light is-5">Key Details</h2> <h2 class="title is-5 has-border-bottom-light">Key Details</h2>
{{#each @model.showFields as |attr|}} {{#each @model.showFields as |attr|}}
<InfoTableRow <InfoTableRow
@alwaysRender={{true}} @alwaysRender={{true}}
@ -168,23 +180,10 @@
{{/each}} {{/each}}
</div> </div>
<div class="has-top-margin-xl has-bottom-margin-s"> <div class="has-top-margin-xl has-bottom-margin-s">
<h2 class="title has-border-bottom-light is-5 {{unless @model.provider.canListKeys 'is-borderless is-marginless'}}"> <h2 class="title is-5 {{if @model.distribution 'has-border-bottom-light' 'is-borderless'}}">
Distribution Details Distribution Details
</h2> </h2>
{{#if (not @model.provider)}} {{#if @model.provider.permissionsError}}
<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
>
{{#if @model.canListProviders}}
<button type="button" class="link" {{on "click" (fn (mut this.isDistributing) true)}}>
Distribute key
<Icon @name="chevron-right" />
</button>
{{/if}}
</EmptyState>
{{else if (not @model.provider.canListKeys)}}
<EmptyState <EmptyState
@title="You are not authorized" @title="You are not authorized"
@subTitle="Error 403" @subTitle="Error 403"
@ -197,9 +196,22 @@
}} }}
@icon="minus-circle" @icon="minus-circle"
/> />
{{else if (is-empty-value @model.provider)}}
<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
>
{{#if @model.canListProviders}}
<button type="button" class="link" {{on "click" (fn (mut this.isDistributing) true)}}>
Distribute key
<Icon @name="chevron-right" />
</button>
{{/if}}
</EmptyState>
{{else}} {{else}}
<InfoTableRow @label="Distributed" @value={{@model.provider}}> <InfoTableRow @label="Distributed" @value={{@model.provider}}>
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{concat "kms/" @model.provider}}> <LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.provider}} @query={{hash itemType="provider"}}>
<Icon @name="check-circle-fill" class="has-text-success" />{{@model.provider}} <Icon @name="check-circle-fill" class="has-text-success" />{{@model.provider}}
</LinkTo> </LinkTo>
</InfoTableRow> </InfoTableRow>
@ -222,7 +234,13 @@
<EmptyState <EmptyState
@title="You are not authorized" @title="You are not authorized"
@subTitle="Error 403" @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." @message={{concat
"You must be granted permissions to view distribution details for this key. Ask your administrator if you think you should have access to GET /"
@model.backend
"/key/"
@model.name
"/kms."
}}
@icon="minus-circle" @icon="minus-circle"
/> />
{{/if}} {{/if}}

View File

@ -23,13 +23,13 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless"> <div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs"> <nav class="tabs">
<ul> <ul>
<li class={{unless this.viewingKeys "is-active"}} data-test-kms-provider-tab="details"> <li class={{unless this.viewingKeys "active"}} data-test-kms-provider-tab="details">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab=""}}> <LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab=""}}>
Details Details
</LinkTo> </LinkTo>
</li> </li>
{{#if @model.canListKeys}} {{#if @model.canListKeys}}
<li class={{if this.viewingKeys "is-active"}} data-test-kms-provider-tab="keys"> <li class={{if this.viewingKeys "active"}} data-test-kms-provider-tab="keys">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}> <LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
Keys Keys
</LinkTo> </LinkTo>
@ -98,8 +98,15 @@
Provider configuration Provider configuration
</h2> </h2>
</div> </div>
{{#if @model.provider}}
{{! Only show last field if provider selected }}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{else}}
<EmptyState @title="No provider selected" @message="Select a provider in order to configure it." />
{{/if}}
{{else}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/if}} {{/if}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}} {{/each}}
{{/if}} {{/if}}
{{#unless this.isCreating}} {{#unless this.isCreating}}

View File

@ -46,7 +46,6 @@
@groupName="mount-type" @groupName="mount-type"
@onRadioChange={{queue (action (mut this.mountModel.type)) (action "onTypeChange" "type")}} @onRadioChange={{queue (action (mut this.mountModel.type)) (action "onTypeChange" "type")}}
@disabled={{if type.requiredFeature (not (has-feature type.requiredFeature)) false}} @disabled={{if type.requiredFeature (not (has-feature type.requiredFeature)) false}}
{{! TODO: verify that keymgmt is in the ADP module }}
@tooltipMessage={{if @tooltipMessage={{if
(or (eq type.type "transform") (eq type.type "kmip") (eq type.type "keymgmt")) (or (eq type.type "transform") (eq type.type "kmip") (eq type.type "keymgmt"))
(concat (concat

View File

@ -33,6 +33,7 @@ export default Component.extend({
cancelButtonText: 'Cancel', cancelButtonText: 'Cancel',
horizontalPosition: 'auto-right', horizontalPosition: 'auto-right',
verticalPosition: 'below', verticalPosition: 'below',
isRunning: false,
disabled: false, disabled: false,
showConfirm: false, showConfirm: false,
onConfirmAction: null, onConfirmAction: null,

View File

@ -32,12 +32,16 @@
<div class="confirm-action-options"> <div class="confirm-action-options">
<button <button
type="button" type="button"
disabled={{this.disabled}} disabled={{or this.disabled this.isRunning}}
class="link is-destroy" class="link is-destroy"
data-test-confirm-button="true" data-test-confirm-button="true"
{{action "onConfirm"}} {{action "onConfirm"}}
> >
{{this.confirmButtonText}} {{#if this.isRunning}}
<span class="loader is-inline-block"></span>
{{else}}
{{this.confirmButtonText}}
{{/if}}
</button> </button>
<button type="button" class="link" data-test-confirm-cancel-button="true" {{action d.actions.close}}> <button type="button" class="link" data-test-confirm-cancel-button="true" {{action d.actions.close}}>
{{this.cancelButtonText}} {{this.cancelButtonText}}

View File

@ -1,11 +1,13 @@
{{#if this.shouldUseFallback}} {{#if this.shouldUseFallback}}
{{component {{component
this.fallbackComponent this.fallbackComponent
label=this.label label=(or this.label this.subLabel)
subText=this.subText
onChange=(action "onChange") onChange=(action "onChange")
inputValue=this.inputValue inputValue=this.inputValue
helpText=this.helpText helpText=this.helpText
placeHolder=this.placeHolder placeHolder=this.placeHolder
id=this.id
}} }}
{{else}} {{else}}
<label class={{if this.labelClass this.labelClass "title is-4"}} data-test-field-label> <label class={{if this.labelClass this.labelClass "title is-4"}} data-test-field-label>

View File

@ -78,6 +78,17 @@ module('Integration | Component | keymgmt/distribute', function (hooks) {
}), }),
]; ];
}); });
this.get('/v1/keymgmt/kms', (response) => {
return [
response,
{ 'Content-Type': 'application/json' },
JSON.stringify({
data: {
keys: ['provider-aws', 'provider-azure', 'provider-gcp'],
},
}),
];
});
}); });
}); });
@ -85,55 +96,58 @@ module('Integration | Component | keymgmt/distribute', function (hooks) {
this.server.shutdown(); this.server.shutdown();
}); });
test('it does not allow operation selection until valid key and provider selected', async function (assert) { test('it does not allow operation selection until valid key/provider combo selected', async function (assert) {
assert.expect(6);
await render( await render(
hbs`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @onClose={{fn (mut this.onClose)}} />` hbs`<Keymgmt::Distribute @backend="keymgmt" @key="example-1" @providers={{providers}} @onClose={{fn (mut this.onClose)}} />`
); );
assert.dom(SELECTORS.operationsSection).hasAttribute('disabled'); assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
// Select
await clickTrigger(); await clickTrigger();
await settled(); assert.equal(ssComponent.options.length, 3, 'shows all provider options');
assert.equal(ssComponent.options.length, 3, 'shows all key options'); await typeInSearch('aws');
await ssComponent.selectOption(); await ssComponent.selectOption();
await settled(); await settled();
assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
await select(SELECTORS.providerInput, 'provider-aws');
await settled();
assert.dom(SELECTORS.operationsSection).doesNotHaveAttribute('disabled'); assert.dom(SELECTORS.operationsSection).doesNotHaveAttribute('disabled');
await select(SELECTORS.providerInput, 'provider-azure'); // Remove selection
await ssComponent.deleteButtons.objectAt(0).click();
// Select Azure
await clickTrigger();
await typeInSearch('azure');
await ssComponent.selectOption();
// await select(SELECTORS.providerInput, 'provider-azure');
assert.dom(SELECTORS.operationsSection).hasAttribute('disabled'); assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
assert.dom(SELECTORS.errorProvider).exists('Shows key/provider match error on provider'); 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) { test('it shows key type select field if new key created', async function (assert) {
assert.expect(2);
await render( await render(
hbs`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @onClose={{fn (mut this.onClose)}} />` hbs`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @onClose={{fn (mut this.onClose)}} />`
); );
assert.dom(SELECTORS.keyTypeSection).doesNotExist('Key Type section is not rendered by default'); assert.dom(SELECTORS.keyTypeSection).doesNotExist('Key Type section is not rendered by default');
// Add new item on search-select // Add new item on search-select
await clickTrigger(); await clickTrigger();
await settled();
await typeInSearch('new-key'); await typeInSearch('new-key');
await ssComponent.selectOption(); await ssComponent.selectOption();
assert.dom(SELECTORS.keyTypeSection).exists('Key Type selector is shown'); assert.dom(SELECTORS.keyTypeSection).exists('Key Type selector is shown');
}); });
test('it hides the provider field if passed from the parent', async function (assert) { test('it hides the provider field if passed from the parent', async function (assert) {
assert.expect(5);
await render( await render(
hbs`<Keymgmt::Distribute @backend="keymgmt" @provider="provider-azure" @onClose={{fn (mut this.onClose)}} />` hbs`<Keymgmt::Distribute @backend="keymgmt" @provider="provider-azure" @onClose={{fn (mut this.onClose)}} />`
); );
assert.dom(SELECTORS.providerInput).doesNotExist('Provider input is hidden'); assert.dom(SELECTORS.providerInput).doesNotExist('Provider input is hidden');
// Select existing key // Select existing key
await clickTrigger(); await clickTrigger();
await settled();
await ssComponent.selectOption(); await ssComponent.selectOption();
await settled(); await settled();
assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); 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'); assert.dom(SELECTORS.errorKey).exists('Shows error on key selector when key/provider mismatch');
// Remove selection // Remove selection
await ssComponent.deleteButtons.objectAt(0).click(); await ssComponent.deleteButtons.objectAt(0).click();
await settled();
// Select new key // Select new key
await clickTrigger(); await clickTrigger();
await settled();
await typeInSearch('new-key'); await typeInSearch('new-key');
await ssComponent.selectOption(); await ssComponent.selectOption();
await select(SELECTORS.keyTypeSection, 'ecdsa-p256'); await select(SELECTORS.keyTypeSection, 'ecdsa-p256');
@ -141,15 +155,16 @@ module('Integration | Component | keymgmt/distribute', function (hooks) {
assert.dom(SELECTORS.errorNewKey).exists('Shows error on key type'); assert.dom(SELECTORS.errorNewKey).exists('Shows error on key type');
}); });
test('it hides the key field if passed from the parent', async function (assert) { test('it hides the key field if passed from the parent', async function (assert) {
assert.expect(4);
await render( await render(
hbs`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @key="example-1" @onClose={{fn (mut this.onClose)}} />` hbs`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @key="example-1" @onClose={{fn (mut this.onClose)}} />`
); );
assert.dom(SELECTORS.providerInput).exists('Provider input shown'); assert.dom(SELECTORS.providerInput).exists('Provider input shown');
assert.dom(SELECTORS.keySection).doesNotExist('Key input not shown'); assert.dom(SELECTORS.keySection).doesNotExist('Key input not shown');
await select(SELECTORS.providerInput, 'provider-azure'); await clickTrigger();
await typeInSearch('azure');
await ssComponent.selectOption();
assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error'); assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
assert.dom(SELECTORS.errorProvider).exists('Shows error due to key/provider mismatch'); 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

@ -29,11 +29,12 @@ module('Integration | Component | keymgmt/key-edit', function (hooks) {
this.tab = ''; this.tab = '';
}); });
// TODO: Add capabilities tests
test('it renders show view as default', async function (assert) { test('it renders show view as default', async function (assert) {
assert.expect(8);
await render(hbs`<Keymgmt::KeyEdit @model={{model}} @tab={{tab}} /><div id="modal-wormhole" />`); 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-secret-header]').hasText('Unicorns', 'Shows key name');
assert.dom('[data-test-keymgmt-key-toolbar]').exists('Subnav toolbar exists'); 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="Details"]').exists('Details tab exists');
assert.dom('[data-test-tab="Versions"]').exists('Versions 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-key-destroy]').isDisabled('Destroy button is disabled');
@ -47,6 +48,7 @@ module('Integration | Component | keymgmt/key-edit', function (hooks) {
}); });
test('it renders the correct elements on edit view', async function (assert) { test('it renders the correct elements on edit view', async function (assert) {
assert.expect(4);
let model = EmberObject.create({ let model = EmberObject.create({
name: 'Unicorns', name: 'Unicorns',
id: 'Unicorns', id: 'Unicorns',
@ -62,6 +64,7 @@ module('Integration | Component | keymgmt/key-edit', function (hooks) {
}); });
test('it renders the correct elements on create view', async function (assert) { test('it renders the correct elements on create view', async function (assert) {
assert.expect(4);
let model = EmberObject.create({}); let model = EmberObject.create({});
this.set('mode', 'create'); this.set('mode', 'create');
this.set('model', model); this.set('model', model);

View File

@ -4,7 +4,6 @@ import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars'; import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, triggerEvent, settled, fillIn } from '@ember/test-helpers'; import { click, triggerEvent, settled, fillIn } from '@ember/test-helpers';
import { format } from 'date-fns';
const ts = 'data-test-kms-provider'; const ts = 'data-test-kms-provider';
const root = { const root = {
@ -28,12 +27,10 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
name: 'foo-bar', name: 'foo-bar',
provider: 'azurekeyvault', provider: 'azurekeyvault',
keyCollection: 'keyvault-1', keyCollection: 'keyvault-1',
created: new Date(),
}, },
}, },
}); });
this.model = this.store.peekRecord('keymgmt/provider', 'foo-bar'); 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.root = root;
this.owner.lookup('service:router').reopen({ this.owner.lookup('service:router').reopen({
currentURL: '/ui/vault/secrets/keymgmt/show/foo-bar', currentURL: '/ui/vault/secrets/keymgmt/show/foo-bar',
@ -45,7 +42,7 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
}); });
test('it should render show view', async function (assert) { test('it should render show view', async function (assert) {
assert.expect(13); assert.expect(12);
// override capability getters // override capability getters
Object.defineProperties(this.model, { Object.defineProperties(this.model, {
@ -86,15 +83,14 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
/>`); />`);
assert.dom(`[${ts}-header]`).hasText('Provider foo-bar', 'Page header renders'); assert.dom(`[${ts}-header]`).hasText('Provider foo-bar', 'Page header renders');
assert.dom(`[${ts}-tab="details"]`).hasClass('is-active', 'Details tab is active'); assert.dom(`[${ts}-tab="details"]`).hasClass('active', 'Details tab is active');
const infoRows = this.element.querySelectorAll('[data-test-component="info-table-row"]'); 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[0]).hasText('Provider name foo-bar', 'Provider name field renders');
assert.dom(infoRows[1]).hasText('Type Azure Key Vault', 'Type 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('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[2]).hasText('Key Vault instance name keyvault-1', 'Key collection field renders');
assert.dom(infoRows[3]).hasText('Key Vault instance name keyvault-1', 'Key collection field renders'); assert.dom(infoRows[3]).hasText('Keys 2 keys', 'Keys field renders');
assert.dom(infoRows[4]).hasText('Keys 2 keys', 'Keys field renders');
await changeTab('keys'); await changeTab('keys');
assert.dom(`[${ts}-details-actions]`).doesNotExist('Toolbar is hidden on keys tab'); assert.dom(`[${ts}-details-actions]`).doesNotExist('Toolbar is hidden on keys tab');
@ -115,11 +111,12 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
}); });
test('it should render create view', async function (assert) { test('it should render create view', async function (assert) {
assert.expect(10); assert.expect(14);
this.server.put('/keymgmt/kms/foo', (schema, req) => { this.server.put('/keymgmt/kms/foo', (schema, req) => {
const params = { const params = {
name: 'foo', name: 'foo',
backend: 'keymgmt',
provider: 'gcpckms', provider: 'gcpckms',
key_collection: 'keyvault-1', key_collection: 'keyvault-1',
credentials: { credentials: {
@ -136,7 +133,7 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save'); assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
}, },
}); });
this.model = this.store.createRecord('keymgmt/provider'); this.model = this.store.createRecord('keymgmt/provider', { backend: 'keymgmt' });
await render(hbs` await render(hbs`
<Keymgmt::ProviderEdit <Keymgmt::ProviderEdit
@ -150,21 +147,20 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
assert.dom(`[${ts}-creds-title]`).doesNotExist('New credentials header hidden in create mode'); assert.dom(`[${ts}-creds-title]`).doesNotExist('New credentials header hidden in create mode');
await click(`[${ts}-submit]`); await click(`[${ts}-submit]`);
assert assert.dom('[data-test-inline-error-message]').exists('Validation error messages shown');
.dom('[data-test-inline-error-message]')
.exists({ count: 5 }, 'Required fields are shown on validation');
await fillIn('[data-test-input="provider"]', 'azurekeyvault');
['client_id', 'client_secret', 'tenant_id'].forEach((prop) => { ['client_id', 'client_secret', 'tenant_id'].forEach((prop) => {
assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`Azure ${prop} field renders`); assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`Azure - ${prop} field renders`);
}); });
await fillIn('[data-test-input="provider"]', 'awskms'); await fillIn('[data-test-input="provider"]', 'awskms');
['access_key', 'secret_key'].forEach((prop) => { ['access_key', 'secret_key'].forEach((prop) => {
assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`AWS ${prop} field renders`); assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`AWS - ${prop} field renders`);
}); });
await fillIn('[data-test-input="provider"]', 'gcpckms'); await fillIn('[data-test-input="provider"]', 'gcpckms');
assert.dom(`[data-test-input="credentials.service_account_file"]`).exists(`GCP cred field renders`); 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="name"]', 'foo');
await fillIn('[data-test-input="keyCollection"]', 'keyvault-1'); await fillIn('[data-test-input="keyCollection"]', 'keyvault-1');
@ -178,6 +174,7 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
this.server.put('/keymgmt/kms/foo', (schema, req) => { this.server.put('/keymgmt/kms/foo', (schema, req) => {
const params = { const params = {
name: 'foo-bar', name: 'foo-bar',
backend: 'keymgmt',
provider: 'azurekeyvault', provider: 'azurekeyvault',
key_collection: 'keyvault-1', key_collection: 'keyvault-1',
credentials: { credentials: {