From f4cb33e4d4ebfc1e38724c5b1ec8ad9a301435cc Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 17 Sep 2020 14:08:06 -0500 Subject: [PATCH] Ui/transform templates (#9981) Add CRUD capabilities on transform templates. Disallow read or edit for built-ins. --- ui/app/adapters/transform/base.js | 3 +- ui/app/components/transform-list-item.js | 32 +++++ ui/app/components/transform-template-edit.js | 3 + ui/app/helpers/options-for-backend.js | 7 +- ui/app/models/transform/template.js | 53 +++++++- .../vault/cluster/secrets/backend/list.js | 2 +- ui/app/serializers/transform/role.js | 1 + ui/app/serializers/transform/template.js | 34 +++++ .../components/transform-list-item.hbs | 66 ++++++++++ .../components/transform-template-edit.hbs | 123 ++++++++++++++++++ .../secret-list/transform-list-item.hbs | 82 +----------- .../components/transform-list-item-test.js | 121 +++++++++++++++++ .../transform-template-edit-test.js | 26 ++++ 13 files changed, 465 insertions(+), 88 deletions(-) create mode 100644 ui/app/components/transform-list-item.js create mode 100644 ui/app/components/transform-template-edit.js create mode 100644 ui/app/serializers/transform/template.js create mode 100644 ui/app/templates/components/transform-list-item.hbs create mode 100644 ui/app/templates/components/transform-template-edit.hbs create mode 100644 ui/tests/integration/components/transform-list-item-test.js create mode 100644 ui/tests/integration/components/transform-template-edit-test.js diff --git a/ui/app/adapters/transform/base.js b/ui/app/adapters/transform/base.js index c7f4ccf9cb..4cac36605b 100644 --- a/ui/app/adapters/transform/base.js +++ b/ui/app/adapters/transform/base.js @@ -55,12 +55,11 @@ export default ApplicationAdapter.extend({ queryRecord(store, type, query) { return this.ajax(this.url(query.backend, type.modelName, query.id), 'GET').then(result => { + // CBS TODO: Add name to response and unmap name <> id on models return { id: query.id, ...result, }; }); }, - - // buildUrl(modelName, id, snapshot, requestType, query, returns) {}, }); diff --git a/ui/app/components/transform-list-item.js b/ui/app/components/transform-list-item.js new file mode 100644 index 0000000000..393ad3a96b --- /dev/null +++ b/ui/app/components/transform-list-item.js @@ -0,0 +1,32 @@ +/** + * @module TransformListItem + * TransformListItem components are used for the list items for the Transform Secret Engines for all but Transformations. + * This component automatically handles read-only list items if capabilities are not granted or the item is internal only. + * + * @example + * ```js + * + * ``` + * @param {object} item - item refers to the model item used on the list item partial + * @param {string} itemPath - usually the id of the item, but can be prefixed with the model type (see transform/role) + * @param {string} [itemType] - itemType is used to calculate whether an item is readable or + */ + +import { computed } from '@ember/object'; +import Component from '@ember/component'; + +export default Component.extend({ + item: null, + itemPath: '', + itemType: '', + + itemViewable: computed('item', 'itemType', function() { + const item = this.get('item'); + if (this.itemType === 'alphabet' || this.itemType === 'template') { + return !item.get('id').startsWith('builtin/'); + } + return true; + }), + + backendType: 'transform', +}); diff --git a/ui/app/components/transform-template-edit.js b/ui/app/components/transform-template-edit.js new file mode 100644 index 0000000000..548e2dd85c --- /dev/null +++ b/ui/app/components/transform-template-edit.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index e805f097c4..8f0a7916b9 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -80,15 +80,14 @@ const SECRET_BACKENDS = { editComponent: 'transform-role-edit', }, { - name: 'templates', + name: 'template', modelPrefix: 'template/', label: 'Templates', searchPlaceholder: 'Filter templates', - item: 'templates', + item: 'template', create: 'Create template', - tab: 'templates', + tab: 'template', editComponent: 'transform-template-edit', - hideCreate: true, }, { name: 'alphabets', diff --git a/ui/app/models/transform/template.js b/ui/app/models/transform/template.js index c84c59268c..1cae81c037 100644 --- a/ui/app/models/transform/template.js +++ b/ui/app/models/transform/template.js @@ -1,7 +1,52 @@ +import { computed } from '@ember/object'; import DS from 'ember-data'; +import { apiPath } from 'vault/macros/lazy-capabilities'; +import attachCapabilities from 'vault/lib/attach-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; -export default DS.Model.extend({ - name: DS.attr('string'), - alphabet: DS.belongsTo('transform/alphabet'), - transformations: DS.hasMany('transformation'), +const { attr } = DS; + +const Model = DS.Model.extend({ + idPrefix: 'template/', + idForNav: computed('id', 'idPrefix', function() { + let modelId = this.id || ''; + return `${this.idPrefix}${modelId}`; + }), + + name: attr('string', { + label: 'Name', + fieldValue: 'id', + readOnly: true, + subText: + 'Templates allow Vault to determine what and how to capture the value to be transformed. This cannot be edited later.', + }), + type: attr('string', { defaultValue: 'regex' }), + pattern: attr('string', { + subText: 'The template’s pattern defines the data format. Expressed in regex.', + }), + alphabet: attr('array', { + subText: + 'Alphabet defines a set of characters (UTF-8) that is used for FPE to determine the validity of plaintext and ciphertext values. You can choose a built-in one, or create your own.', + editType: 'searchSelect', + fallbackComponent: 'string-list', + label: 'Alphabet', + models: ['transform/alphabet'], + selectLimit: 1, + }), + + attrs: computed('pattern', 'alphabet', function() { + let keys = ['name', 'pattern', 'alphabet']; + return expandAttributeMeta(this, keys); + }), + + editableAttrs: computed('pattern', 'alphabet', function() { + let keys = ['pattern', 'alphabet']; + return expandAttributeMeta(this, keys); + }), + + backend: attr('string', { readOnly: true }), +}); + +export default attachCapabilities(Model, { + updatePath: apiPath`${'backend'}/template/${'id'}`, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 669e121640..e5c87f57a8 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -28,7 +28,7 @@ export default Route.extend({ case 'role': modelType = 'transform/role'; break; - case 'templates': + case 'template': modelType = 'transform/template'; break; case 'alphabets': diff --git a/ui/app/serializers/transform/role.js b/ui/app/serializers/transform/role.js index ed04af7935..5e35ae6a2c 100644 --- a/ui/app/serializers/transform/role.js +++ b/ui/app/serializers/transform/role.js @@ -1,4 +1,5 @@ import ApplicationSerializer from '../application'; + export default ApplicationSerializer.extend({ extractLazyPaginatedData(payload) { let ret; diff --git a/ui/app/serializers/transform/template.js b/ui/app/serializers/transform/template.js new file mode 100644 index 0000000000..18bb7bf280 --- /dev/null +++ b/ui/app/serializers/transform/template.js @@ -0,0 +1,34 @@ +import ApplicationSerializer from '../application'; + +export default ApplicationSerializer.extend({ + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + payload.data.name = payload.id; + if (payload.data.alphabet) { + payload.data.alphabet = [payload.data.alphabet]; + } + return this._super(store, primaryModelClass, payload, id, requestType); + }, + + serialize() { + let json = this._super(...arguments); + if (json.alphabet && Array.isArray(json.alphabet)) { + // Templates should only ever have one alphabet + json.alphabet = json.alphabet[0]; + } + return json; + }, + + extractLazyPaginatedData(payload) { + let ret; + ret = payload.data.keys.map(key => { + let model = { + id: key, + }; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + return ret; + }, +}); diff --git a/ui/app/templates/components/transform-list-item.hbs b/ui/app/templates/components/transform-list-item.hbs new file mode 100644 index 0000000000..13f4087099 --- /dev/null +++ b/ui/app/templates/components/transform-list-item.hbs @@ -0,0 +1,66 @@ +{{#if (and itemViewable item.updatePath.canRead)}} + {{#linked-block + "vault.cluster.secrets.backend.show" + itemPath + class="list-item-row" + data-test-secret-link=itemPath + encode=true + queryParams=(secret-query-params backendType) + }} +
+
+ + + {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} + +
+
+ {{#if (or item.updatePath.canRead item.updatePath.canUpdate)}} + + + + {{/if}} +
+
+ {{/linked-block}} +{{else}} +
+
+
+ + {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} +
+
+
+{{/if}} diff --git a/ui/app/templates/components/transform-template-edit.hbs b/ui/app/templates/components/transform-template-edit.hbs new file mode 100644 index 0000000000..18c31fb5f5 --- /dev/null +++ b/ui/app/templates/components/transform-template-edit.hbs @@ -0,0 +1,123 @@ + + + {{key-value-header + baseKey=(hash display=model.id id=model.idForNav) + path="vault.cluster.secrets.backend.list" + mode=mode + root=root + showCurrent=true + }} + + +

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

+
+
+ +{{#if (eq mode "show")}} + + + {{#if capabilities.canDelete}} + + {{/if}} + {{#if capabilities.canUpdate }} + + Edit template + + {{/if}} + + +{{/if}} + +{{#if (or (eq mode 'edit') (eq mode 'create'))}} +
+
+ {{message-error model=model}} + + {{#each model.attrs as |attr|}} + {{#if (and (eq attr.name 'name') (eq mode 'edit')) }} + + {{#if attr.options.subText}} +

{{attr.options.subText}}

+ {{/if}} + + {{else}} + + {{/if}} + {{/each}} +
+
+
+ + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=(concat model.idPrefix model.id) + }} + Cancel + {{/secret-link}} +
+
+
+{{else}} +
+ {{#each model.attrs 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 if (eq attr.type "array")}} + {{info-table-row + label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) + value=(get model attr.name) + type=attr.type + viewAll=attr.name + }} + {{else}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/if}} + {{/each}} +
+{{/if}} diff --git a/ui/app/templates/partials/secret-list/transform-list-item.hbs b/ui/app/templates/partials/secret-list/transform-list-item.hbs index dd3951b324..5f2ba5027e 100644 --- a/ui/app/templates/partials/secret-list/transform-list-item.hbs +++ b/ui/app/templates/partials/secret-list/transform-list-item.hbs @@ -1,77 +1,5 @@ -{{!-- TODO do not let click if !canRead --}} -{{#if (eq options.item "role")}} - {{#let (concat options.modelPrefix item.id) as |itemPath|}} - {{#linked-block - "vault.cluster.secrets.backend.show" - itemPath - class="list-item-row" - data-test-secret-link=itemPath - encode=true - queryParams=(secret-query-params backendModel.type) - }} -
-
- - - {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} - -
-
- {{#if (or item.updatePath.canRead item.updatePath.canUpdate)}} - - - - {{/if}} -
-
- {{/linked-block}} - {{/let}} -{{else}} -
-
-
- - {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} -
-
-
-{{/if}} + diff --git a/ui/tests/integration/components/transform-list-item-test.js b/ui/tests/integration/components/transform-list-item-test.js new file mode 100644 index 0000000000..b0ee8c56f0 --- /dev/null +++ b/ui/tests/integration/components/transform-list-item-test.js @@ -0,0 +1,121 @@ +import EmberObject from '@ember/object'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, findAll, click } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | transform-list-item', function(hooks) { + setupRenderingTest(hooks); + + test('it renders un-clickable item if no read capability', async function(assert) { + let item = EmberObject.create({ + id: 'foo', + updatePath: { + canRead: false, + canDelete: true, + canUpdate: true, + }, + }); + this.set('itemPath', 'role/foo'); + this.set('itemType', 'role'); + this.set('item', item); + await render(hbs``); + + assert.dom('[data-test-view-only-list-item]').exists('shows view only list item'); + assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label'); + }); + + test('it is clickable with details menu item if read capability', async function(assert) { + let item = EmberObject.create({ + id: 'foo', + updatePath: { + canRead: true, + canDelete: false, + canUpdate: false, + }, + }); + this.set('itemPath', 'template/foo'); + this.set('itemType', 'template'); + this.set('item', item); + await render(hbs``); + + assert.dom('[data-test-secret-link="template/foo"]').exists('shows clickable list item'); + await click('button.popup-menu-trigger'); + assert.equal(findAll('.popup-menu-content li').length, 1, 'has one option'); + }); + + test('it has details and edit menu item if read & edit capabilities', async function(assert) { + let item = EmberObject.create({ + id: 'foo', + updatePath: { + canRead: true, + canDelete: true, + canUpdate: true, + }, + }); + this.set('itemPath', 'alphabet/foo'); + this.set('itemType', 'alphabet'); + this.set('item', item); + await render(hbs``); + + assert.dom('[data-test-secret-link="alphabet/foo"]').exists('shows clickable list item'); + await click('button.popup-menu-trigger'); + assert.equal(findAll('.popup-menu-content li').length, 2, 'has both options'); + }); + + test('it is not clickable if built-in template with all capabilities', async function(assert) { + let item = EmberObject.create({ + id: 'builtin/foo', + updatePath: { + canRead: true, + canDelete: true, + canUpdate: true, + }, + }); + this.set('itemPath', 'template/builtin/foo'); + this.set('itemType', 'template'); + this.set('item', item); + await render(hbs``); + + assert.dom('[data-test-view-only-list-item]').exists('shows view only list item'); + assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label'); + }); + + test('it is not clickable if built-in alphabet', async function(assert) { + let item = EmberObject.create({ + id: 'builtin/foo', + updatePath: { + canRead: true, + canDelete: true, + canUpdate: true, + }, + }); + this.set('itemPath', 'alphabet/builtin/foo'); + this.set('itemType', 'alphabet'); + this.set('item', item); + await render(hbs``); + + assert.dom('[data-test-view-only-list-item]').exists('shows view only list item'); + assert.dom('[data-test-view-only-list-item]').hasText(item.id, 'has correct label'); + }); +}); diff --git a/ui/tests/integration/components/transform-template-edit-test.js b/ui/tests/integration/components/transform-template-edit-test.js new file mode 100644 index 0000000000..f364e5a1a7 --- /dev/null +++ b/ui/tests/integration/components/transform-template-edit-test.js @@ -0,0 +1,26 @@ +import { module, skip } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | transform-template-edit', function(hooks) { + setupRenderingTest(hooks); + + skip('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-template-edit}}`); + + assert.equal(this.element.textContent.trim(), ''); + + // Template block usage: + await render(hbs` + {{#transform-template-edit}} + template block text + {{/transform-template-edit}} + `); + + assert.equal(this.element.textContent.trim(), 'template block text'); + }); +});