Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync UI: Add granularity to sync destinations #25500

Merged
merged 11 commits into from
Feb 20, 2024
1 change: 1 addition & 0 deletions ui/app/models/sync/association.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
20 changes: 20 additions & 0 deletions ui/app/models/sync/destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion ui/app/models/sync/destinations/aws-sm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion ui/app/models/sync/destinations/azure-kv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions ui/app/models/sync/destinations/gcp-sm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions ui/app/models/sync/destinations/gh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
];

Expand Down
5 changes: 4 additions & 1 deletion ui/app/models/sync/destinations/vercel-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ui/app/serializers/sync/association.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default class SyncAssociationSerializer extends ApplicationSerializer {
destinationType: { serialize: false },
syncStatus: { serialize: false },
updatedAt: { serialize: false },
subKey: { serialize: false },
};

extractLazyPaginatedData(payload) {
Expand Down
7 changes: 6 additions & 1 deletion ui/lib/core/addon/components/form-field.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used Hds here because InfoTooltip styling was too low and didn't have the patience to CSS

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-02-16 at 8 17 23 PM

<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}}
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-02-16 at 8 15 00 PM

Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,32 @@
~}}

<Secrets::DestinationHeader @destination={{@destination}} @refreshList={{this.refreshRoute}} />

{{#if (eq @destination.granularity "secret-key")}}
<Hds::Alert @type="inline" @color="neutral" class="has-top-margin-s has-bottom-margin-s" @icon="info" as |A|>
<A.Description>
Each secret key below maps to an external secret synced to this destination. Any sync or unsync actions will apply to
the Vault secret and not the individual key.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if @associations.meta.filteredTotal}}
<div class="has-bottom-margin-s">
{{#each @associations as |association index|}}
<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}}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")}}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-02-16 at 8 03 37 PM

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">
Expand Down
4 changes: 4 additions & 0 deletions ui/mirage/factories/sync-destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
}),
Expand All @@ -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' },
}),
Expand All @@ -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' },
}),
Expand All @@ -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({
Expand Down
47 changes: 38 additions & 9 deletions ui/mirage/handlers/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
2 changes: 2 additions & 0 deletions ui/tests/helpers/sync/sync-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Loading