Sync UI: Add granularity to sync destinations (#25500)

* add granularity form field to sync destinations

* update mirage, shim in subkey response

* fix comment

* add granular updates to list view

* update mirage;

* update test

* comment for updating test

* use hds::dropdown in destinations for consistency

* move banner to popup menu

* add changelog

* remove spans from test
This commit is contained in:
claire bontempo
2024-02-20 13:17:34 -08:00
committed by GitHub
parent 3132592c19
commit dd62f9fa28
20 changed files with 249 additions and 94 deletions

3
changelog/25500.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: add granularity param to sync destinations
```

View File

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

View File

@@ -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-<accessor_id>-<secret_path>" 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;

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -12,6 +12,7 @@ export default class SyncAssociationSerializer extends ApplicationSerializer {
destinationType: { serialize: false },
syncStatus: { serialize: false },
updatedAt: { serialize: false },
subKey: { serialize: false },
};
extractLazyPaginatedData(payload) {

View File

@@ -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}}
>
<span>{{or val.label val.value val}}</span>
{{or val.label val.value val}}
{{#if val.helpText}}
<Hds::TooltipButton @text={{val.helpText}} aria-label="More information">
<FlightIcon @name="info" />
</Hds::TooltipButton>
{{/if}}
{{#if this.hasRadioSubText}}
<p class="has-left-margin-xs has-text-grey is-size-8" data-test-radio-subText={{val.subText}}>
{{val.subText}}

View File

@@ -67,44 +67,44 @@
</code>
</Item.content>
<Item.menu>
{{#if destination.destinationPath.isLoading}}
<li class="action">
<LoadingDropdownOption />
</li>
{{else}}
<li>
<LinkTo
class="has-text-black has-text-weight-semibold"
<Item.menu @hasMenu={{false}}>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="Destinations popup menu"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#if destination.destinationPath.isLoading}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else}}
<dd.Interactive
@text="Details"
data-test-details
@route="secrets.destinations.destination.details"
@models={{array destination.type destination.name}}
@disabled={{not destination.canRead}}
>
Details
</LinkTo>
</li>
<li>
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-edit
@route="secrets.destinations.destination.edit"
@models={{array destination.type destination.name}}
@disabled={{not destination.canEdit}}
>
Edit
</LinkTo>
</li>
{{#if destination.canDelete}}
<ConfirmAction
data-test-delete
@isInDropdown={{true}}
@buttonText="Delete"
@confirmMessage="The destination will be permanently deleted and all the secrets will be unsynced. This cannot be undone."
@onConfirmAction={{fn this.onDelete destination}}
/>
{{#if destination.canEdit}}
<dd.Interactive
@text="Edit"
data-test-edit
@route="secrets.destinations.destination.edit"
@models={{array destination.type destination.name}}
/>
{{/if}}
{{#if destination.canDelete}}
<dd.Interactive
data-test-delete
@text="Delete"
@color="critical"
{{on "click" (fn (mut this.destinationToDelete) destination)}}
/>
{{/if}}
{{/if}}
{{/if}}
</Hds::Dropdown>
</Item.menu>
</ListItem>
{{/each}}
@@ -120,4 +120,13 @@
</div>
{{else}}
<EmptyState @title={{this.noResultsMessage}} />
{{/if}}
{{#if this.destinationToDelete}}
<ConfirmModal
@color="critical"
@confirmMessage="The destination will be permanently deleted and all the secrets will be unsynced. This cannot be undone."
@onClose={{fn (mut this.destinationToDelete) null}}
@onConfirm={{fn this.onDelete this.destinationToDelete}}
/>
{{/if}}

View File

@@ -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<Args
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
@tracked destinationToDelete = null;
// for some reason there isn't a full page refresh happening when transitioning on filter change
// when the transition happens it causes the FilterInput component to lose focus since it can only focus on didInsert
// to work around this, verify that a transition from this route was completed and then focus the input
@@ -101,6 +103,8 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
this.flashMessages.success(message);
} catch (error) {
this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`);
} finally {
this.destinationToDelete = null;
}
}
}

View File

@@ -11,15 +11,18 @@
<ListItem as |Item|>
<Item.content>
<div>
<Hds::Badge @text="{{association.mount}}/" />
<Hds::TooltipButton @text="KV v2 engine mount path">
<Hds::Badge @text="{{association.mount}}/" />
</Hds::TooltipButton>
<LinkToExternal
data-test-association-name={{index}}
class="has-text-black has-text-weight-semibold"
@route="kvSecretDetails"
@models={{array association.mount association.secretName}}
>
{{association.secretName}}
</LinkToExternal>
>{{association.secretName}}</LinkToExternal>
{{#if association.subKey}}
<Hds::Badge @text="secret key: {{association.subKey}}/" />
{{/if}}
<div>
<SyncStatusBadge @status={{association.syncStatus}} data-test-association-status={{index}} />
<code class="has-text-grey is-size-8" data-test-association-updated={{index}}>
@@ -30,42 +33,49 @@
</div>
</Item.content>
<Item.menu>
{{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}}
<li class="action">
<LoadingDropdownOption />
</li>
{{else}}
<li class="action">
<Hds::Button
@text="Sync now"
class="link is-flex-start"
@isFullWidth={{true}}
disabled={{not association.canSync}}
data-test-association-action="sync"
{{on "click" (fn this.update association "set")}}
/>
</li>
<li>
<LinkToExternal
class="has-text-black has-text-weight-semibold"
<Item.menu @hasMenu={{false}}>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" @width="210px" as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="Synced secret popup menu"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else}}
{{#if (eq @destination.granularity "secret-key")}}
<dd.Description
@text='Sync or unsync actions will apply to the secret "{{association.secretName}}" and not this individual key.'
/>
<dd.Separator />
{{/if}}
{{#if association.canSync}}
<dd.Interactive
@text="Sync now"
data-test-association-action="sync"
{{on "click" (fn this.update association "set")}}
/>
{{/if}}
<dd.Interactive
@text="View secret"
data-test-association-action="view"
@route="kvSecretDetails"
@isRouteExternal={{true}}
@models={{array association.mount association.secretName}}
>
View secret
</LinkToExternal>
</li>
{{#if association.canUnsync}}
<ConfirmAction
data-test-association-action="unsync"
@isInDropdown={{true}}
@buttonText="Unsync"
@confirmMessage="This secret will be unsynced from this destination."
@onConfirmAction={{fn this.update association "remove"}}
/>
{{#if association.canUnsync}}
<dd.Interactive
data-test-association-action="unsync"
@color="critical"
@text="Unsync"
{{on "click" (fn (mut this.secretToUnsync) association)}}
/>
{{/if}}
{{/if}}
{{/if}}
</Hds::Dropdown>
</Item.menu>
</ListItem>
{{/each}}
@@ -92,4 +102,13 @@
@route="secrets.destinations.destination.sync"
/>
</EmptyState>
{{/if}}
{{#if this.secretToUnsync}}
<ConfirmModal
@color="critical"
@confirmMessage='The secret "{{this.secretToUnsync.secretName}}" will be unsynced from this destination.'
@onClose={{fn (mut this.secretToUnsync) null}}
@onConfirm={{fn this.update this.secretToUnsync "remove"}}
/>
{{/if}}

View File

@@ -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 Component<Args
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
@tracked secretToUnsync = null;
get mountPoint(): string {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
@@ -55,6 +58,7 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
} catch (error) {
this.flashMessages.danger(`Sync operation error: \n ${errorMessage(error)}`);
} finally {
this.secretToUnsync = null;
this.refreshRoute();
}
}

View File

@@ -38,6 +38,11 @@
Select a KV engine mount and path to sync a secret to the
{{@destination.typeDisplayName}}
destination. Selecting a previously synced secret will re-sync that secret.
{{#if (eq @destination.granularity "secret-key")}}
This destination is configured to sync with
<strong>secret key</strong>
granularity. Each key-value pair of the selected secret will sync as a distinct entry at the destination.
{{/if}}
</p>
<div class="has-top-margin-l">

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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