mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
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:
@@ -24,6 +24,7 @@ export default IdentityModel.extend({
|
||||
}),
|
||||
metadata: attr({
|
||||
editType: 'kv',
|
||||
isSectionHeader: true,
|
||||
}),
|
||||
mountPath: attr('string', {
|
||||
readOnly: true,
|
||||
|
||||
@@ -23,6 +23,7 @@ export default IdentityModel.extend({
|
||||
mergedEntityIds: attr(),
|
||||
metadata: attr({
|
||||
editType: 'kv',
|
||||
isSectionHeader: true,
|
||||
}),
|
||||
policies: attr({
|
||||
editType: 'yield',
|
||||
|
||||
@@ -37,6 +37,7 @@ export default IdentityModel.extend({
|
||||
}),
|
||||
metadata: attr('object', {
|
||||
editType: 'kv',
|
||||
isSectionHeader: true,
|
||||
}),
|
||||
policies: attr({
|
||||
editType: 'yield',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
6
ui/lib/core/app/helpers/is-empty-value.js
Normal file
6
ui/lib/core/app/helpers/is-empty-value.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/is-empty-value';
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 }}',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user