UI: Transform secrets engine with transformations

* Ui/transform enable (#9647)

* Show Transform on engines list if enterprise

* Add box-radio component

* Add is-disabled styling for box-radio and fix tooltip styling when position: above

* Add KMIP and Transform to possible features on has feature helper

* Sidebranch: Transform Secret Engine Initial setup (#9625)

* WIP // list transforms, console.logs and all

* setup LIST transformations ajax request and draft out options-for-backend options

* change from plural to singluar and add transform to secret-edit

* create two transform edit components

* modify transform model with new attrs

* add adapterFor to connect transform adapter to transform-edit-form component

* setup Allowed roles searchSelect component to search over new transform/role adapter and model.

* clean up for PR

* clean up linting errors

* restructure adapter call, now it works.

* remove console

* setup template model for SearchSelect component

* add props to form field and search select for styling

Co-authored-by: Chelsea Shaw <chelshaw.dev@gmail.com>

* Ui/transform language fixes (#9666)

* Update casing and wording on Transform list route. Use generic list item for transformations

* Add back js file for transformation-edit

* Set up transform for tabs

* Ui/create edit transformation fixes (#9668)

* add conditional for masking vs tweak source based on type, and update text for create transformation

* change order

* fix error with stringArray

* setup the edit/delete transformation view

* clean up toolbar links

* setup serializer to change response of mask character from keycode to character

* change styling of label and sub-text size, confirmed with design

* temp fix on templates vs template

* add clickable list item

* add space between template list

* setup styling and structure for the rest of the show transformation.  TODO: turn into components.

* create transform-show-transformation component

* add attachCapabilities to transform model and update transform-transformation-itme list accordingly

* clean up liniting errors

* address pr comments

* remove leftover

* clean up

* Sidebranch: UI transform create and edit clean up (#9778)

* clean up some of the TODOs

* setup edit view with read only attributes for name and template

* setup initial selected for search select component

* fixes

* hide templates form field for now

* set selectLimit for search select component

* hide power select if the select limit is greater than or equal to the selectedOptions length

* clean up failing linting

* address pr comments

* Ui/fix list roles transformation (#9788)

* Update search-select to pass backend to query if exists

* Update role and template adapters

* cleanup

* Fix replace with static string

* Ui/transform cleanup 2 (#9789)

* amend encode/decode commands for now until design gets back with more details

* restrict character count on masking input field

* clean up selectLimit

* show backend instead of transform in cli copy command

* Show KMIP un-selectable if enterprise but no ADP module (#9780)

* New component transform-edit-base

* Duplicate RoleEdit as TransformEditBase and swap in all transform components

* Roll back role-edit changes

* Update to transform edit base

* Remove extraeneous set backend type on transform components

* formatting

* Revert search-select changes

* Update template/templates data on transformation (#9838)

Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
This commit is contained in:
Chelsea Shaw 2020-08-26 11:31:18 -05:00 committed by GitHub
parent 4c4fb54806
commit 5c64846225
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1116 additions and 71 deletions

View File

@ -0,0 +1,98 @@
import { assign } from '@ember/polyfills';
import { allSettled } from 'rsvp';
import ApplicationAdapter from './application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapter.extend({
namespace: 'v1',
createOrUpdate(store, type, snapshot) {
const serializer = store.serializerFor(type.modelName);
const data = serializer.serialize(snapshot);
const { id } = snapshot;
let url = this.urlForTransformations(snapshot.record.get('backend'), id);
return this.ajax(url, 'POST', { data });
},
createRecord() {
return this.createOrUpdate(...arguments);
},
updateRecord() {
return this.createOrUpdate(...arguments, 'update');
},
deleteRecord(store, type, snapshot) {
const { id } = snapshot;
return this.ajax(this.urlForTransformations(snapshot.record.get('backend'), id), 'DELETE');
},
pathForType() {
return 'transform';
},
urlForTransformations(backend, id) {
let url = `${this.buildURL()}/${encodePath(backend)}/transformation`;
if (id) {
url = url + '/' + encodePath(id);
}
return url;
},
optionsForQuery(id) {
let data = {};
if (!id) {
data['list'] = true;
}
return { data };
},
fetchByQuery(store, query) {
const { id, backend } = query;
const queryAjax = this.ajax(this.urlForTransformations(backend, id), 'GET', this.optionsForQuery(id));
return allSettled([queryAjax]).then(results => {
// query result 404d, so throw the adapterError
if (!results[0].value) {
throw results[0].reason;
}
let resp = {
id,
name: id,
backend,
data: {},
};
results.forEach(result => {
if (result.value) {
if (result.value.data.roles) {
// TODO: Check if this is needed and remove if not
resp.data = assign({}, resp.data, { zero_address_roles: result.value.data.roles });
} else {
let d = result.value.data;
if (d.templates) {
// In Transformations data goes up as "template", but comes down as "templates"
// To keep the keys consistent we're translating here
d = {
...d,
template: [d.templates],
};
delete d.templates;
}
resp.data = assign({}, resp.data, d);
}
}
});
return resp;
});
},
query(store, type, query) {
return this.fetchByQuery(store, query);
},
queryRecord(store, type, query) {
return this.fetchByQuery(store, query);
},
});

View File

@ -0,0 +1,25 @@
import ApplicationAdapater from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapater.extend({
namespace: 'v1',
pathForType() {
return 'role';
},
_url(backend, id) {
let type = this.pathForType();
let base = `/v1/${encodePath(backend)}/${type}`;
if (id) {
return `${base}/${encodePath(id)}`;
}
return base + '?list=true';
},
query(store, type, query) {
return this.ajax(this._url(query.backend), 'GET').then(result => {
return result;
});
},
});

View File

@ -0,0 +1,25 @@
import ApplicationAdapater from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapater.extend({
namespace: 'v1',
pathForType() {
return 'template';
},
_url(backend, id) {
let type = this.pathForType();
let base = `${this.buildURL()}/${encodePath(backend)}/${type}`;
if (id) {
return `${base}/${encodePath(id)}`;
}
return base + '?list=true';
},
query(store, type, query) {
return this.ajax(this._url(query.backend), 'GET').then(result => {
return result;
});
},
});

View File

@ -3,7 +3,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 } from 'vault/helpers/mountable-secret-engines';
import { engines, KMIP, TRANSFORM } from 'vault/helpers/mountable-secret-engines';
const METHODS = methods();
const ENGINES = engines();
@ -56,11 +56,10 @@ export default Component.extend({
}),
engines: computed('version.features[]', function() {
if (this.version.hasFeature('KMIP')) {
return ENGINES.concat([KMIP]);
} else {
return ENGINES;
if (this.get('version.isEnterprise')) {
return ENGINES.concat([KMIP, TRANSFORM]);
}
return ENGINES;
}),
willDestroy() {

View File

@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@ -0,0 +1,104 @@
import { inject as service } from '@ember/service';
import { or } from '@ember/object/computed';
import { isBlank } from '@ember/utils';
import { task, waitForEvent } from 'ember-concurrency';
import Component from '@ember/component';
import { set, get } from '@ember/object';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import keys from 'vault/lib/keycodes';
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
export default Component.extend(FocusOnInsertMixin, {
router: service(),
wizard: service(),
mode: null,
// TODO: Investigate if we need all of these
emptyData: '{\n}',
onDataChange() {},
onRefresh() {},
model: null,
requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'),
init() {
this._super(...arguments);
this.set('backendType', 'transform');
},
willDestroyElement() {
this._super(...arguments);
if (this.model && this.model.isError) {
this.model.rollbackAttributes();
}
},
waitForKeyUp: task(function*() {
while (true) {
let event = yield waitForEvent(document.body, 'keyup');
this.onEscape(event);
}
})
.on('didInsertElement')
.cancelOn('willDestroyElement'),
transitionToRoute() {
this.get('router').transitionTo(...arguments);
},
onEscape(e) {
if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') {
return;
}
this.transitionToRoute(LIST_ROOT_ROUTE);
},
hasDataChanges() {
get(this, 'onDataChange')(get(this, 'model.hasDirtyAttributes'));
},
persist(method, successCallback) {
const model = get(this, 'model');
return model[method]().then(() => {
if (!get(model, 'isError')) {
if (this.get('wizard.featureState') === 'role') {
this.get('wizard').transitionFeatureMachine('role', 'CONTINUE', this.get('backendType'));
}
successCallback(model);
}
});
},
actions: {
createOrUpdate(type, event) {
event.preventDefault();
const modelId = this.get('model.id') || this.get('model.name'); // transform comes in as model.name
// prevent from submitting if there's no key
// maybe do something fancier later
if (type === 'create' && isBlank(modelId)) {
return;
}
this.persist('save', () => {
this.hasDataChanges();
this.transitionToRoute(SHOW_ROUTE, modelId);
});
},
setValue(key, event) {
set(get(this, 'model'), key, event.target.checked);
},
refresh() {
this.get('onRefresh')();
},
delete() {
this.persist('destroyRecord', () => {
this.hasDataChanges();
this.transitionToRoute(LIST_ROOT_ROUTE);
});
},
},
});

View File

@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@ -5,6 +5,15 @@ export const KMIP = {
value: 'kmip',
type: 'kmip',
category: 'generic',
requiredFeature: 'KMIP',
};
export const TRANSFORM = {
displayName: 'Transform',
value: 'transform',
type: 'transform',
category: 'generic',
requiredFeature: 'Transform Secrets Engine',
};
const MOUNTABLE_SECRET_ENGINES = [

View File

@ -55,6 +55,55 @@ const SECRET_BACKENDS = {
editComponent: 'role-ssh-edit',
listItemPartial: 'partials/secret-list/ssh-role-item',
},
transform: {
displayName: 'Transformation',
navigateTree: false,
listItemPartial: 'partials/secret-list/transform-transformation-item',
tabs: [
{
name: 'transformations',
label: 'Transformations',
searchPlaceholder: 'Filter transformations',
item: 'transformation',
create: 'Create transformation',
editComponent: 'transformation-edit',
},
// TODO: Add tabs as needed
// {
// name: 'roles',
// modelPrefix: 'role/',
// label: 'Roles',
// searchPlaceholder: 'Filter roles',
// item: 'roles',
// create: 'Create role',
// tab: 'role',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'transform-role-edit',
// },
// {
// name: 'templates',
// modelPrefix: 'template/',
// label: 'Templates',
// searchPlaceholder: 'Filter templates',
// item: 'templates',
// create: 'Create template',
// tab: 'template',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'transform-template-edit',
// },
// {
// name: 'alphabets',
// modelPrefix: 'alphabet/',
// label: 'Alphabets',
// searchPlaceholder: 'Filter alphabets',
// item: 'alphabets',
// create: 'Create alphabet',
// tab: 'alphabet',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'alphabet-edit',
// },
],
},
transit: {
searchPlaceholder: 'Filter keys',
item: 'key',

View File

@ -1,6 +1,16 @@
import { helper as buildHelper } from '@ember/component/helper';
const SUPPORTED_SECRET_BACKENDS = ['aws', 'cubbyhole', 'generic', 'kv', 'pki', 'ssh', 'transit', 'kmip'];
const SUPPORTED_SECRET_BACKENDS = [
'aws',
'cubbyhole',
'generic',
'kv',
'pki',
'ssh',
'transit',
'kmip',
'transform',
];
export function supportedSecretBackends() {
return SUPPORTED_SECRET_BACKENDS;

View File

@ -0,0 +1,96 @@
import { computed } from '@ember/object';
import DS from 'ember-data';
import { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import attachCapabilities from 'vault/lib/attach-capabilities';
const { attr } = DS;
// these arrays define the order in which the fields will be displayed
// see
//https://www.vaultproject.io/api-docs/secret/transform#create-update-transformation
const TYPES = [
{
value: 'fpe',
displayName: 'Format Preserving Encryption (FPE)',
},
{
value: 'masking',
displayName: 'Masking',
},
];
const TWEAK_SOURCE = [
{
value: 'supplied',
displayName: 'supplied',
},
{
value: 'generated',
displayName: 'generated',
},
{
value: 'internal',
displayName: 'internal',
},
];
const Model = DS.Model.extend({
useOpenAPI: false,
name: attr('string', {
// TODO: make this required for making a transformation
label: 'Name',
fieldValue: 'id',
readOnly: true,
subText: 'The name for your transformation. This cannot be edited later.',
}),
type: attr('string', {
defaultValue: 'fpe',
label: 'Type',
possibleValues: TYPES,
subText:
'Vault provides two types of transformations: Format Preserving Encryption (FPE) is reversible, while Masking is not. This cannot be edited later.',
}),
tweak_source: attr('string', {
defaultValue: 'supplied',
label: 'Tweak source',
possibleValues: TWEAK_SOURCE,
subText: `A tweak value is used when performing FPE transformations. This can be supplied, generated, or internal.`, // TODO: I do not include the link here. Need to figure out the best way to approach this.
}),
masking_character: attr('string', {
characterLimit: 1,
defaultValue: '*',
label: 'Masking character',
subText: 'Specify which character youd like to mask your data.',
}),
template: attr('array', {
editType: 'searchSelect',
fallbackComponent: 'string-list',
label: 'Template', // TODO: make this required for making a transformation
models: ['transform/template'],
selectLimit: 1,
subLabel: 'Template Name',
subText:
'Templates allow Vault to determine what and how to capture the value to be transformed. Type to use an existing template or create a new one.',
}),
allowed_roles: attr('array', {
editType: 'searchSelect',
label: 'Allowed roles',
fallbackComponent: 'string-list',
models: ['transform/role'],
subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).',
}),
transformAttrs: computed('type', function() {
if (this.type === 'masking') {
return ['name', 'type', 'masking_character', 'template', 'allowed_roles'];
}
return ['name', 'type', 'tweak_source', 'template', 'allowed_roles'];
}),
transformFieldAttrs: computed('transformAttrs', function() {
return expandAttributeMeta(this, this.get('transformAttrs'));
}),
});
export default attachCapabilities(Model, {
updatePath: apiPath`transform/transformation/${'id'}`,
});

View File

@ -0,0 +1,3 @@
import DS from 'ember-data';
export default DS.Model.extend({});

View File

@ -0,0 +1,3 @@
import DS from 'ember-data';
export default DS.Model.extend({});

View File

@ -117,6 +117,8 @@ Router.map(function() {
// transit-specific routes
this.route('actions-root', { path: '/actions/' });
this.route('actions', { path: '/actions/*secret' });
// transform-specific routes
// TODO: add these
});
});
this.route('policies', { path: '/policies/:type' }, function() {

View File

@ -56,6 +56,7 @@ export default Route.extend({
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
transform: 'transform',
aws: 'role-aws',
pki: tab === 'certs' ? 'pki-certificate' : 'role-pki',
// secret or secret-v2
@ -70,6 +71,7 @@ export default Route.extend({
const secret = this.secretParam() || '';
const backend = this.enginePathParam();
const backendModel = this.modelFor('vault.cluster.secrets.backend');
return hash({
secret,
secrets: this.store

View File

@ -71,6 +71,7 @@ export default Route.extend(UnloadModelRoute, {
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
transform: 'transform',
aws: 'role-aws',
pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki',
cubbyhole: 'secret',

View File

@ -0,0 +1,19 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.data.masking_character) {
payload.data.masking_character = String.fromCharCode(payload.data.masking_character);
}
return this._super(store, primaryModelClass, payload, id, requestType);
},
serialize() {
let json = this._super(...arguments);
if (json.template && Array.isArray(json.template)) {
// Transformations should only ever have one template
json.template = json.template[0];
}
return json;
},
});

View File

@ -175,12 +175,13 @@ export default Service.extend({
// Returns relevant information from OpenAPI
// as determined by the expandOpenApiProps util
getProps(helpUrl, backend) {
// add name of thing you want
debug(`Fetching schema properties for ${backend} from ${helpUrl}`);
return this.ajax(helpUrl, backend).then(help => {
// paths is an array but it will have a single entry
// for the scope we're in
const path = Object.keys(help.openapi.paths)[0];
const path = Object.keys(help.openapi.paths)[0]; // do this or look at name
const pathInfo = help.openapi.paths[path];
const params = pathInfo.parameters;
let paramProp = {};
@ -202,7 +203,9 @@ export default Service.extend({
}
// TODO: handle post endpoints without requestBody
const props = pathInfo.post.requestBody.content['application/json'].schema.properties;
const props = pathInfo.post
? pathInfo.post.requestBody.content['application/json'].schema.properties
: {};
// put url params (e.g. {name}, {role})
// at the front of the props list
const newProps = assign({}, paramProp, props);

View File

@ -7,6 +7,9 @@
color: $grey;
margin: $size-7 0 0 0;
}
.box-radio-spacing {
margin: $size-6 $size-3 $size-6 0;
}
.box-radio {
box-sizing: border-box;
flex-basis: 7rem;
@ -32,6 +35,10 @@
box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle;
}
&.is-disabled {
opacity: 0.5;
}
input[type='radio'].radio {
position: absolute;
z-index: 1;

View File

@ -44,12 +44,13 @@
.ember-basic-dropdown-content--below.ember-basic-dropdown-content--right.tool-tip {
@include css-top-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px));
}
.ember-basic-dropdown-content--above.tool-tip {
@include css-bottom-arrow(8px, $grey, 1px, $grey-dark);
margin-top: -8px;
}
.ember-basic-dropdown-content--above.ember-basic-dropdown-content--right.tool-tip {
@include css-bottom-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px));
}
.ember-basic-dropdown-content--above.tool-tip {
margin-top: -2px;
}
.b-checkbox .tool-tip-trigger {
position: relative;

View File

@ -0,0 +1,8 @@
.copy-text {
background: $ui-gray-010;
& > code {
color: $ui-gray-800;
padding: 14px;
}
}

View File

@ -103,6 +103,7 @@
@import './components/token-expire-warning';
@import './components/toolbar';
@import './components/tool-tip';
@import './components/transform-edit.scss';
@import './components/transit-card';
@import './components/ttl-picker2';
@import './components/unseal-warning';

View File

@ -21,7 +21,7 @@ label {
.b-checkbox .is-label {
color: $grey-darker;
display: inline-block;
font-size: $size-small;
font-size: $body-size;
font-weight: $font-weight-bold;
&:not(:last-child) {
@ -70,6 +70,11 @@ label {
}
}
.sub-text {
color: $grey;
margin-bottom: 0.25rem;
font-size: $size-8;
}
.input,
.textarea,
.select select {
@ -209,6 +214,12 @@ label {
.field-body .field {
margin-bottom: 0;
}
.field {
// cannot use :read-only selector because tag used for other purposes
&.is-readOnly {
background-color: $ui-gray-100;
}
}
.field.has-addons {
flex-wrap: wrap;
.control {

View File

@ -168,6 +168,10 @@
.has-top-margin-xl {
margin-top: $spacing-xl;
}
.has-border-bottom-light {
border-radius: 0;
border-bottom: 1px solid $grey-light;
}
.has-border-danger {
border: 1px solid $danger;
}

View File

@ -49,34 +49,22 @@
</h3>
<div class="box-radio-container">
{{#each (filter-by "category" category mountTypes) as |type|}}
<label
for={{type.type}}
class="box-radio
{{if (eq mountModel.type type.type) " is-selected"}}"
data-test-mount-type-radio
data-test-mount-type={{type.type}}
>
<Icon
<BoxRadio
@displayName={{type.displayName}}
@type={{type.type}}
@glyph={{or type.glyph type.type}}
@size="xl"
class="has-text-grey-light"
/>
{{type.displayName}}
<RadioButton
@value={{type.type}}
@radioClass="radio"
@groupValue={{mountModel.type}}
@changed={{queue
@groupName="mount-type"
@onRadioChange={{queue
(action (mut mountModel.type))
(action "onTypeChange" "type")
}}
@name="mount-type"
@radioId={{type.type}}
@disabled={{if type.requiredFeature (not (has-feature type.requiredFeature)) false}}
@tooltipMessage={{if (or (eq type.type 'transform') (eq type.type 'kmip'))
(concat type.displayName " is part of the Advanced Data Protection module, which is not included in your enterprise license.")
'This secret engine is not included in your license.'
}}
/>
<label for={{type.type}}></label>
</label>
{{/each}}
</div>
{{/each}}

View File

@ -0,0 +1,37 @@
<form onsubmit={{action "createOrUpdate" "create"}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{message-error model=model}}
{{!-- TODO: figure out what this ?? --}}
{{!-- <NamespaceReminder @mode={{mode}} @noun="SSH role" /> --}}
{{#each model.transformFieldAttrs as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{buttonDisabled}}
class="button is-primary"
data-test-role-ssh-create=true
>
{{#if (eq mode 'create')}}
Create transformation
{{else if (eq mode 'edit')}}
Save
{{/if}}
</button>
{{#secret-link
mode=(if (eq mode "create") "list" "show")
class="button"
secret=model.id
}}
Cancel
{{/secret-link}}
</div>
</div>
</form>

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,60 @@
<form onsubmit={{action "createOrUpdate" "create"}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{message-error model=model}}
{{!-- TODO: figure out what this ?? --}}
{{!-- <NamespaceReminder @mode={{mode}} @noun="SSH role" /> --}}
{{#each model.transformFieldAttrs as |attr|}}
{{#if (or (eq attr.name 'name') (eq attr.name 'type')) }}
<label for="{{attr.name}}" class="is-label">
{{attr.options.label}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
{{/if}}
{{#if attr.options.possibleValues}}
<div class="control is-expanded field is-readOnly">
<div class="select is-fullwidth">
<select name="{{attr.name}}" id="{{attr.name}}" disabled data-test-input={{attr.name}}>
<option selected={{get model attr.name}} value={{get model attr.name}}>
{{get model attr.name}}
</option>
</select>
</div>
</div>
{{else}}
<input data-test-input={{attr.name}} id={{attr.name}} autocomplete="off" spellcheck="false"
value={{or (get model attr.name) attr.options.defaultValue}} readonly class="field input is-readOnly" type={{attr.type}} />
{{/if}}
{{else}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/if}}
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{buttonDisabled}}
class="button is-primary"
data-test-role-ssh-create=true
>
{{#if (eq mode 'create')}}
Create transformation
{{else if (eq mode 'edit')}}
Save
{{/if}}
</button>
{{#secret-link
mode=(if (eq mode "create") "list" "show")
class="button"
secret=model.id
}}
Cancel
{{/secret-link}}
</div>
</div>
</form>

View File

@ -0,0 +1,47 @@
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each model.transformFieldAttrs as |attr|}}
{{#if (eq attr.type "object")}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}}
{{else}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}}
{{/if}}
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-s">
<label class="title has-border-bottom-light page-header">CLI Commands</label>
<div class="has-bottom-margin-s">
<h2 class="title is-6">Encode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test the encoding capability of your transformation, use the following command. It will output an encoded_value.
</span>
</div>
<div class="copy-text level">
{{#let "vault write <backend>/encode/<your role name> value=<enter your value here> tweak=<base-64-string>" as |copyEncodeCommand|}}
<code>vault write &lt;backend&gt;/encode/&lt;your role name&gt; value=&lt;enter your value here&gt; tweak=&lt;base-64 string&gt;</code>
<CopyButton class="button is-transparent level-right" @clipboardText={{copyEncodeCommand}}
@buttonType="button" @success={{action (set-flash-message 'Command copied!')}}>
<Icon @size='l' @glyph="copy-action" aria-label="Copy" />
</CopyButton>
{{/let}}
</div>
</div>
<div>
<h2 class="title is-6">Decode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test decoding capability of your transformation, use the encoded_value in the following command. It should return your original input.
</span>
</div>
<div class="copy-text level">
{{#let "vault write <backend>/decode/<your role name> value=<enter your value here> tweak=<base-64-string>" as |copyDecodeCommand|}}
<code>vault write &lt;backend&gt;/decode/&lt;your role name&gt; value=&lt;enter your value here&gt; tweak=&lt;base-64 string&gt;</code>
<CopyButton class="button is-transparent level-right" @clipboardText={{copyDecodeCommand}}
@buttonType="button" @success={{action (set-flash-message 'Command copied!')}}>
<Icon @size='l' @glyph="copy-action" aria-label="Copy" />
</CopyButton>
{{/let}}
</div>
</div>
</div>

View File

@ -0,0 +1,64 @@
<PageHeader as |p|>
<p.top>
{{key-value-header
baseKey=model
path="vault.cluster.secrets.backend.list"
mode=mode
root=root
showCurrent=true
}}
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-secret-header="true">
{{#if (eq mode "create") }}
Create Transformation
{{else if (eq mode 'edit')}}
Edit Transformation
{{else}}
Transformation <code>{{model.id}}</code>
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if (eq mode "show")}}
<Toolbar>
<ToolbarActions>
{{!-- TODO: update these actions, show delete grey out if not allowed --}}
{{#if (or model.canUpdate model.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if model.canDelete}}
{{!-- TODO only allow deletion when not in use by a role --}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action "delete"}}
@confirmTitle="Are you sure?"
@confirmMessage="This transformation is not in use by a role and can be deleted."
>
Delete transformation
</ConfirmAction>
{{/if}}
{{#if model.canUpdate }}
<ToolbarSecretLink
@secret={{model.id}}
@mode="edit"
@data-test-edit-link=true
@replace=true
>
Edit transformation
</ToolbarSecretLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{/if}}
{{#if (eq mode 'edit')}}
<TransformEditForm @mode={{mode}} @model={{model}} />
{{else if (eq mode 'create')}}
<TransformCreateForm @mode={{mode}} @model={{model}} />
{{else}}
<TransformShowTransformation
@model={{model}}
/>
{{/if}}

View File

@ -0,0 +1,60 @@
{{!-- TODO do not let click if !canRead --}}
{{#linked-block
"vault.cluster.secrets.backend.show"
item.id
class="list-item-row"
data-test-secret-link=item.id
encode=true
queryParams=(secret-query-params backendModel.type)
}}
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode="show"
@secret={{item.id}}
@queryParams={{if (eq backendModel.type "transform") (query-params tab="actions") ""}}
@class="has-text-black has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</SecretLink>
</div>
<div class="column has-text-right">
<PopupMenu name="secret-menu">
<nav class="menu">
<ul class="menu-list">
{{#if (or item.versionPath.isLoading item.secretPath.isLoading)}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
{{#if item.updatePath.canRead}}
<li class="action">
<SecretLink
@mode="show"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Details
</SecretLink>
</li>
{{/if}}
{{#if item.updatePath.canUpdate}}
<li class="action">
<SecretLink
@mode="edit"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Edit
</SecretLink>
</li>
{{/if}}
{{/if}}
</ul>
</nav>
</PopupMenu>
</div>
</div>
{{/linked-block}}

View File

@ -0,0 +1,26 @@
/**
* @module BoxRadio
* BoxRadio components are used to display options for a radio selection.
*
* @example
* ```js
* <BoxRadio @displayName="Catahoula Leopard" @type="catahoula" @glyph="dog" @groupValue="labrador" @groupName="my-favorite-dog" @onRadioChange={{handleRadioChange}} />
* ```
* @param {string} displayName - This is the string that will show on the box radio option.
* @param {string} type - type is the key that the radio input will be identified by. Please use a value without spaces.
* @param {string} glyph - glyph is the name of the icon that will be used in the box
* @param {string} groupValue - The key of the radio option that is currently selected for this radio group
* @param {string} groupName - The name (key) of the group that this radio option belongs to
* @param {function} onRadioChange - This callback will trigger when the radio option is selected (if enabled)
* @param {boolean} [disabled=false] - This parameter controls whether the radio option is selectable. If not, it will be grayed out and show a tooltip.
* @param {string} [tooltipMessage=default] - The message that shows in the tooltip if the radio option is disabled
*/
import Component from '@ember/component';
import layout from '../templates/components/box-radio';
export default Component.extend({
layout,
disabled: false,
tooltipMessage: 'This option is not available to you at this time.',
});

View File

@ -21,7 +21,7 @@ import layout from '../templates/components/form-field';
* @param model=null {DS.Model} - The Ember Data model that `attr` is defined on
* @param [disabled=false] {Boolean} - whether the field is disabled
* @param [showHelpText=true] {Boolean} - whether to show the tooltip with help text from OpenAPI
*
* @param [subText] {String} - Text to be displayed below the label
*
*/
@ -31,6 +31,7 @@ export default Component.extend({
classNames: ['field'],
disabled: false,
showHelpText: true,
subText: '',
onChange() {},

View File

@ -9,15 +9,19 @@ import layout from '../templates/components/search-select';
* @module SearchSelect
* The `SearchSelect` is an implementation of the [ember-power-select-with-create](https://github.com/poteto/ember-cli-flash) used for form elements where options come dynamically from the API.
* @example
* <SearchSelect @id="group-policies" @models={{["policies/acl"]}} @onChange={{onChange}} @inputValue={{get model valuePath}} @helpText="Policies associated with this group" @label="Policies" @fallbackComponent="string-list" />
* <SearchSelect @id="group-policies" @models={{["policies/acl"]}} @onChange={{onChange}} @selectLimit={{2}} @inputValue={{get model valuePath}} @helpText="Policies associated with this group" @label="Policies" @fallbackComponent="string-list" />
*
* @param id {String} - The name of the form field
* @param models {String} - An array of model types to fetch from the API.
* @param onChange {Func} - The onchange action for this form field.
* @param inputValue {String | Array} - A comma-separated string or an array of strings.
* @param [helpText] {String} - Text to be displayed in the info tooltip for this form field
* @param [subText] {String} - Text to be displayed below the label
* @param [selectLimit] {Number} - A number that sets the limit to how many select options they can choose
* @param label {String} - Label for this form field
* @param [subLabel] {String} - a smaller label below the main Label
* @param fallbackComponent {String} - name of component to be rendered if the API call 403s
* @param [backend] {String} - name of the backend if the query for options needs additional information (eg. secret backend)
*
* @param options {Array} - *Advanced usage* - `options` can be passed directly from the outside to the
* power-select component. If doing this, `models` should not also be passed as that will overwrite the
@ -86,7 +90,11 @@ export default Component.extend({
this.set('shouldRenderName', true);
}
try {
let options = yield this.store.query(modelType, {});
let queryOptions = {};
if (this.backend) {
queryOptions = { backend: this.backend };
}
let options = yield this.store.query(modelType, queryOptions);
this.formatOptions(options);
} catch (err) {
if (err.httpStatus === 404) {

View File

@ -3,7 +3,7 @@ import { assert } from '@ember/debug';
import Helper from '@ember/component/helper';
import { observer } from '@ember/object';
const FEATURES = [
const POSSIBLE_FEATURES = [
'HSM',
'Performance Replication',
'DR Replication',
@ -12,10 +12,12 @@ const FEATURES = [
'Seal Wrapping',
'Control Groups',
'Namespaces',
'KMIP',
'Transform Secrets Engine',
];
export function hasFeature(featureName, features) {
if (!FEATURES.includes(featureName)) {
if (!POSSIBLE_FEATURES.includes(featureName)) {
assert(`${featureName} is not one of the available values for Vault Enterprise features.`, false);
return false;
}

View File

@ -0,0 +1,66 @@
{{#if disabled}}
<div class="box-radio-spacing">
<ToolTip
@verticalPosition="above"
@horizontalPosition="center"
as |T|>
<T.trigger @tabindex=false>
<label
for={{type}}
class="box-radio is-disabled is-marginless"
data-test-mount-type-radio
data-test-mount-type={{type}}
>
<Icon
@glyph={{glyph}}
@size="xl"
class="has-text-grey-light"
/>
{{displayName}}
<RadioButton
@value={{type}}
@radioClass="radio"
@groupValue={{groupValue}}
@changed={{onRadioChange}}
@name={{groupName}}
@radioId={{type}}
@disabled={{disabled}}
/>
<label for={{type}}></label>
</label>
</T.trigger>
<T.content @class="tool-tip">
<div class="box">
{{tooltipMessage}}
</div>
</T.content>
</ToolTip>
</div>
{{else}}
<div class="box-radio-spacing">
<label
for={{type}}
class="box-radio is-marginless
{{if (eq groupValue type) " is-selected"}}"
data-test-mount-type-radio
data-test-mount-type={{type}}
>
<Icon
@glyph={{glyph}}
@size="xl"
class="has-text-grey-light"
/>
{{displayName}}
<RadioButton
@value={{type}}
@radioClass="radio"
@groupValue={{mountType}}
@changed={{onRadioChange}}
@name={{groupName}}
@radioId={{type}}
@disabled={{disabled}}
/>
<label for={{type.type}}></label>
</label>
</div>
{{/if}}

View File

@ -26,6 +26,9 @@
{{/info-tooltip}}
{{/if}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
{{/if}}
{{/unless}}
{{#if attr.options.possibleValues}}
<div class="control is-expanded">
@ -64,10 +67,19 @@
</div>
{{else if (eq attr.options.editType "searchSelect")}}
<div class="form-section">
<SearchSelect @id={{attr.name}} @models={{attr.options.models}}
@onChange={{action (action "setAndBroadcast" valuePath)}} @inputValue={{get model valuePath}}
@helpText={{attr.options.helpText}} @label={{labelString}}
@fallbackComponent={{attr.options.fallbackComponent}} />
<SearchSelect
@id={{attr.name}}
@models={{attr.options.models}}
@onChange={{action (action "setAndBroadcast" valuePath)}}
@inputValue={{get model valuePath}}
@helpText={{attr.options.helpText}}
@subText={{attr.options.subText}}
@label={{labelString}}
@subLabel={{attr.options.subLabel}}
@fallbackComponent={{attr.options.fallbackComponent}}
@selectLimit={{attr.options.selectLimit}}
@backend={{model.backend}}
/>
</div>
{{else if (eq attr.options.editType "mountAccessor")}}
{{mount-accessor-select
@ -147,7 +159,7 @@
value={{or (get model valuePath) attr.options.defaultValue}} oninput={{action
(action "setAndBroadcast" valuePath)
value="target.value"
}} class="input" />
}} class="input" maxLength={{attr.options.characterLimit}} />
{{#if attr.options.validationAttr}}
{{#if

View File

@ -7,12 +7,19 @@
helpText=helpText
}}
{{else}}
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>
{{label}}
{{#if helpText}}
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
{{/if}}
</label>
{{#if subLabel}}
<p class="is-label">{{subLabel}}</p>
{{/if}}
{{#if subText}}
<p class="sub-text">{{subText}}</p>
{{/if}}
{{#unless (gte selectedOptions.length selectLimit)}}
{{#power-select-with-create
options=options
search=search
@ -34,6 +41,7 @@
{{option.id}}
{{/if}}
{{/power-select-with-create}}
{{/unless}}
<ul class="search-select-list">
{{#each selectedOptions as |selected|}}
<li class="search-select-list-item" data-test-selected-option="true">

View File

@ -0,0 +1 @@
export { default } from 'core/components/box-radio';

View File

@ -0,0 +1,30 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in lib/core/addon/components/box-radio.js. To make changes, first edit that file and run "yarn gen-story-md box-radio" to re-generate the content.-->
## BoxRadio
BoxRadio components are used to display options for a radio selection.
**Params**
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| displayName | <code>string</code> | | This is the string that will show on the box radio option. |
| type | <code>string</code> | | type is the key that the radio input will be identified by. Please use a value without spaces. |
| glyph | <code>string</code> | | glyph is the name of the icon that will be used in the box |
| groupValue | <code>string</code> | | The key of the radio option that is currently selected for this radio group |
| groupName | <code>string</code> | | The name (key) of the group that this radio option belongs to |
| onRadioChange | <code>function</code> | | This callback will trigger when the radio option is selected (if enabled) |
| [disabled] | <code>boolean</code> | <code>false</code> | This parameter controls whether the radio option is selectable. If not, it will be grayed out and show a tooltip. |
| [tooltipMessage] | <code>string</code> | <code>&quot;default&quot;</code> | The message that shows in the tooltip if the radio option is disabled |
**Example**
```js
<BoxRadio @displayName="Catahoula Leopard" @type="catahoula" @glyph="dog" @groupValue="labrador" @groupName="my-favorite-dog" @onRadioChange={{handleRadioChange}} />
```
**See**
- [Uses of BoxRadio](https://github.com/hashicorp/vault/search?l=Handlebars&q=BoxRadio+OR+box-radio)
- [BoxRadio Source Code](https://github.com/hashicorp/vault/blob/master/ui/lib/core/addon/components/box-radio.js)
---

View File

@ -0,0 +1,50 @@
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { withKnobs, text, boolean, select } from '@storybook/addon-knobs';
import notes from './box-radio.md';
const GLYPHS = {
KMIP: 'kmip',
Transform: 'transform',
AWS: 'aws',
Azure: 'azure',
Cert: 'cert',
GCP: 'gcp',
Github: 'github',
JWT: 'jwt',
HashiCorp: 'hashicorp',
LDAP: 'ldap',
OKTA: 'okta',
Radius: 'radius',
Userpass: 'userpass',
Secrets: 'kv',
Consul: 'consul',
};
storiesOf('BoxRadio', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`BoxRadio`,
() => ({
template: hbs`
<h5 class="title is-5">Box Radio</h5>
<BoxRadio
@type={{type}}
@glyph={{type}}
@displayName={{displayName}}
@onRadioChange={{onRadioChange}}
@disabled={{disabled}}
/>
`,
context: {
displayName: text('displayName', 'HashiCorp'),
type: select('glyph', GLYPHS, 'hashicorp'),
disabled: boolean('disabled', false),
onRadioChange: e => {
console.log('Radio changed!', e);
},
},
}),
{ notes }
);

View File

@ -0,0 +1,16 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="10" height="10" rx="1" fill="white" stroke="#BAC1CC" stroke-width="2"/>
<rect x="13" y="13" width="10" height="10" rx="1" fill="white" stroke="#BAC1CC" stroke-width="2"/>
<rect x="19" y="6" width="2" height="2" fill="#BAC1CC"/>
<rect x="19" y="9" width="2" height="2" fill="#BAC1CC"/>
<rect x="11" y="21" width="2" height="2" transform="rotate(-180 11 21)" fill="#BAC1CC"/>
<rect x="8" y="21" width="2" height="2" transform="rotate(-180 8 21)" fill="#BAC1CC"/>
<rect x="5" y="21" width="2" height="2" transform="rotate(-180 5 21)" fill="#BAC1CC"/>
<rect x="5" y="18" width="2" height="2" transform="rotate(-180 5 18)" fill="#BAC1CC"/>
<rect x="5" y="15" width="2" height="2" transform="rotate(-180 5 15)" fill="#BAC1CC"/>
<path d="M7.02393 9H8.78174L6.98438 2.65869H5.01123L3.21826 9H4.81787L5.12109 7.60693H6.72949L7.02393 9ZM5.89453 4.08691H5.97803L6.479 6.44678H5.37598L5.89453 4.08691Z" fill="#BAC1CC"/>
<path d="M15.4248 17.3833L16.9761 18.1743L15.4248 18.9653L16.0269 20.0068L17.4902 19.062L17.3979 20.8022H18.6021L18.5098 19.0576L19.9731 20.0112L20.5752 18.9653L19.0195 18.1743L20.5752 17.3833L19.9731 16.3418L18.5142 17.291L18.6021 15.5464H17.3979L17.4858 17.291L16.0269 16.3374L15.4248 17.3833Z" fill="#BAC1CC"/>
<rect x="13" y="3" width="2" height="2" fill="#BAC1CC"/>
<rect x="16" y="3" width="2" height="2" fill="#BAC1CC"/>
<rect x="19" y="3" width="2" height="2" fill="#BAC1CC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,50 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import sinon from 'sinon';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | box-radio', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('type', 'aws');
this.set('displayName', 'An Option');
this.set('mountType', '');
this.set('disabled', false);
});
test('it renders', async function(assert) {
const spy = sinon.spy();
this.set('onRadioChange', spy);
await render(hbs`<BoxRadio
@type={{type}}
@glyph={{type}}
@displayName={{displayName}}
@onRadioChange={{onRadioChange}}
@disabled={{disabled}}
/>`);
assert.equal(this.element.textContent.trim(), 'An Option', 'shows the display name of the option');
assert.dom('.tooltip').doesNotExist('tooltip does not exist when disabled is false');
await click('[data-test-mount-type="aws"]');
assert.ok(spy.calledOnce, 'calls the radio change function when option clicked');
});
test('it renders correctly when disabled', async function(assert) {
const spy = sinon.spy();
this.set('onRadioChange', spy);
await render(hbs`<BoxRadio
@type={{type}}
@glyph={{type}}
@displayName={{displayName}}
@onRadioChange={{onRadioChange}}
@disabled=true
/>`);
assert.equal(this.element.textContent.trim(), 'An Option', 'shows the display name of the option');
assert.dom('.ember-basic-dropdown-trigger').exists('tooltip exists');
await click('[data-test-mount-type="aws"]');
assert.ok(spy.notCalled, 'does not call the radio change function when option is clicked');
});
});

View File

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | transform-edit-base', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{transform-edit-base}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#transform-edit-base}}
template block text
{{/transform-edit-base}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});