diff --git a/ui/app/models/identity/entity-alias.js b/ui/app/models/identity/entity-alias.js index d6042175a7..badd7f2863 100644 --- a/ui/app/models/identity/entity-alias.js +++ b/ui/app/models/identity/entity-alias.js @@ -24,6 +24,7 @@ export default IdentityModel.extend({ }), metadata: attr({ editType: 'kv', + isSectionHeader: true, }), mountPath: attr('string', { readOnly: true, diff --git a/ui/app/models/identity/entity.js b/ui/app/models/identity/entity.js index bb53324d81..d7585d45e3 100644 --- a/ui/app/models/identity/entity.js +++ b/ui/app/models/identity/entity.js @@ -23,6 +23,7 @@ export default IdentityModel.extend({ mergedEntityIds: attr(), metadata: attr({ editType: 'kv', + isSectionHeader: true, }), policies: attr({ editType: 'yield', diff --git a/ui/app/models/identity/group.js b/ui/app/models/identity/group.js index d2be312b31..79e5f3efe7 100644 --- a/ui/app/models/identity/group.js +++ b/ui/app/models/identity/group.js @@ -37,6 +37,7 @@ export default IdentityModel.extend({ }), metadata: attr('object', { editType: 'kv', + isSectionHeader: true, }), policies: attr({ editType: 'yield', diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js index d4953b923d..66d1d1b8c0 100644 --- a/ui/app/models/kv/metadata.js +++ b/ui/app/models/kv/metadata.js @@ -51,6 +51,7 @@ export default class KvSecretMetadataModel extends Model { @attr('object', { editType: 'kv', + isSectionHeader: true, subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', }) customMetadata; diff --git a/ui/app/models/sync/destination.js b/ui/app/models/sync/destination.js index 67660017be..c1a5eb250e 100644 --- a/ui/app/models/sync/destination.js +++ b/ui/app/models/sync/destination.js @@ -20,6 +20,12 @@ const validations = { export default class SyncDestinationModel extends Model { @attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name; @attr type; + @attr('string', { + subText: + 'Go-template string that indicates how to format the secret name at the destination. The default template varies by destination type but is generally in the form of "vault--" e.g. "vault-kv-1234-my-secret-1".', + }) + secretNameTemplate; + // only present if delete action has been initiated @attr('string') purgeInitiatedAt; @attr('string') purgeError; diff --git a/ui/app/models/sync/destinations/aws-sm.js b/ui/app/models/sync/destinations/aws-sm.js index 843ab6d3e2..865208d784 100644 --- a/ui/app/models/sync/destinations/aws-sm.js +++ b/ui/app/models/sync/destinations/aws-sm.js @@ -7,9 +7,16 @@ import SyncDestinationModel from '../destination'; import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; -const displayFields = ['name', 'region', 'accessKeyId', 'secretAccessKey']; +const displayFields = [ + 'name', + 'region', + 'accessKeyId', + 'secretAccessKey', + 'secretNameTemplate', + 'customTags', +]; const formFieldGroups = [ - { default: ['name', 'region'] }, + { default: ['name', 'region', 'secretNameTemplate', 'customTags'] }, { Credentials: ['accessKeyId', 'secretAccessKey'] }, ]; @withFormFields(displayFields, formFieldGroups) @@ -34,4 +41,11 @@ export default class SyncDestinationsAwsSecretsManagerModel extends SyncDestinat editDisabled: true, }) region; + + @attr('object', { + subText: + 'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.', + editType: 'kv', + }) + customTags; } diff --git a/ui/app/models/sync/destinations/azure-kv.js b/ui/app/models/sync/destinations/azure-kv.js index 0f37211a7a..91cb6bb0cd 100644 --- a/ui/app/models/sync/destinations/azure-kv.js +++ b/ui/app/models/sync/destinations/azure-kv.js @@ -6,9 +6,19 @@ import SyncDestinationModel from '../destination'; import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; -const displayFields = ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'clientSecret']; + +const displayFields = [ + 'name', + 'keyVaultUri', + 'tenantId', + 'cloud', + 'clientId', + 'clientSecret', + 'secretNameTemplate', + 'customTags', +]; const formFieldGroups = [ - { default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId'] }, + { default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'secretNameTemplate', 'customTags'] }, { Credentials: ['clientSecret'] }, ]; @withFormFields(displayFields, formFieldGroups) @@ -47,4 +57,11 @@ export default class SyncDestinationsAzureKeyVaultModel extends SyncDestinationM editDisabled: true, }) cloud; + + @attr('object', { + subText: + 'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.', + editType: 'kv', + }) + customTags; } diff --git a/ui/app/models/sync/destinations/gcp-sm.js b/ui/app/models/sync/destinations/gcp-sm.js index 0ddfb23a78..5f0dcf40b9 100644 --- a/ui/app/models/sync/destinations/gcp-sm.js +++ b/ui/app/models/sync/destinations/gcp-sm.js @@ -7,8 +7,11 @@ import SyncDestinationModel from '../destination'; import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; -const displayFields = ['name', 'credentials']; -const formFieldGroups = [{ default: ['name'] }, { Credentials: ['credentials'] }]; +const displayFields = ['name', 'credentials', 'secretNameTemplate', 'customTags']; +const formFieldGroups = [ + { default: ['name', 'secretNameTemplate', 'customTags'] }, + { Credentials: ['credentials'] }, +]; @withFormFields(displayFields, formFieldGroups) export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel { @attr('string', { @@ -20,5 +23,10 @@ export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncD }) credentials; // obfuscated, never returned by API - // TODO - confirm if project_id is going to be added to READ response (not editable) + @attr('object', { + subText: + 'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.', + editType: 'kv', + }) + customTags; } diff --git a/ui/app/models/sync/destinations/gh.js b/ui/app/models/sync/destinations/gh.js index 664269707a..955dcf3124 100644 --- a/ui/app/models/sync/destinations/gh.js +++ b/ui/app/models/sync/destinations/gh.js @@ -6,9 +6,9 @@ import SyncDestinationModel from '../destination'; import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; -const displayFields = ['name', 'repositoryOwner', 'repositoryName', 'accessToken']; +const displayFields = ['name', 'repositoryOwner', 'repositoryName', 'accessToken', 'secretNameTemplate']; const formFieldGroups = [ - { default: ['name', 'repositoryOwner', 'repositoryName'] }, + { default: ['name', 'repositoryOwner', 'repositoryName', 'secretNameTemplate'] }, { Credentials: ['accessToken'] }, ]; diff --git a/ui/app/models/sync/destinations/vercel-project.js b/ui/app/models/sync/destinations/vercel-project.js index 5f775a2a58..f40c73800f 100644 --- a/ui/app/models/sync/destinations/vercel-project.js +++ b/ui/app/models/sync/destinations/vercel-project.js @@ -21,9 +21,17 @@ const validations = { // getter/setter for the deploymentEnvironments model attribute deploymentEnvironmentsArray: [{ type: 'presence', message: 'At least one environment is required.' }], }; -const displayFields = ['name', 'accessToken', 'projectId', 'teamId', 'deploymentEnvironments']; + +const displayFields = [ + 'name', + 'accessToken', + 'projectId', + 'teamId', + 'deploymentEnvironments', + 'secretNameTemplate', +]; const formFieldGroups = [ - { default: ['name', 'projectId', 'teamId', 'deploymentEnvironments'] }, + { default: ['name', 'projectId', 'teamId', 'deploymentEnvironments', 'secretNameTemplate'] }, { Credentials: ['accessToken'] }, ]; @withModelValidations(validations) diff --git a/ui/app/serializers/sync/destination.js b/ui/app/serializers/sync/destination.js index e20b1920c5..c1fbde78fe 100644 --- a/ui/app/serializers/sync/destination.js +++ b/ui/app/serializers/sync/destination.js @@ -20,7 +20,12 @@ export default class SyncDestinationSerializer extends ApplicationSerializer { // only send changed parameters for PATCH requests const changedKeys = Object.keys(snapshot.changedAttributes()).map((key) => decamelize(key)); return changedKeys.reduce((payload, key) => { - payload[key] = data[key]; + if (JSON.stringify(data[key]) === '{}') { + // sending an empty object won't clear the previous param, set to null so PATCH removes pre-existing value + payload[key] = null; + } else { + payload[key] = data[key]; + } return payload; }, {}); } @@ -55,10 +60,11 @@ export default class SyncDestinationSerializer extends ApplicationSerializer { } else if (payload?.data) { // uses name for id and spreads connection_details object into data const { data } = payload; - const connection_details = payload.data.connection_details || {}; + const { connection_details, options } = data; data.id = data.name; delete data.connection_details; - return { data: { ...data, ...connection_details } }; + delete data.options; + return { data: { ...data, ...connection_details, ...options } }; } return payload; } diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index a43d5b320f..4f0fa8dbbf 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -133,12 +133,12 @@ @value={{get @model this.valuePath}} @onChange={{this.setAndBroadcast}} @label={{this.labelString}} - @labelClass="title {{if (eq @mode 'create') 'is-5' 'is-4'}}" + @labelClass={{if @attr.options.isSectionHeader "title is-4" "is-label"}} @helpText={{@attr.options.helpText}} @subText={{@attr.options.subText}} @onKeyUp={{this.handleKeyUp}} @validationError={{this.validationError}} - class="form-section" + class={{if @attr.options.isSectionHeader "form-section"}} /> {{else if (eq @attr.options.editType "file")}} {{! File Input }} diff --git a/ui/app/helpers/is-empty-value.js b/ui/lib/core/addon/helpers/is-empty-value.js similarity index 100% rename from ui/app/helpers/is-empty-value.js rename to ui/lib/core/addon/helpers/is-empty-value.js diff --git a/ui/lib/core/app/helpers/is-empty-value.js b/ui/lib/core/app/helpers/is-empty-value.js new file mode 100644 index 0000000000..80df11a442 --- /dev/null +++ b/ui/lib/core/app/helpers/is-empty-value.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/helpers/is-empty-value'; diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.hbs index 21eb43b890..5593fa5911 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.hbs @@ -18,22 +18,21 @@ Connection credentials are sensitive information and the value cannot be read. Enable the input to update. - {{/if}} - - {{#each fields as |attr|}} - {{#if (and (eq group "Credentials") (not @destination.isNew))}} + {{#each fields as |attr|}} - {{else}} + {{/each}} + {{else}} + {{#each fields as |attr|}} - {{/if}} - {{/each}} + {{/each}} + {{/if}} {{/each-in}} {{/each}} diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs index c9be9f94cd..137e510ee6 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs @@ -6,13 +6,22 @@ {{#each @destination.formFields as |field|}} - {{#let (get @destination field.name) as |value|}} + {{#let (get @destination field.name) as |fieldValue|}} {{#if (includes field.name @destination.maskedParams)}} - + + {{else if (eq field.name "customTags")}} + {{#unless (is-empty-value fieldValue)}} + + Custom tags + + {{/unless}} + {{#each-in fieldValue as |key value|}} + + {{/each-in}} {{else}} - + {{/if}} {{/let}} {{/each}} \ No newline at end of file diff --git a/ui/mirage/factories/sync-destination.js b/ui/mirage/factories/sync-destination.js index ace9da16f7..9195e6267f 100644 --- a/ui/mirage/factories/sync-destination.js +++ b/ui/mirage/factories/sync-destination.js @@ -9,38 +9,56 @@ export default Factory.extend({ ['aws-sm']: trait({ type: 'aws-sm', name: 'destination-aws', + // connection_details access_key_id: '*****', secret_access_key: '*****', region: 'us-west-1', + // options + secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', + custom_tags: { foo: 'bar' }, }), ['azure-kv']: trait({ type: 'azure-kv', name: 'destination-azure', + // connection_details key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net', subscription_id: 'subscription-id', tenant_id: 'tenant-id', client_id: 'azure-client-id', client_secret: '*****', cloud: 'Azure Public Cloud', + // options + secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', + custom_tags: { foo: 'bar' }, }), ['gcp-sm']: trait({ type: 'gcp-sm', name: 'destination-gcp', + // connection_details credentials: '*****', + // options + secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', + custom_tags: { foo: 'bar' }, }), gh: trait({ type: 'gh', name: 'destination-gh', + // connection_details access_token: '*****', repository_owner: 'my-organization-or-username', repository_name: 'my-repository', + // options + secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', }), ['vercel-project']: trait({ type: 'vercel-project', name: 'destination-vercel', + // connection_details access_token: '*****', project_id: 'prj_12345', team_id: 'team_12345', deployment_environments: ['development', 'preview'], // 'production' is also an option, but left out for testing to assert form changes value + // options + secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', }), }); diff --git a/ui/tests/helpers/general-selectors.js b/ui/tests/helpers/general-selectors.js index 36ee70c5f9..edd229e7a1 100644 --- a/ui/tests/helpers/general-selectors.js +++ b/ui/tests/helpers/general-selectors.js @@ -25,6 +25,7 @@ export const SELECTORS = { // FORMS infoRowValue: (label) => `[data-test-value-div="${label}"]`, inputByAttr: (attr) => `[data-test-input="${attr}"]`, + fieldByAttr: (attr) => `[data-test-field="${attr}"]`, validation: (attr) => `[data-test-field-validation=${attr}]`, validationWarning: (attr) => `[data-test-validation-warning=${attr}]`, messageError: '[data-test-message-error]', diff --git a/ui/tests/helpers/sync/sync-selectors.js b/ui/tests/helpers/sync/sync-selectors.js index ca72fa5f2a..ec0f3ae39a 100644 --- a/ui/tests/helpers/sync/sync-selectors.js +++ b/ui/tests/helpers/sync/sync-selectors.js @@ -27,6 +27,9 @@ export const PAGE = { }, destinations: { deleteBanner: '[data-test-delete-status-banner]', + details: { + sectionHeader: '[data-test-section-header]', + }, sync: { mountSelect: '[data-test-sync-mount-select]', mountInput: '[data-test-sync-mount-input]', @@ -71,6 +74,9 @@ export const PAGE = { case 'credentials': await click('[data-test-text-toggle]'); return fillIn('[data-test-text-file-textarea]', value); + case 'customTags': + await fillIn('[data-test-kv-key="0"]', 'foo'); + return fillIn('[data-test-kv-value="0"]', value); case 'deploymentEnvironments': await click('[data-test-input="deploymentEnvironments"] input#development'); await click('[data-test-input="deploymentEnvironments"] input#preview'); diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js index 8fdc78b81f..6da9a9ad95 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js @@ -144,7 +144,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE assert.dom(PAGE.title).hasTextContaining(`Create Destination for ${name}`); for (const attr of this.model.formFields) { - assert.dom(PAGE.inputByAttr(attr.name)).exists(); + assert.dom(PAGE.fieldByAttr(attr.name)).exists(); } }); @@ -206,16 +206,18 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE // EDIT FORM ASSERTIONS FOR EACH DESTINATION TYPE const EDITABLE_FIELDS = { - 'aws-sm': ['accessKeyId', 'secretAccessKey'], - 'azure-kv': ['clientId', 'clientSecret'], - 'gcp-sm': ['credentials'], - gh: ['accessToken'], - 'vercel-project': ['accessToken', 'teamId', 'deploymentEnvironments'], + 'aws-sm': ['accessKeyId', 'secretAccessKey', 'secretNameTemplate', 'customTags'], + 'azure-kv': ['clientId', 'clientSecret', 'secretNameTemplate', 'customTags'], + 'gcp-sm': ['credentials', 'secretNameTemplate', 'customTags'], + gh: ['accessToken', 'secretNameTemplate'], + 'vercel-project': ['accessToken', 'teamId', 'deploymentEnvironments', 'secretNameTemplate'], }; const EXPECTED_VALUE = (key) => { switch (key) { case 'deployment_environments': return ['production']; + case 'custom_tags': + return { foo: `new-${key}-value` }; default: // for all string type parameters return `new-${key}-value`; diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js index a41fd0c349..95e6a79fc8 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js @@ -74,7 +74,7 @@ module( this.unmaskedAttrs = this.model.formFields.filter((attr) => !maskedParams.includes(attr.name)); }); - test('it renders destination details with connection_details', async function (assert) { + test('it renders destination details with connection_details and options', async function (assert) { assert.expect(this.model.formFields.length); await this.renderFormComponent(); @@ -86,23 +86,36 @@ module( }); // assert the remaining model attributes render - this.unmaskedAttrs.forEach(({ name, options }) => { - const label = options.label || toLabel([name]); - const value = Array.isArray(this.model[name]) ? this.model[name].join(',') : this.model[name]; + this.unmaskedAttrs.forEach(({ name, options, type }) => { + let label, value; + if (type === 'object') { + [label] = Object.keys(this.model[name]); + [value] = Object.values(this.model[name]); + } else { + label = options.label || toLabel([name]); + value = Array.isArray(this.model[name]) ? this.model[name].join(',') : this.model[name]; + } assert.dom(PAGE.infoRowValue(label)).hasText(value); }); }); - test('it renders destination details without connection_details', async function (assert) { - assert.expect(this.maskedAttrs.length + 3); + test('it renders destination details without connection_details or options', async function (assert) { + assert.expect(this.maskedAttrs.length + 4); this.maskedAttrs.forEach((attr) => { // these values are undefined when environment variables are set this.model[attr.name] = undefined; }); + // assert custom tags section header does not render + if (this.model?.get('customTags')) { + this.model['customTags'] = undefined; + } await this.renderFormComponent(); + assert + .dom(PAGE.destinations.details.sectionHeader) + .doesNotExist('does not render Custom tags header'); assert.dom(PAGE.title).hasTextContaining(this.model.name); assert.dom(PAGE.icon(this.model.icon)).exists(); assert.dom(PAGE.infoRowValue('Name')).hasText(this.model.name);