diff --git a/changelog/25500.txt b/changelog/25500.txt new file mode 100644 index 0000000000..22711e8f44 --- /dev/null +++ b/changelog/25500.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: add granularity param to sync destinations +``` diff --git a/ui/app/models/sync/association.js b/ui/app/models/sync/association.js index 1bcc86437d..d32c2cd29a 100644 --- a/ui/app/models/sync/association.js +++ b/ui/app/models/sync/association.js @@ -14,6 +14,7 @@ export default class SyncAssociationModel extends Model { // destination related properties that are not serialized to payload @attr destinationName; @attr destinationType; + @attr subKey; // this property is added if a destination has 'secret-key' granularity @lazyCapabilities( apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/set`, diff --git a/ui/app/models/sync/destination.js b/ui/app/models/sync/destination.js index 9b970356ff..f040e68c2f 100644 --- a/ui/app/models/sync/destination.js +++ b/ui/app/models/sync/destination.js @@ -25,6 +25,26 @@ export default class SyncDestinationModel extends Model { '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; + @attr('string', { + editType: 'radio', + label: 'Secret sync granularity', + possibleValues: [ + { + label: 'Secret path', + subText: 'Sync entire secret contents as a single entry at the destination.', + value: 'secret-path', + }, + { + label: 'Secret key', + subText: 'Sync each key-value pair of secret data as a distinct entry at the destination.', + helpText: + 'Only top-level keys will be synced and any nested or complex values will be encoded as a JSON string.', + value: 'secret-key', + }, + ], + defaultValue: 'secret-path', + }) + granularity; // only present if delete action has been initiated @attr('string') purgeInitiatedAt; diff --git a/ui/app/models/sync/destinations/aws-sm.js b/ui/app/models/sync/destinations/aws-sm.js index 865208d784..27e6dcc290 100644 --- a/ui/app/models/sync/destinations/aws-sm.js +++ b/ui/app/models/sync/destinations/aws-sm.js @@ -8,15 +8,18 @@ import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; const displayFields = [ + // connection details 'name', 'region', 'accessKeyId', 'secretAccessKey', + // sync config options + 'granularity', 'secretNameTemplate', 'customTags', ]; const formFieldGroups = [ - { default: ['name', 'region', 'secretNameTemplate', 'customTags'] }, + { default: ['name', 'region', 'granularity', 'secretNameTemplate', 'customTags'] }, { Credentials: ['accessKeyId', 'secretAccessKey'] }, ]; @withFormFields(displayFields, formFieldGroups) diff --git a/ui/app/models/sync/destinations/azure-kv.js b/ui/app/models/sync/destinations/azure-kv.js index 91cb6bb0cd..42de9a1730 100644 --- a/ui/app/models/sync/destinations/azure-kv.js +++ b/ui/app/models/sync/destinations/azure-kv.js @@ -8,17 +8,31 @@ import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; const displayFields = [ + // connection details 'name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'clientSecret', + // vault sync config options + 'granularity', 'secretNameTemplate', 'customTags', ]; const formFieldGroups = [ - { default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'secretNameTemplate', 'customTags'] }, + { + default: [ + 'name', + 'keyVaultUri', + 'tenantId', + 'cloud', + 'clientId', + 'granularity', + 'secretNameTemplate', + 'customTags', + ], + }, { Credentials: ['clientSecret'] }, ]; @withFormFields(displayFields, formFieldGroups) diff --git a/ui/app/models/sync/destinations/gcp-sm.js b/ui/app/models/sync/destinations/gcp-sm.js index 5f0dcf40b9..1a901899a5 100644 --- a/ui/app/models/sync/destinations/gcp-sm.js +++ b/ui/app/models/sync/destinations/gcp-sm.js @@ -7,9 +7,17 @@ import SyncDestinationModel from '../destination'; import { attr } from '@ember-data/model'; import { withFormFields } from 'vault/decorators/model-form-fields'; -const displayFields = ['name', 'credentials', 'secretNameTemplate', 'customTags']; +const displayFields = [ + // connection details + 'name', + 'credentials', + // vault sync config options + 'granularity', + 'secretNameTemplate', + 'customTags', +]; const formFieldGroups = [ - { default: ['name', 'secretNameTemplate', 'customTags'] }, + { default: ['name', 'granularity', 'secretNameTemplate', 'customTags'] }, { Credentials: ['credentials'] }, ]; @withFormFields(displayFields, formFieldGroups) diff --git a/ui/app/models/sync/destinations/gh.js b/ui/app/models/sync/destinations/gh.js index 955dcf3124..a8db62a9a8 100644 --- a/ui/app/models/sync/destinations/gh.js +++ b/ui/app/models/sync/destinations/gh.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', 'repositoryOwner', 'repositoryName', 'accessToken', 'secretNameTemplate']; + +const displayFields = [ + // connection details + 'name', + 'repositoryOwner', + 'repositoryName', + 'accessToken', + // vault sync config options + 'granularity', + 'secretNameTemplate', +]; const formFieldGroups = [ - { default: ['name', 'repositoryOwner', 'repositoryName', 'secretNameTemplate'] }, + { default: ['name', 'repositoryOwner', 'repositoryName', 'granularity', 'secretNameTemplate'] }, { Credentials: ['accessToken'] }, ]; diff --git a/ui/app/models/sync/destinations/vercel-project.js b/ui/app/models/sync/destinations/vercel-project.js index f40c73800f..aaa88f671c 100644 --- a/ui/app/models/sync/destinations/vercel-project.js +++ b/ui/app/models/sync/destinations/vercel-project.js @@ -23,15 +23,18 @@ const validations = { }; const displayFields = [ + // connection details 'name', 'accessToken', 'projectId', 'teamId', 'deploymentEnvironments', + // vault sync config options + 'granularity', 'secretNameTemplate', ]; const formFieldGroups = [ - { default: ['name', 'projectId', 'teamId', 'deploymentEnvironments', 'secretNameTemplate'] }, + { default: ['name', 'projectId', 'teamId', 'deploymentEnvironments', 'granularity', 'secretNameTemplate'] }, { Credentials: ['accessToken'] }, ]; @withModelValidations(validations) diff --git a/ui/app/serializers/sync/association.js b/ui/app/serializers/sync/association.js index a0f83e1a9e..695d90df4f 100644 --- a/ui/app/serializers/sync/association.js +++ b/ui/app/serializers/sync/association.js @@ -12,6 +12,7 @@ export default class SyncAssociationSerializer extends ApplicationSerializer { destinationType: { serialize: false }, syncStatus: { serialize: false }, updatedAt: { serialize: false }, + subKey: { serialize: false }, }; extractLazyPaginatedData(payload) { diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index 3dfb3dd23c..03cd70cb08 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -40,7 +40,12 @@ class="has-left-margin-xs has-text-black is-size-7" data-test-radio-label={{or val.label val.value val}} > - {{or val.label val.value val}} + {{or val.label val.value val}} + {{#if val.helpText}} + + + + {{/if}} {{#if this.hasRadioSubText}}

{{val.subText}} diff --git a/ui/lib/sync/addon/components/secrets/page/destinations.hbs b/ui/lib/sync/addon/components/secrets/page/destinations.hbs index a1cf7ecbea..be25d891fe 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations.hbs @@ -67,44 +67,44 @@ - - {{#if destination.destinationPath.isLoading}} -

  • - -
  • - {{else}} -
  • - + + + {{#if destination.destinationPath.isLoading}} + + + + {{else}} + - Details - -
  • -
  • - - Edit - -
  • - {{#if destination.canDelete}} - + {{#if destination.canEdit}} + + {{/if}} + {{#if destination.canDelete}} + + {{/if}} {{/if}} - {{/if}} + {{/each}} @@ -120,4 +120,13 @@ {{else}} +{{/if}} + +{{#if this.destinationToDelete}} + {{/if}} \ No newline at end of file diff --git a/ui/lib/sync/addon/components/secrets/page/destinations.ts b/ui/lib/sync/addon/components/secrets/page/destinations.ts index 2ecb5b7be5..95b40bbea6 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations.ts +++ b/ui/lib/sync/addon/components/secrets/page/destinations.ts @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { getOwner } from '@ember/application'; import errorMessage from 'vault/utils/error-message'; import { findDestination, syncDestinations } from 'core/helpers/sync-destinations'; @@ -30,6 +31,7 @@ export default class SyncSecretsDestinationsPageComponent extends Component
    - + + + - {{association.secretName}} - + >{{association.secretName}} + {{#if association.subKey}} + + {{/if}}
    @@ -30,42 +33,49 @@
    - - {{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}} -
  • - -
  • - {{else}} -
  • - -
  • -
  • - + + + {{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}} + + + + {{else}} + {{#if (eq @destination.granularity "secret-key")}} + + + {{/if}} + {{#if association.canSync}} + + {{/if}} + - View secret - -
  • - {{#if association.canUnsync}} - + {{#if association.canUnsync}} + + {{/if}} {{/if}} - {{/if}} +
    {{/each}} @@ -92,4 +102,13 @@ @route="secrets.destinations.destination.sync" /> +{{/if}} + +{{#if this.secretToUnsync}} + {{/if}} \ No newline at end of file diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts index 94a4ece721..56510bef96 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { getOwner } from '@ember/application'; import errorMessage from 'vault/utils/error-message'; @@ -26,6 +27,8 @@ export default class SyncSecretsDestinationsPageComponent extends Componentsecret key + granularity. Each key-value pair of the selected secret will sync as a distinct entry at the destination. + {{/if}}

    diff --git a/ui/mirage/factories/sync-destination.js b/ui/mirage/factories/sync-destination.js index 9195e6267f..97898eb005 100644 --- a/ui/mirage/factories/sync-destination.js +++ b/ui/mirage/factories/sync-destination.js @@ -14,6 +14,7 @@ export default Factory.extend({ secret_access_key: '*****', region: 'us-west-1', // options + granularity: 'secret-path', // default option (same for all destinations) so edit test can update to 'secret-key' secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', custom_tags: { foo: 'bar' }, }), @@ -28,6 +29,7 @@ export default Factory.extend({ client_secret: '*****', cloud: 'Azure Public Cloud', // options + granularity: 'secret-path', secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', custom_tags: { foo: 'bar' }, }), @@ -37,6 +39,7 @@ export default Factory.extend({ // connection_details credentials: '*****', // options + granularity: 'secret-path', secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', custom_tags: { foo: 'bar' }, }), @@ -48,6 +51,7 @@ export default Factory.extend({ repository_owner: 'my-organization-or-username', repository_name: 'my-repository', // options + granularity: 'secret-path', secret_name_template: 'vault-{{ .MountAccessor | replace "_" "-" }}-{{ .SecretPath }}', }), ['vercel-project']: trait({ diff --git a/ui/mirage/handlers/sync.js b/ui/mirage/handlers/sync.js index eba258f42a..4ec06a9a69 100644 --- a/ui/mirage/handlers/sync.js +++ b/ui/mirage/handlers/sync.js @@ -10,19 +10,48 @@ import clientsHandler from './clients'; export const associationsResponse = (schema, req) => { const { type, name } = req.params; + const [destination] = schema.db.syncDestinations.where({ type, name }); const records = schema.db.syncAssociations.where({ type, name }); + const associations = records.length + ? records.reduce((associations, association) => { + const key = `${association.mount}/${association.secret_name}`; + delete association.type; + delete association.name; + associations[key] = association; + return associations; + }, {}) + : {}; + + // if a destination has granularity: 'secret-key' keys of the secret + // are added to the association response but they are not individual associations + // the secret itself is still a single association + const subKeys = { + 'my-kv/my-granular-secret/foo': { + mount: 'my-kv', + secret_name: 'my-granular-secret', + sync_status: 'SYNCED', + updated_at: '2023-09-20T10:51:53.961861096-04:00', + sub_key: 'foo', + }, + 'my-kv/my-granular-secret/bar': { + mount: 'my-kv', + secret_name: 'my-granular-secret', + sync_status: 'SYNCED', + updated_at: '2023-09-20T10:51:53.961861096-04:00', + sub_key: 'bar', + }, + 'my-kv/my-granular-secret/baz': { + mount: 'my-kv', + secret_name: 'my-granular-secret', + sync_status: 'SYNCED', + updated_at: '2023-09-20T10:51:53.961861096-04:00', + sub_key: 'baz', + }, + }; return { data: { - associated_secrets: records.length - ? records.reduce((associations, association) => { - const key = `${association.mount}/${association.secret_name}`; - delete association.type; - delete association.name; - associations[key] = association; - return associations; - }, {}) - : {}, + associated_secrets: destination.granularity === 'secret-path' ? associations : subKeys, store_name: name, store_type: type, }, diff --git a/ui/tests/helpers/sync/sync-selectors.js b/ui/tests/helpers/sync/sync-selectors.js index ccb13886b7..7c3afb3b72 100644 --- a/ui/tests/helpers/sync/sync-selectors.js +++ b/ui/tests/helpers/sync/sync-selectors.js @@ -76,6 +76,8 @@ export const PAGE = { fillInByAttr: async (attr, value) => { // for handling more complex form input elements by attr name switch (attr) { + case 'granularity': + return await click(`[data-test-radio="secret-key"]`); case 'credentials': await click('[data-test-text-toggle]'); return fillIn('[data-test-text-file-textarea]', value); diff --git a/ui/tests/integration/components/form-field-test.js b/ui/tests/integration/components/form-field-test.js index 05e6e8067a..6df822f747 100644 --- a/ui/tests/integration/components/form-field-test.js +++ b/ui/tests/integration/components/form-field-test.js @@ -188,9 +188,9 @@ module('Integration | Component | form field', function (hooks) { assert.ok(component.hasRadio, 'renders radio buttons'); const selectedValue = 'SHA256'; await component.selectRadioInput(selectedValue); - assert.dom('[data-test-radio-label="Label 1"] span').hasText('Label 1'); - assert.dom('[data-test-radio-label="Label 2"] span').hasText('Label 2'); - assert.dom('[data-test-radio-label="SHA256"] span').hasText('SHA256'); + assert.dom('[data-test-radio-label="Label 1"]').hasTextContaining('Label 1'); + assert.dom('[data-test-radio-label="Label 2"]').hasTextContaining('Label 2'); + assert.dom('[data-test-radio-label="SHA256"]').hasTextContaining('SHA256'); assert.dom('[data-test-radio-subText="Some subtext 1"]').hasText('Some subtext 1'); assert.dom('[data-test-radio-subText="Some subtext 2"]').hasText('Some subtext 2'); assert.dom('[data-test-radio-subText="Some subtext 3"]').hasText('Some subtext 3'); 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 2357ea91e6..0a3130a522 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 @@ -266,19 +266,30 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE } // EDIT FORM ASSERTIONS FOR EACH DESTINATION TYPE + // * test updates: if editable, add param here + // if it is not a string type, add case to EXPECTED_VALUE and update + // fillInByAttr() (in sync-selectors) to interact with the form const EDITABLE_FIELDS = { - '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'], + 'aws-sm': ['accessKeyId', 'secretAccessKey', 'granularity', 'secretNameTemplate', 'customTags'], + 'azure-kv': ['clientId', 'clientSecret', 'granularity', 'secretNameTemplate', 'customTags'], + 'gcp-sm': ['credentials', 'granularity', 'secretNameTemplate', 'customTags'], + gh: ['accessToken', 'granularity', 'secretNameTemplate'], + 'vercel-project': [ + 'accessToken', + 'teamId', + 'deploymentEnvironments', + 'granularity', + 'secretNameTemplate', + ], }; const EXPECTED_VALUE = (key) => { switch (key) { - case 'deployment_environments': - return ['production']; case 'custom_tags': return { foo: `new-${key}-value` }; + case 'deployment_environments': + return ['production']; + case 'granularity': + return 'secret-key'; default: // for all string type parameters return `new-${key}-value`;