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({
editType: 'kv',
isSectionHeader: true,
}),
mountPath: attr('string', {
readOnly: true,

View File

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

View File

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

View File

@@ -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;

View File

@@ -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-<accessor_id>-<secret_path>" e.g. "vault-kv-1234-my-secret-1".',
})
secretNameTemplate;
// only present if delete action has been initiated
@attr('string') purgeInitiatedAt;
@attr('string') purgeError;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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'] },
];

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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 }}

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">
Connection credentials are sensitive information and the value cannot be read. Enable the input to update.
</Hds::Text::Body>
{{/if}}
{{#each fields as |attr|}}
{{#if (and (eq group "Credentials") (not @destination.isNew))}}
{{#each fields as |attr|}}
<EnableInput data-test-enable-field={{attr.name}} class="field" @attr={{attr}}>
<FormField @attr={{attr}} @model={{@destination}} @modelValidations={{this.modelValidations}} />
</EnableInput>
{{else}}
{{/each}}
{{else}}
{{#each fields as |attr|}}
<FormField
@attr={{attr}}
@model={{@destination}}
@modelValidations={{this.modelValidations}}
@onKeyUp={{this.updateWarningValidation}}
/>
{{/if}}
{{/each}}
{{/each}}
{{/if}}
{{/each-in}}
{{/each}}

View File

@@ -6,13 +6,22 @@
<Secrets::DestinationHeader @destination={{@destination}} />
{{#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)}}
<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>
{{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}}
<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}}
{{/let}}
{{/each}}

View File

@@ -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 }}',
}),
});

View File

@@ -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]',

View File

@@ -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');

View File

@@ -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`;

View File

@@ -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);