diff --git a/changelog/25436.txt b/changelog/25436.txt new file mode 100644 index 0000000000..132af39f14 --- /dev/null +++ b/changelog/25436.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Add `deletion_allowed` param to transformations and include `tokenization` as a type option +``` diff --git a/ui/app/adapters/transform.js b/ui/app/adapters/transform.js index 1846341050..619ecfd65d 100644 --- a/ui/app/adapters/transform.js +++ b/ui/app/adapters/transform.js @@ -11,11 +11,11 @@ import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', - createOrUpdate(store, type, snapshot) { - const { backend, name } = snapshot.record; - const serializer = store.serializerFor(type.modelName); + createOrUpdate(store, { modelName }, snapshot) { + const { backend, name, type } = snapshot.record; + const serializer = store.serializerFor(modelName); const data = serializer.serialize(snapshot); - const url = this.urlForTransformations(backend, name); + const url = this.urlForTransformations(backend, name, type); return this.ajax(url, 'POST', { data }).then((resp) => { const response = resp || {}; @@ -41,11 +41,11 @@ export default ApplicationAdapter.extend({ return 'transform'; }, - urlForTransformations(backend, id) { - let url = `${this.buildURL()}/${encodePath(backend)}/transformation`; - if (id) { - url = url + '/' + encodePath(id); - } + urlForTransformations(backend, id, type) { + const base = `${this.buildURL()}/${encodePath(backend)}`; + // when type exists, transformations is plural + const url = type ? `${base}/transformations/${type}` : `${base}/transformation`; + if (id) return `${url}/${encodePath(id)}`; return url; }, @@ -62,7 +62,7 @@ export default ApplicationAdapter.extend({ const queryAjax = this.ajax(this.urlForTransformations(backend, id), 'GET', this.optionsForQuery(id)); return allSettled([queryAjax]).then((results) => { - // query result 404d, so throw the adapterError + // query result 404, so throw the adapterError if (!results[0].value) { throw results[0].reason; } diff --git a/ui/app/models/transform.js b/ui/app/models/transform.js index 09656bd7bc..e60d17f571 100644 --- a/ui/app/models/transform.js +++ b/ui/app/models/transform.js @@ -8,8 +8,7 @@ import { computed } from '@ember/object'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; -// these arrays define the order in which the fields will be displayed -// see +// these arrays define the order in which the fields will be displayed, see: // https://developer.hashicorp.com/vault/api-docs/secret/transform#create-update-transformation-deprecated-1-6 const TYPES = [ { @@ -20,6 +19,10 @@ const TYPES = [ value: 'masking', displayName: 'Masking', }, + { + value: 'tokenization', + displayName: 'Tokenization', + }, ]; const TWEAK_SOURCE = [ @@ -83,12 +86,49 @@ export default Model.extend({ subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).', wildcardLabel: 'role', }), - transformAttrs: computed('type', function () { - if (this.type === 'masking') { - return ['name', 'type', 'masking_character', 'template', 'allowed_roles']; - } - return ['name', 'type', 'tweak_source', 'template', 'allowed_roles']; + deletion_allowed: attr('boolean', { + label: 'Allow deletion', + subText: + 'If checked, this transform can be deleted otherwise deletion is blocked. Note that deleting the transform deletes the underlying key which makes decoding of tokenized values impossible without restoring from a backup.', }), + convergent: attr('boolean', { + label: 'Use convergent tokenization', + subText: + "This cannot be edited later. If checked, tokenization of the same plaintext more than once results in the same token. Defaults to false as unique tokens are more desirable from a security standpoint if there isn't a use-case need for convergence.", + }), + stores: attr('array', { + label: 'Stores', + editType: 'stringArray', + subText: + "The list of tokenization stores to use for tokenization state. Vault's internal storage is used by default.", + }), + mapping_mode: attr('string', { + defaultValue: 'default', + subText: + 'Specifies the mapping mode for stored tokenization values. "default" is strongly recommended for highest security, "exportable" allows for all plaintexts to be decoded via the export-decoded endpoint in an emergency.', + }), + max_ttl: attr({ + editType: 'ttl', + defaultValue: '0', + label: 'Maximum TTL (time-to-live) of a token', + helperTextDisabled: 'If "0" or unspecified, tokens may have no expiration.', + }), + + transformAttrs: computed('type', function () { + // allowed_roles not included so it displays at the bottom of the form + const baseAttrs = ['name', 'type', 'deletion_allowed']; + switch (this.type) { + case 'fpe': + return [...baseAttrs, 'tweak_source', 'template', 'allowed_roles']; + case 'masking': + return [...baseAttrs, 'masking_character', 'template', 'allowed_roles']; + case 'tokenization': + return [...baseAttrs, 'mapping_mode', 'convergent', 'max_ttl', 'stores', 'allowed_roles']; + default: + return [...baseAttrs]; + } + }), + transformFieldAttrs: computed('transformAttrs', function () { return expandAttributeMeta(this, this.transformAttrs); }), diff --git a/ui/tests/unit/adapters/transform-test.js b/ui/tests/unit/adapters/transform-test.js new file mode 100644 index 0000000000..eea263af13 --- /dev/null +++ b/ui/tests/unit/adapters/transform-test.js @@ -0,0 +1,95 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +const TRANSFORM_TYPES = ['fpe', 'masking', 'tokenization']; +module('Unit | Adapter | transform', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.backend = 'my-transform-engine'; + this.name = 'my-transform'; + }); + + hooks.afterEach(function () { + this.store.unloadAll('transform'); + }); + + test('it should make request to correct endpoint when querying all records', async function (assert) { + assert.expect(2); + this.server.get(`${this.backend}/transformation`, (schema, req) => { + assert.ok(true, 'GET request made to correct endpoint when querying record'); + assert.propEqual(req.queryParams, { list: 'true' }, 'query params include list: true'); + return { data: { key_info: {}, keys: [] } }; + }); + await this.store.query('transform', { backend: this.backend }); + }); + + test('it should make request to correct endpoint when querying a record', async function (assert) { + assert.expect(1); + this.server.get(`${this.backend}/transformation/${this.name}`, () => { + assert.ok(true, 'GET request made to correct endpoint when querying record'); + return { data: { backend: this.backend, name: this.name } }; + }); + await this.store.queryRecord('transform', { backend: this.backend, id: this.name }); + }); + + test('it should make request to correct endpoint when creating new record', async function (assert) { + assert.expect(3); + + for (const type of TRANSFORM_TYPES) { + const name = `transform-${type}-test`; + this.server.post(`${this.backend}/transformations/${type}/${name}`, () => { + assert.ok(true, `POST request made to transformations/${type}/:name creating a record`); + return { data: { backend: this.backend, name, type } }; + }); + const record = this.store.createRecord('transform', { backend: this.backend, name, type }); + await record.save(); + } + }); + + test('it should make request to correct endpoint when updating record', async function (assert) { + assert.expect(3); + for (const type of TRANSFORM_TYPES) { + const name = `transform-${type}-test`; + this.server.post(`${this.backend}/transformations/${type}/${name}`, () => { + assert.ok(true, `POST request made to transformations/${type}/:name endpoint`); + }); + this.store.pushPayload('transform', { + modelName: 'transform', + backend: this.backend, + id: name, + type, + name, + }); + const record = this.store.peekRecord('transform', name); + await record.save(); + } + }); + + test('it should make request to correct endpoint when deleting record', async function (assert) { + assert.expect(3); + for (const type of TRANSFORM_TYPES) { + const name = `transform-${type}-test`; + this.server.delete(`${this.backend}/transformation/${name}`, () => { + assert.ok(true, `type: ${type} - DELETE request to transformation/:name endpoint`); + }); + this.store.pushPayload('transform', { + modelName: 'transform', + backend: this.backend, + id: name, + type, + name, + }); + const record = this.store.peekRecord('transform', name); + await record.destroyRecord(); + } + }); +});