Secrets Sync UI: Add sync destination fields custom_tags and secret_name_template (#24930)

* leverage isSectionHeader option to change component styling

* update destination models to include new params

* update form and details template to accommodate new fields

* remove extra horizontal line

* move is-empty-value to core addon and use in details template

* remove leftover or conditional

* update mirage and tests

* update form tests
This commit is contained in:
claire bontempo
2024-01-18 12:15:52 -08:00
committed by GitHub
parent fb71d7f3c8
commit d0d66266c7
21 changed files with 155 additions and 38 deletions

View File

@@ -24,6 +24,7 @@ export default IdentityModel.extend({
}), }),
metadata: attr({ metadata: attr({
editType: 'kv', editType: 'kv',
isSectionHeader: true,
}), }),
mountPath: attr('string', { mountPath: attr('string', {
readOnly: true, readOnly: true,

View File

@@ -23,6 +23,7 @@ export default IdentityModel.extend({
mergedEntityIds: attr(), mergedEntityIds: attr(),
metadata: attr({ metadata: attr({
editType: 'kv', editType: 'kv',
isSectionHeader: true,
}), }),
policies: attr({ policies: attr({
editType: 'yield', editType: 'yield',

View File

@@ -37,6 +37,7 @@ export default IdentityModel.extend({
}), }),
metadata: attr('object', { metadata: attr('object', {
editType: 'kv', editType: 'kv',
isSectionHeader: true,
}), }),
policies: attr({ policies: attr({
editType: 'yield', editType: 'yield',

View File

@@ -51,6 +51,7 @@ export default class KvSecretMetadataModel extends Model {
@attr('object', { @attr('object', {
editType: 'kv', editType: 'kv',
isSectionHeader: true,
subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.',
}) })
customMetadata; customMetadata;

View File

@@ -20,6 +20,12 @@ const validations = {
export default class SyncDestinationModel extends Model { export default class SyncDestinationModel extends Model {
@attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name; @attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name;
@attr type; @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-<accessor_id>-<secret_path>" e.g. "vault-kv-1234-my-secret-1".',
})
secretNameTemplate;
// only present if delete action has been initiated // only present if delete action has been initiated
@attr('string') purgeInitiatedAt; @attr('string') purgeInitiatedAt;
@attr('string') purgeError; @attr('string') purgeError;

View File

@@ -7,9 +7,16 @@ import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model'; import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields'; import { withFormFields } from 'vault/decorators/model-form-fields';
const displayFields = ['name', 'region', 'accessKeyId', 'secretAccessKey']; const displayFields = [
'name',
'region',
'accessKeyId',
'secretAccessKey',
'secretNameTemplate',
'customTags',
];
const formFieldGroups = [ const formFieldGroups = [
{ default: ['name', 'region'] }, { default: ['name', 'region', 'secretNameTemplate', 'customTags'] },
{ Credentials: ['accessKeyId', 'secretAccessKey'] }, { Credentials: ['accessKeyId', 'secretAccessKey'] },
]; ];
@withFormFields(displayFields, formFieldGroups) @withFormFields(displayFields, formFieldGroups)
@@ -34,4 +41,11 @@ export default class SyncDestinationsAwsSecretsManagerModel extends SyncDestinat
editDisabled: true, editDisabled: true,
}) })
region; 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;
} }

View File

@@ -6,9 +6,19 @@
import SyncDestinationModel from '../destination'; import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model'; import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields'; 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 = [ const formFieldGroups = [
{ default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId'] }, { default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'secretNameTemplate', 'customTags'] },
{ Credentials: ['clientSecret'] }, { Credentials: ['clientSecret'] },
]; ];
@withFormFields(displayFields, formFieldGroups) @withFormFields(displayFields, formFieldGroups)
@@ -47,4 +57,11 @@ export default class SyncDestinationsAzureKeyVaultModel extends SyncDestinationM
editDisabled: true, editDisabled: true,
}) })
cloud; 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;
} }

View File

@@ -7,8 +7,11 @@ import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model'; import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields'; import { withFormFields } from 'vault/decorators/model-form-fields';
const displayFields = ['name', 'credentials']; const displayFields = ['name', 'credentials', 'secretNameTemplate', 'customTags'];
const formFieldGroups = [{ default: ['name'] }, { Credentials: ['credentials'] }]; const formFieldGroups = [
{ default: ['name', 'secretNameTemplate', 'customTags'] },
{ Credentials: ['credentials'] },
];
@withFormFields(displayFields, formFieldGroups) @withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel { export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel {
@attr('string', { @attr('string', {
@@ -20,5 +23,10 @@ export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncD
}) })
credentials; // obfuscated, never returned by API 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;
} }

View File

@@ -6,9 +6,9 @@
import SyncDestinationModel from '../destination'; import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model'; import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields'; import { withFormFields } from 'vault/decorators/model-form-fields';
const displayFields = ['name', 'repositoryOwner', 'repositoryName', 'accessToken']; const displayFields = ['name', 'repositoryOwner', 'repositoryName', 'accessToken', 'secretNameTemplate'];
const formFieldGroups = [ const formFieldGroups = [
{ default: ['name', 'repositoryOwner', 'repositoryName'] }, { default: ['name', 'repositoryOwner', 'repositoryName', 'secretNameTemplate'] },
{ Credentials: ['accessToken'] }, { Credentials: ['accessToken'] },
]; ];

View File

@@ -21,9 +21,17 @@ const validations = {
// getter/setter for the deploymentEnvironments model attribute // getter/setter for the deploymentEnvironments model attribute
deploymentEnvironmentsArray: [{ type: 'presence', message: 'At least one environment is required.' }], 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 = [ const formFieldGroups = [
{ default: ['name', 'projectId', 'teamId', 'deploymentEnvironments'] }, { default: ['name', 'projectId', 'teamId', 'deploymentEnvironments', 'secretNameTemplate'] },
{ Credentials: ['accessToken'] }, { Credentials: ['accessToken'] },
]; ];
@withModelValidations(validations) @withModelValidations(validations)

View File

@@ -20,7 +20,12 @@ export default class SyncDestinationSerializer extends ApplicationSerializer {
// only send changed parameters for PATCH requests // only send changed parameters for PATCH requests
const changedKeys = Object.keys(snapshot.changedAttributes()).map((key) => decamelize(key)); const changedKeys = Object.keys(snapshot.changedAttributes()).map((key) => decamelize(key));
return changedKeys.reduce((payload, 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; return payload;
}, {}); }, {});
} }
@@ -55,10 +60,11 @@ export default class SyncDestinationSerializer extends ApplicationSerializer {
} else if (payload?.data) { } else if (payload?.data) {
// uses name for id and spreads connection_details object into data // uses name for id and spreads connection_details object into data
const { data } = payload; const { data } = payload;
const connection_details = payload.data.connection_details || {}; const { connection_details, options } = data;
data.id = data.name; data.id = data.name;
delete data.connection_details; delete data.connection_details;
return { data: { ...data, ...connection_details } }; delete data.options;
return { data: { ...data, ...connection_details, ...options } };
} }
return payload; return payload;
} }

View File

@@ -133,12 +133,12 @@
@value={{get @model this.valuePath}} @value={{get @model this.valuePath}}
@onChange={{this.setAndBroadcast}} @onChange={{this.setAndBroadcast}}
@label={{this.labelString}} @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}} @helpText={{@attr.options.helpText}}
@subText={{@attr.options.subText}} @subText={{@attr.options.subText}}
@onKeyUp={{this.handleKeyUp}} @onKeyUp={{this.handleKeyUp}}
@validationError={{this.validationError}} @validationError={{this.validationError}}
class="form-section" class={{if @attr.options.isSectionHeader "form-section"}}
/> />
{{else if (eq @attr.options.editType "file")}} {{else if (eq @attr.options.editType "file")}}
{{! File Input }} {{! File Input }}

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/helpers/is-empty-value';

View File

@@ -18,22 +18,21 @@
<Hds::Text::Body @tag="p" @size="100" @color="faint" class="has-bottom-margin-m"> <Hds::Text::Body @tag="p" @size="100" @color="faint" class="has-bottom-margin-m">
Connection credentials are sensitive information and the value cannot be read. Enable the input to update. Connection credentials are sensitive information and the value cannot be read. Enable the input to update.
</Hds::Text::Body> </Hds::Text::Body>
{{/if}} {{#each fields as |attr|}}
{{#each fields as |attr|}}
{{#if (and (eq group "Credentials") (not @destination.isNew))}}
<EnableInput data-test-enable-field={{attr.name}} class="field" @attr={{attr}}> <EnableInput data-test-enable-field={{attr.name}} class="field" @attr={{attr}}>
<FormField @attr={{attr}} @model={{@destination}} @modelValidations={{this.modelValidations}} /> <FormField @attr={{attr}} @model={{@destination}} @modelValidations={{this.modelValidations}} />
</EnableInput> </EnableInput>
{{else}} {{/each}}
{{else}}
{{#each fields as |attr|}}
<FormField <FormField
@attr={{attr}} @attr={{attr}}
@model={{@destination}} @model={{@destination}}
@modelValidations={{this.modelValidations}} @modelValidations={{this.modelValidations}}
@onKeyUp={{this.updateWarningValidation}} @onKeyUp={{this.updateWarningValidation}}
/> />
{{/if}} {{/each}}
{{/each}} {{/if}}
{{/each-in}} {{/each-in}}
{{/each}} {{/each}}

View File

@@ -6,13 +6,22 @@
<Secrets::DestinationHeader @destination={{@destination}} /> <Secrets::DestinationHeader @destination={{@destination}} />
{{#each @destination.formFields as |field|}} {{#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)}} {{#if (includes field.name @destination.maskedParams)}}
<InfoTableRow @label={{or field.options.label (to-label field.name)}}> <InfoTableRow @label={{or field.options.label (to-label field.name)}}>
<Hds::Badge @text={{this.credentialValue value}} @icon="check-circle" @color="success" /> <Hds::Badge @text={{this.credentialValue fieldValue}} @icon="check-circle" @color="success" />
</InfoTableRow> </InfoTableRow>
{{else if (eq field.name "customTags")}}
{{#unless (is-empty-value fieldValue)}}
<Hds::Text::Display @tag="h3" @size="300" @weight="semibold" class="has-top-margin-l" data-test-section-header>
Custom tags
</Hds::Text::Display>
{{/unless}}
{{#each-in fieldValue as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{/each-in}}
{{else}} {{else}}
<InfoTableRow @label={{or field.options.label (to-label field.name)}} @value={{value}} /> <InfoTableRow @label={{or field.options.label (to-label field.name)}} @value={{fieldValue}} />
{{/if}} {{/if}}
{{/let}} {{/let}}
{{/each}} {{/each}}

View File

@@ -9,38 +9,56 @@ export default Factory.extend({
['aws-sm']: trait({ ['aws-sm']: trait({
type: 'aws-sm', type: 'aws-sm',
name: 'destination-aws', name: 'destination-aws',
// connection_details
access_key_id: '*****', access_key_id: '*****',
secret_access_key: '*****', secret_access_key: '*****',
region: 'us-west-1', region: 'us-west-1',
// options
secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}',
custom_tags: { foo: 'bar' },
}), }),
['azure-kv']: trait({ ['azure-kv']: trait({
type: 'azure-kv', type: 'azure-kv',
name: 'destination-azure', name: 'destination-azure',
// connection_details
key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net', key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net',
subscription_id: 'subscription-id', subscription_id: 'subscription-id',
tenant_id: 'tenant-id', tenant_id: 'tenant-id',
client_id: 'azure-client-id', client_id: 'azure-client-id',
client_secret: '*****', client_secret: '*****',
cloud: 'Azure Public Cloud', cloud: 'Azure Public Cloud',
// options
secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}',
custom_tags: { foo: 'bar' },
}), }),
['gcp-sm']: trait({ ['gcp-sm']: trait({
type: 'gcp-sm', type: 'gcp-sm',
name: 'destination-gcp', name: 'destination-gcp',
// connection_details
credentials: '*****', credentials: '*****',
// options
secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}',
custom_tags: { foo: 'bar' },
}), }),
gh: trait({ gh: trait({
type: 'gh', type: 'gh',
name: 'destination-gh', name: 'destination-gh',
// connection_details
access_token: '*****', access_token: '*****',
repository_owner: 'my-organization-or-username', repository_owner: 'my-organization-or-username',
repository_name: 'my-repository', repository_name: 'my-repository',
// options
secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}',
}), }),
['vercel-project']: trait({ ['vercel-project']: trait({
type: 'vercel-project', type: 'vercel-project',
name: 'destination-vercel', name: 'destination-vercel',
// connection_details
access_token: '*****', access_token: '*****',
project_id: 'prj_12345', project_id: 'prj_12345',
team_id: 'team_12345', team_id: 'team_12345',
deployment_environments: ['development', 'preview'], // 'production' is also an option, but left out for testing to assert form changes value 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 }}',
}), }),
}); });

View File

@@ -25,6 +25,7 @@ export const SELECTORS = {
// FORMS // FORMS
infoRowValue: (label) => `[data-test-value-div="${label}"]`, infoRowValue: (label) => `[data-test-value-div="${label}"]`,
inputByAttr: (attr) => `[data-test-input="${attr}"]`, inputByAttr: (attr) => `[data-test-input="${attr}"]`,
fieldByAttr: (attr) => `[data-test-field="${attr}"]`,
validation: (attr) => `[data-test-field-validation=${attr}]`, validation: (attr) => `[data-test-field-validation=${attr}]`,
validationWarning: (attr) => `[data-test-validation-warning=${attr}]`, validationWarning: (attr) => `[data-test-validation-warning=${attr}]`,
messageError: '[data-test-message-error]', messageError: '[data-test-message-error]',

View File

@@ -27,6 +27,9 @@ export const PAGE = {
}, },
destinations: { destinations: {
deleteBanner: '[data-test-delete-status-banner]', deleteBanner: '[data-test-delete-status-banner]',
details: {
sectionHeader: '[data-test-section-header]',
},
sync: { sync: {
mountSelect: '[data-test-sync-mount-select]', mountSelect: '[data-test-sync-mount-select]',
mountInput: '[data-test-sync-mount-input]', mountInput: '[data-test-sync-mount-input]',
@@ -71,6 +74,9 @@ export const PAGE = {
case 'credentials': case 'credentials':
await click('[data-test-text-toggle]'); await click('[data-test-text-toggle]');
return fillIn('[data-test-text-file-textarea]', value); 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': case 'deploymentEnvironments':
await click('[data-test-input="deploymentEnvironments"] input#development'); await click('[data-test-input="deploymentEnvironments"] input#development');
await click('[data-test-input="deploymentEnvironments"] input#preview'); await click('[data-test-input="deploymentEnvironments"] input#preview');

View File

@@ -144,7 +144,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE
assert.dom(PAGE.title).hasTextContaining(`Create Destination for ${name}`); assert.dom(PAGE.title).hasTextContaining(`Create Destination for ${name}`);
for (const attr of this.model.formFields) { 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 // EDIT FORM ASSERTIONS FOR EACH DESTINATION TYPE
const EDITABLE_FIELDS = { const EDITABLE_FIELDS = {
'aws-sm': ['accessKeyId', 'secretAccessKey'], 'aws-sm': ['accessKeyId', 'secretAccessKey', 'secretNameTemplate', 'customTags'],
'azure-kv': ['clientId', 'clientSecret'], 'azure-kv': ['clientId', 'clientSecret', 'secretNameTemplate', 'customTags'],
'gcp-sm': ['credentials'], 'gcp-sm': ['credentials', 'secretNameTemplate', 'customTags'],
gh: ['accessToken'], gh: ['accessToken', 'secretNameTemplate'],
'vercel-project': ['accessToken', 'teamId', 'deploymentEnvironments'], 'vercel-project': ['accessToken', 'teamId', 'deploymentEnvironments', 'secretNameTemplate'],
}; };
const EXPECTED_VALUE = (key) => { const EXPECTED_VALUE = (key) => {
switch (key) { switch (key) {
case 'deployment_environments': case 'deployment_environments':
return ['production']; return ['production'];
case 'custom_tags':
return { foo: `new-${key}-value` };
default: default:
// for all string type parameters // for all string type parameters
return `new-${key}-value`; return `new-${key}-value`;

View File

@@ -74,7 +74,7 @@ module(
this.unmaskedAttrs = this.model.formFields.filter((attr) => !maskedParams.includes(attr.name)); 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); assert.expect(this.model.formFields.length);
await this.renderFormComponent(); await this.renderFormComponent();
@@ -86,23 +86,36 @@ module(
}); });
// assert the remaining model attributes render // assert the remaining model attributes render
this.unmaskedAttrs.forEach(({ name, options }) => { this.unmaskedAttrs.forEach(({ name, options, type }) => {
const label = options.label || toLabel([name]); let label, value;
const value = Array.isArray(this.model[name]) ? this.model[name].join(',') : this.model[name]; 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); assert.dom(PAGE.infoRowValue(label)).hasText(value);
}); });
}); });
test('it renders destination details without connection_details', async function (assert) { test('it renders destination details without connection_details or options', async function (assert) {
assert.expect(this.maskedAttrs.length + 3); assert.expect(this.maskedAttrs.length + 4);
this.maskedAttrs.forEach((attr) => { this.maskedAttrs.forEach((attr) => {
// these values are undefined when environment variables are set // these values are undefined when environment variables are set
this.model[attr.name] = undefined; 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(); 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.title).hasTextContaining(this.model.name);
assert.dom(PAGE.icon(this.model.icon)).exists(); assert.dom(PAGE.icon(this.model.icon)).exists();
assert.dom(PAGE.infoRowValue('Name')).hasText(this.model.name); assert.dom(PAGE.infoRowValue('Name')).hasText(this.model.name);