From 5c648462250da776ee33a20787847644845cdc38 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Wed, 26 Aug 2020 11:31:18 -0500 Subject: [PATCH] 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 * 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 --- ui/app/adapters/transform.js | 98 +++++++++++++++++ ui/app/adapters/transform/role.js | 25 +++++ ui/app/adapters/transform/template.js | 25 +++++ ui/app/components/mount-backend-form.js | 9 +- ui/app/components/transform-create-form.js | 3 + ui/app/components/transform-edit-base.js | 104 ++++++++++++++++++ ui/app/components/transform-edit-form.js | 3 + .../transform-show-transformation.js | 3 + ui/app/components/transformation-edit.js | 3 + ui/app/helpers/mountable-secret-engines.js | 9 ++ ui/app/helpers/options-for-backend.js | 49 +++++++++ ui/app/helpers/supported-secret-backends.js | 12 +- ui/app/models/transform.js | 96 ++++++++++++++++ ui/app/models/transform/role.js | 3 + ui/app/models/transform/template.js | 3 + ui/app/router.js | 2 + .../vault/cluster/secrets/backend/list.js | 2 + .../cluster/secrets/backend/secret-edit.js | 1 + ui/app/serializers/transform.js | 19 ++++ ui/app/services/path-help.js | 7 +- ui/app/styles/components/box-radio.scss | 7 ++ ui/app/styles/components/tool-tip.scss | 7 +- ui/app/styles/components/transform-edit.scss | 8 ++ ui/app/styles/core.scss | 1 + ui/app/styles/core/forms.scss | 13 ++- ui/app/styles/core/helpers.scss | 4 + .../components/mount-backend-form.hbs | 44 +++----- .../components/transform-create-form.hbs | 37 +++++++ .../components/transform-edit-base.hbs | 1 + .../components/transform-edit-form.hbs | 60 ++++++++++ .../transform-show-transformation.hbs | 47 ++++++++ .../components/transformation-edit.hbs | 64 +++++++++++ .../transform-transformation-item.hbs | 60 ++++++++++ ui/lib/core/addon/components/box-radio.js | 26 +++++ ui/lib/core/addon/components/form-field.js | 3 +- ui/lib/core/addon/components/search-select.js | 12 +- ui/lib/core/addon/helpers/has-feature.js | 6 +- .../addon/templates/components/box-radio.hbs | 66 +++++++++++ .../addon/templates/components/form-field.hbs | 22 +++- .../templates/components/search-select.hbs | 50 +++++---- ui/lib/core/app/components/box-radio.js | 1 + ui/lib/core/stories/box-radio.md | 30 +++++ ui/lib/core/stories/box-radio.stories.js | 50 +++++++++ ui/public/eco/transform.svg | 16 +++ .../integration/components/box-radio-test.js | 50 +++++++++ .../components/transform-edit-base-test.js | 26 +++++ 46 files changed, 1116 insertions(+), 71 deletions(-) create mode 100644 ui/app/adapters/transform.js create mode 100644 ui/app/adapters/transform/role.js create mode 100644 ui/app/adapters/transform/template.js create mode 100644 ui/app/components/transform-create-form.js create mode 100644 ui/app/components/transform-edit-base.js create mode 100644 ui/app/components/transform-edit-form.js create mode 100644 ui/app/components/transform-show-transformation.js create mode 100644 ui/app/components/transformation-edit.js create mode 100644 ui/app/models/transform.js create mode 100644 ui/app/models/transform/role.js create mode 100644 ui/app/models/transform/template.js create mode 100644 ui/app/serializers/transform.js create mode 100644 ui/app/styles/components/transform-edit.scss create mode 100644 ui/app/templates/components/transform-create-form.hbs create mode 100644 ui/app/templates/components/transform-edit-base.hbs create mode 100644 ui/app/templates/components/transform-edit-form.hbs create mode 100644 ui/app/templates/components/transform-show-transformation.hbs create mode 100644 ui/app/templates/components/transformation-edit.hbs create mode 100644 ui/app/templates/partials/secret-list/transform-transformation-item.hbs create mode 100644 ui/lib/core/addon/components/box-radio.js create mode 100644 ui/lib/core/addon/templates/components/box-radio.hbs create mode 100644 ui/lib/core/app/components/box-radio.js create mode 100644 ui/lib/core/stories/box-radio.md create mode 100644 ui/lib/core/stories/box-radio.stories.js create mode 100644 ui/public/eco/transform.svg create mode 100644 ui/tests/integration/components/box-radio-test.js create mode 100644 ui/tests/integration/components/transform-edit-base-test.js diff --git a/ui/app/adapters/transform.js b/ui/app/adapters/transform.js new file mode 100644 index 000000000..1f7627ba1 --- /dev/null +++ b/ui/app/adapters/transform.js @@ -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); + }, +}); diff --git a/ui/app/adapters/transform/role.js b/ui/app/adapters/transform/role.js new file mode 100644 index 000000000..aef79adf7 --- /dev/null +++ b/ui/app/adapters/transform/role.js @@ -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; + }); + }, +}); diff --git a/ui/app/adapters/transform/template.js b/ui/app/adapters/transform/template.js new file mode 100644 index 000000000..e88825677 --- /dev/null +++ b/ui/app/adapters/transform/template.js @@ -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; + }); + }, +}); diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index 683e5f29d..6328e38ed 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -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() { diff --git a/ui/app/components/transform-create-form.js b/ui/app/components/transform-create-form.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-create-form.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transform-edit-base.js b/ui/app/components/transform-edit-base.js new file mode 100644 index 000000000..3673a4582 --- /dev/null +++ b/ui/app/components/transform-edit-base.js @@ -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); + }); + }, + }, +}); diff --git a/ui/app/components/transform-edit-form.js b/ui/app/components/transform-edit-form.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-edit-form.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transform-show-transformation.js b/ui/app/components/transform-show-transformation.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-show-transformation.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transformation-edit.js b/ui/app/components/transformation-edit.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transformation-edit.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index 19af064a5..7ec283fc4 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -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 = [ diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index 1a2111e1c..667d55ce5 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -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', diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index 9c86859b7..1cc6274e4 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -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; diff --git a/ui/app/models/transform.js b/ui/app/models/transform.js new file mode 100644 index 000000000..ccafb7f28 --- /dev/null +++ b/ui/app/models/transform.js @@ -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 you’d 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'}`, +}); diff --git a/ui/app/models/transform/role.js b/ui/app/models/transform/role.js new file mode 100644 index 000000000..40c6bd6be --- /dev/null +++ b/ui/app/models/transform/role.js @@ -0,0 +1,3 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({}); diff --git a/ui/app/models/transform/template.js b/ui/app/models/transform/template.js new file mode 100644 index 000000000..40c6bd6be --- /dev/null +++ b/ui/app/models/transform/template.js @@ -0,0 +1,3 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({}); diff --git a/ui/app/router.js b/ui/app/router.js index 916dacbb8..b50720287 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -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() { diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 7a671ff5a..eff65f4b6 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -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 diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index c79e90972..6fe3edd95 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -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', diff --git a/ui/app/serializers/transform.js b/ui/app/serializers/transform.js new file mode 100644 index 000000000..ab30958d0 --- /dev/null +++ b/ui/app/serializers/transform.js @@ -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; + }, +}); diff --git a/ui/app/services/path-help.js b/ui/app/services/path-help.js index 9a0aa5527..9cd6a57e7 100644 --- a/ui/app/services/path-help.js +++ b/ui/app/services/path-help.js @@ -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); diff --git a/ui/app/styles/components/box-radio.scss b/ui/app/styles/components/box-radio.scss index dbc9d2055..1828e1e86 100644 --- a/ui/app/styles/components/box-radio.scss +++ b/ui/app/styles/components/box-radio.scss @@ -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; diff --git a/ui/app/styles/components/tool-tip.scss b/ui/app/styles/components/tool-tip.scss index 36ff39918..c36584c87 100644 --- a/ui/app/styles/components/tool-tip.scss +++ b/ui/app/styles/components/tool-tip.scss @@ -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; diff --git a/ui/app/styles/components/transform-edit.scss b/ui/app/styles/components/transform-edit.scss new file mode 100644 index 000000000..574001004 --- /dev/null +++ b/ui/app/styles/components/transform-edit.scss @@ -0,0 +1,8 @@ +.copy-text { + background: $ui-gray-010; + + & > code { + color: $ui-gray-800; + padding: 14px; + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 62d76be84..2e684cfd7 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -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'; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index deb16d9e5..106353c71 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -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 { diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index ecc7bd6ad..a9c76e02f 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -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; } diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs index 63cd367e5..0fd3bf78a 100644 --- a/ui/app/templates/components/mount-backend-form.hbs +++ b/ui/app/templates/components/mount-backend-form.hbs @@ -49,34 +49,22 @@
{{#each (filter-by "category" category mountTypes) as |type|}} - + {{/each}}
{{/each}} diff --git a/ui/app/templates/components/transform-create-form.hbs b/ui/app/templates/components/transform-create-form.hbs new file mode 100644 index 000000000..a35e16c68 --- /dev/null +++ b/ui/app/templates/components/transform-create-form.hbs @@ -0,0 +1,37 @@ +
+
+ {{message-error model=model}} + {{!-- TODO: figure out what this ?? --}} + {{!-- --}} + {{#each model.transformFieldAttrs as |attr|}} + + {{/each}} +
+
+
+ + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=model.id + }} + Cancel + {{/secret-link}} +
+
+
diff --git a/ui/app/templates/components/transform-edit-base.hbs b/ui/app/templates/components/transform-edit-base.hbs new file mode 100644 index 000000000..fb5c4b157 --- /dev/null +++ b/ui/app/templates/components/transform-edit-base.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/ui/app/templates/components/transform-edit-form.hbs b/ui/app/templates/components/transform-edit-form.hbs new file mode 100644 index 000000000..3855980c7 --- /dev/null +++ b/ui/app/templates/components/transform-edit-form.hbs @@ -0,0 +1,60 @@ +
+
+ {{message-error model=model}} + {{!-- TODO: figure out what this ?? --}} + {{!-- --}} + {{#each model.transformFieldAttrs as |attr|}} + {{#if (or (eq attr.name 'name') (eq attr.name 'type')) }} + + {{#if attr.options.subText}} +

{{attr.options.subText}}

+ {{/if}} + {{#if attr.options.possibleValues}} +
+
+ +
+
+ {{else}} + + {{/if}} + {{else}} + + {{/if}} + {{/each}} +
+
+
+ + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=model.id + }} + Cancel + {{/secret-link}} +
+
+
diff --git a/ui/app/templates/components/transform-show-transformation.hbs b/ui/app/templates/components/transform-show-transformation.hbs new file mode 100644 index 000000000..22a4ec8e4 --- /dev/null +++ b/ui/app/templates/components/transform-show-transformation.hbs @@ -0,0 +1,47 @@ +
+ {{#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}} +
+ +
+ +
+

Encode

+
+ + To test the encoding capability of your transformation, use the following command. It will output an encoded_value. + +
+
+ {{#let "vault write /encode/ value= tweak=" as |copyEncodeCommand|}} + vault write <backend>/encode/<your role name> value=<enter your value here> tweak=<base-64 string> + + + + {{/let}} +
+
+
+

Decode

+
+ + To test decoding capability of your transformation, use the encoded_value in the following command. It should return your original input. + +
+
+ {{#let "vault write /decode/ value= tweak=" as |copyDecodeCommand|}} + vault write <backend>/decode/<your role name> value=<enter your value here> tweak=<base-64 string> + + + + {{/let}} +
+
+
diff --git a/ui/app/templates/components/transformation-edit.hbs b/ui/app/templates/components/transformation-edit.hbs new file mode 100644 index 000000000..a09314927 --- /dev/null +++ b/ui/app/templates/components/transformation-edit.hbs @@ -0,0 +1,64 @@ + + + {{key-value-header + baseKey=model + path="vault.cluster.secrets.backend.list" + mode=mode + root=root + showCurrent=true + }} + + +

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

+
+
+ +{{#if (eq mode "show")}} + + + {{!-- TODO: update these actions, show delete grey out if not allowed --}} + {{#if (or model.canUpdate model.canDelete)}} +
+ {{/if}} + {{#if model.canDelete}} + {{!-- TODO only allow deletion when not in use by a role --}} + + Delete transformation + + {{/if}} + {{#if model.canUpdate }} + + Edit transformation + + {{/if}} + + +{{/if}} + +{{#if (eq mode 'edit')}} + +{{else if (eq mode 'create')}} + +{{else}} + +{{/if}} diff --git a/ui/app/templates/partials/secret-list/transform-transformation-item.hbs b/ui/app/templates/partials/secret-list/transform-transformation-item.hbs new file mode 100644 index 000000000..94ccc1f89 --- /dev/null +++ b/ui/app/templates/partials/secret-list/transform-transformation-item.hbs @@ -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) +}} +
+
+ + + {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} + +
+
+ + + +
+
+{{/linked-block}} diff --git a/ui/lib/core/addon/components/box-radio.js b/ui/lib/core/addon/components/box-radio.js new file mode 100644 index 000000000..ed366e472 --- /dev/null +++ b/ui/lib/core/addon/components/box-radio.js @@ -0,0 +1,26 @@ +/** + * @module BoxRadio + * BoxRadio components are used to display options for a radio selection. + * + * @example + * ```js + * + * ``` + * @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.', +}); diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 38c1546c1..de8cc1520 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -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() {}, diff --git a/ui/lib/core/addon/components/search-select.js b/ui/lib/core/addon/components/search-select.js index bc710c5d2..cdaa19658 100644 --- a/ui/lib/core/addon/components/search-select.js +++ b/ui/lib/core/addon/components/search-select.js @@ -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 - * + * * * @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) { diff --git a/ui/lib/core/addon/helpers/has-feature.js b/ui/lib/core/addon/helpers/has-feature.js index d3248a7d6..0ddd687c7 100644 --- a/ui/lib/core/addon/helpers/has-feature.js +++ b/ui/lib/core/addon/helpers/has-feature.js @@ -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; } diff --git a/ui/lib/core/addon/templates/components/box-radio.hbs b/ui/lib/core/addon/templates/components/box-radio.hbs new file mode 100644 index 000000000..f4dcbf302 --- /dev/null +++ b/ui/lib/core/addon/templates/components/box-radio.hbs @@ -0,0 +1,66 @@ +{{#if disabled}} +
+ + + + + +
+ {{tooltipMessage}} +
+
+
+
+{{else}} +
+ +
+{{/if}} diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs index d0559089b..8ea8c1a63 100644 --- a/ui/lib/core/addon/templates/components/form-field.hbs +++ b/ui/lib/core/addon/templates/components/form-field.hbs @@ -26,6 +26,9 @@ {{/info-tooltip}} {{/if}} + {{#if attr.options.subText}} +

{{attr.options.subText}}

+ {{/if}} {{/unless}} {{#if attr.options.possibleValues}}
@@ -64,10 +67,19 @@
{{else if (eq attr.options.editType "searchSelect")}}
- +
{{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 diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs index a38c05f5d..93ed1d72a 100644 --- a/ui/lib/core/addon/templates/components/search-select.hbs +++ b/ui/lib/core/addon/templates/components/search-select.hbs @@ -7,33 +7,41 @@ helpText=helpText }} {{else}} -