From 60e551fa66f048517f253f01ec5cde130b5dc246 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Fri, 31 Jul 2020 13:48:41 -0600 Subject: [PATCH] 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/app/adapters/transform.js | 108 ++++++++++++++++++ ui/app/adapters/transform/role.js | 20 ++++ ui/app/adapters/transform/template.js | 20 ++++ ui/app/components/role-edit.js | 2 +- ui/app/components/transform-edit-form.js | 8 ++ ui/app/components/transform-edit.js | 8 ++ ui/app/helpers/options-for-backend.js | 9 ++ ui/app/helpers/supported-secret-backends.js | 12 +- ui/app/models/transform.js | 105 +++++++++++++++++ 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/services/path-help.js | 7 +- ui/app/styles/core/forms.scss | 4 + .../components/transform-edit-form.hbs | 38 ++++++ .../templates/components/transform-edit.hbs | 78 +++++++++++++ ui/lib/core/addon/components/form-field.js | 3 +- ui/lib/core/addon/components/search-select.js | 2 + .../addon/templates/components/form-field.hbs | 6 +- .../templates/components/search-select.hbs | 8 +- 22 files changed, 442 insertions(+), 7 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-edit-form.js create mode 100644 ui/app/components/transform-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/templates/components/transform-edit-form.hbs create mode 100644 ui/app/templates/components/transform-edit.hbs diff --git a/ui/app/adapters/transform.js b/ui/app/adapters/transform.js new file mode 100644 index 000000000000..ffc6f3820164 --- /dev/null +++ b/ui/app/adapters/transform.js @@ -0,0 +1,108 @@ +import { assign } from '@ember/polyfills'; +import { resolve, allSettled } from 'rsvp'; +import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default ApplicationAdapter.extend({ + // TODO this adapter was copied over, much of this stuff may or may not need to be here. + namespace: 'v1', + + // defaultSerializer: 'role', + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor('transform'); // TODO replace transform with 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.urlForRole(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType() { + return 'transform'; + }, + + urlForAlphabet(backend, id) { + let url = `${this.buildURL()}/${encodePath(backend)}/alphabet`; + if (id) { + url = url + '/' + encodePath(id); + } + return url; + }, + + 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; + let zeroAddressAjax = resolve(); + const queryAjax = this.ajax(this.urlForTransformations(backend, id), 'GET', this.optionsForQuery(id)); + if (!id) { + zeroAddressAjax = this.findAllZeroAddress(store, query); + } + + return allSettled([queryAjax, zeroAddressAjax]).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) { + resp.data = assign({}, resp.data, { zero_address_roles: result.value.data.roles }); + } else { + resp.data = assign({}, resp.data, result.value.data); + } + } + }); + return resp; + }); + }, + + findAllZeroAddress(store, query) { + const { backend } = query; + const url = `/v1/${encodePath(backend)}/config/zeroaddress`; + return this.ajax(url, 'GET'); + }, + + 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 000000000000..38d688d8c020 --- /dev/null +++ b/ui/app/adapters/transform/role.js @@ -0,0 +1,20 @@ +import ApplicationAdapater from '../application'; + +export default ApplicationAdapater.extend({ + namespace: 'v1', + pathForType(type) { + return type; + }, + + urlForQuery() { + return this._super(...arguments) + '?list=true'; + }, + + query(store, type) { + return this.ajax(this.buildURL(type.modelName, null, null, 'query'), 'GET'); + }, + + buildURL(modelName, id, snapshot, requestType, query) { + return this._super(`${modelName}/`, id, snapshot, requestType, query); + }, +}); diff --git a/ui/app/adapters/transform/template.js b/ui/app/adapters/transform/template.js new file mode 100644 index 000000000000..38d688d8c020 --- /dev/null +++ b/ui/app/adapters/transform/template.js @@ -0,0 +1,20 @@ +import ApplicationAdapater from '../application'; + +export default ApplicationAdapater.extend({ + namespace: 'v1', + pathForType(type) { + return type; + }, + + urlForQuery() { + return this._super(...arguments) + '?list=true'; + }, + + query(store, type) { + return this.ajax(this.buildURL(type.modelName, null, null, 'query'), 'GET'); + }, + + buildURL(modelName, id, snapshot, requestType, query) { + return this._super(`${modelName}/`, id, snapshot, requestType, query); + }, +}); diff --git a/ui/app/components/role-edit.js b/ui/app/components/role-edit.js index fb9bc80b6b3a..88270bc367d6 100644 --- a/ui/app/components/role-edit.js +++ b/ui/app/components/role-edit.js @@ -89,7 +89,7 @@ export default Component.extend(FocusOnInsertMixin, { createOrUpdate(type, event) { event.preventDefault(); - const modelId = this.get('model.id'); + const modelId = this.get('model.id') || this.get('model.name'); //ARG TODO this is not okay // prevent from submitting if there's no key // maybe do something fancier later if (type === 'create' && isBlank(modelId)) { diff --git a/ui/app/components/transform-edit-form.js b/ui/app/components/transform-edit-form.js new file mode 100644 index 000000000000..d15a8a29f2e1 --- /dev/null +++ b/ui/app/components/transform-edit-form.js @@ -0,0 +1,8 @@ +import RoleEdit from './role-edit'; + +export default RoleEdit.extend({ + init() { + this._super(...arguments); + this.set('backendType', 'transform'); + }, +}); diff --git a/ui/app/components/transform-edit.js b/ui/app/components/transform-edit.js new file mode 100644 index 000000000000..d15a8a29f2e1 --- /dev/null +++ b/ui/app/components/transform-edit.js @@ -0,0 +1,8 @@ +import RoleEdit from './role-edit'; + +export default RoleEdit.extend({ + init() { + this._super(...arguments); + this.set('backendType', 'transform'); + }, +}); diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index 1a2111e1c515..e1c4a868cc47 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -55,6 +55,15 @@ const SECRET_BACKENDS = { editComponent: 'role-ssh-edit', listItemPartial: 'partials/secret-list/ssh-role-item', }, + // TODO: edit or remove listItemPartial and better understand what's happening here + transform: { + displayName: 'Transform', + searchPlaceholder: 'Filter Transform', + item: 'transform', + create: 'Create Transformation', + editComponent: 'transform-edit', + listItemPartial: 'partials/secret-list/ssh-role-item', + }, 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 9c86859b7207..1cc6274e4d79 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 000000000000..77360af2582b --- /dev/null +++ b/ui/app/models/transform.js @@ -0,0 +1,105 @@ +import { alias } from '@ember/object/computed'; +import { computed } from '@ember/object'; +import DS from 'ember-data'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +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', + }, +]; + +export default DS.Model.extend({ + // TODO: for now, commenting out openApi info, but keeping here just in case we end up using it. + // useOpenAPI: true, + // getHelpUrl: function(backend) { + // console.log(backend, 'Backend'); + // return `/v1/${backend}?help=1`; + // }, + name: attr('string', { + // TODO: make this required for making a transformation + label: 'Name', + fieldValue: 'id', + readOnly: true, + }), + 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.', + }), + template: attr('stringArray', { + label: 'Template', // TODO: make this required for making a transformation + 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.', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['transform/template'], + }), + 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', { + label: 'Masking character', + subText: 'Specify which character you’d like to mask your data.', + }), + allowed_roles: attr('stringArray', { + label: 'Allowed roles', + editType: 'searchSelect', + 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(function() { + // TODO: group them into sections/groups. Right now, we don't different between required and not required as we do by hiding options. + // will default to design mocks on how to handle as it will likely be a different pattern using client-side validation, which we have not done before + return ['name', 'type', 'template', 'tweak_source', 'masking_characters', 'allowed_roles']; + }), + transformFieldAttrs: computed('transformAttrs', function() { + return expandAttributeMeta(this, this.get('transformAttrs')); + }), + updatePath: lazyCapabilities(apiPath`${'backend'}/transforms/${'id'}`, 'backend', 'id'), + canDelete: alias('updatePath.canDelete'), + canEdit: alias('updatePath.canUpdate'), + canRead: alias('updatePath.canRead'), + + generatePath: lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id'), + canGenerate: alias('generatePath.canUpdate'), + + signPath: lazyCapabilities(apiPath`${'backend'}/sign/${'id'}`, 'backend', 'id'), + canSign: alias('signPath.canUpdate'), + + zeroAddressPath: lazyCapabilities(apiPath`${'backend'}/config/zeroaddress`, 'backend'), + canEditZeroAddress: alias('zeroAddressPath.canUpdate'), +}); diff --git a/ui/app/models/transform/role.js b/ui/app/models/transform/role.js new file mode 100644 index 000000000000..40c6bd6be1aa --- /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 000000000000..40c6bd6be1aa --- /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 916dacbb8829..b50720287941 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 7a671ff5aa7e..eff65f4b6a31 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 c79e90972de1..6fe3edd952c4 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/services/path-help.js b/ui/app/services/path-help.js index 9a0aa55270af..9cd6a57e723f 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/core/forms.scss b/ui/app/styles/core/forms.scss index deb16d9e5f65..113576c374da 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -70,6 +70,10 @@ label { } } +.sub-text { + color: $grey; + margin-bottom: 0.25rem; +} .input, .textarea, .select select { 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 000000000000..f693b79aa7d9 --- /dev/null +++ b/ui/app/templates/components/transform-edit-form.hbs @@ -0,0 +1,38 @@ +
+
+ {{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.hbs b/ui/app/templates/components/transform-edit.hbs new file mode 100644 index 000000000000..aaef3777369c --- /dev/null +++ b/ui/app/templates/components/transform-edit.hbs @@ -0,0 +1,78 @@ + + + {{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}} + SSH role {{model.id}} + {{/if}} +

+
+
+ +{{#if (eq mode "show")}} + + + {{#if (eq model.keyType "otp")}} + + Generate Credential + + {{else}} + + Sign Keys + + {{/if}} + {{#if (or model.canUpdate model.canDelete)}} +
+ {{/if}} + {{#if model.canDelete}} + + Delete role + + {{/if}} + {{#if (or model.canUpdate model.canDelete)}} + + Edit role + + {{/if}} + + +{{/if}} + + +{{!-- TODO: keeping here for now to remind us what other component we need to add --}} +{{!-- TODO: not following partial pattern in Transform, this comes from SSH --}} +{{!-- {{#if (or (eq mode 'edit') (eq mode 'create'))}} + {{partial 'partials/role-ssh/form'}} +{{else}} + {{partial 'partials/role-ssh/show'}} +{{/if}} --}} diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 38c1546c101f..de8cc1520c46 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 bc710c5d2428..baa8a10622fc 100644 --- a/ui/lib/core/addon/components/search-select.js +++ b/ui/lib/core/addon/components/search-select.js @@ -16,7 +16,9 @@ import layout from '../templates/components/search-select'; * @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 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 options {Array} - *Advanced usage* - `options` can be passed directly from the outside to the diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs index d0559089baf0..1d89f94bd08a 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}}
@@ -66,7 +69,8 @@
{{else if (eq attr.options.editType "mountAccessor")}} diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs index a38c05f5da80..044e122fc22a 100644 --- a/ui/lib/core/addon/templates/components/search-select.hbs +++ b/ui/lib/core/addon/templates/components/search-select.hbs @@ -7,12 +7,18 @@ helpText=helpText }} {{else}} -