Skip to content
Merged
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
dc27b26
Ember Engine Setup for Secrets Sync (#23653)
zofskeez Oct 16, 2023
8338397
Sync Mirage Setup (#23683)
zofskeez Oct 16, 2023
b0ca805
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Oct 17, 2023
df35050
UI Secrets Sync: Ember data sync destinations (#23674)
hellobontempo Oct 17, 2023
94fe0f4
UI Secrets Sync: Overview landing page (#23696)
hellobontempo Oct 18, 2023
866b61f
UI Secrets Sync: Destinations adapter add LIST (#23716)
hellobontempo Oct 19, 2023
32d2868
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Oct 20, 2023
379ceaf
Secrets Sync: Destinations create - select type (#23792)
hellobontempo Oct 23, 2023
e78c5f1
UI Secrets Sync: Create destination form and route (#23806)
hellobontempo Oct 25, 2023
7884a13
cleanup test selectors
hellobontempo Oct 25, 2023
f92c811
secrets sync: refactor sync destinations helper (#23839)
hellobontempo Oct 25, 2023
b437785
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Oct 26, 2023
e6f2087
Secrets sync UI: Destination details page (#23842)
hellobontempo Oct 26, 2023
ed05f84
Secrets Sync UI: Cleanup headers + tabs (#23873)
hellobontempo Oct 27, 2023
4f8e1a9
Merge branch 'main' into ui/VAULT-17968/secrets-sync
zofskeez Oct 31, 2023
4d838c8
Secrets Sync Destinations List View (#23949)
zofskeez Nov 2, 2023
889a7cf
Sync Destinations Capabilities (#23953)
zofskeez Nov 3, 2023
3a948bd
Sync Associations Ember Data Setup (#24132)
zofskeez Nov 15, 2023
8a92993
Sync Destination Secrets Route and Page Component (#24155)
zofskeez Nov 16, 2023
681d540
Merge branch 'main' into ui/VAULT-17968/secrets-sync
zofskeez Nov 16, 2023
2af55e2
updates usage of old spacing style variable after merge
zofskeez Nov 16, 2023
f908a7e
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Nov 17, 2023
bba33f6
use confirm action instead of contextual confirm (old) component (#24…
hellobontempo Nov 20, 2023
01cd83a
UI Secrets Sync: Adds secret status to kv v2 details page (#24208)
hellobontempo Nov 23, 2023
0a5ad75
Sync Secrets to Destination (#24247)
zofskeez Nov 27, 2023
f4b7956
Secrets Sync Landing Page Images (#24277)
zofskeez Nov 28, 2023
2016eb8
UI Secrets Sync: Serialize trailing slash from destination type (#24…
hellobontempo Nov 30, 2023
e1d8221
Sync Overview (#24340)
zofskeez Dec 5, 2023
b37e040
Merge branch 'main' into ui/VAULT-17968/secrets-sync
zofskeez Dec 5, 2023
7e81adb
Secrets Sync UI: Add loading and error substates (#24353)
hellobontempo Dec 6, 2023
39744bb
Remove is-version Helper (#24388)
zofskeez Dec 6, 2023
7eb7dff
updates sync tests to use common selectors (#24397)
zofskeez Dec 6, 2023
d9e7003
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Dec 6, 2023
a13c780
update capitalization to consistently be titlecase, fix breadcrumb se…
hellobontempo Dec 6, 2023
d3bf747
Merge branch 'main' into ui/VAULT-17968/secrets-sync
hellobontempo Dec 7, 2023
4d7a3cf
clears sync associations from store on destination sync page componen…
zofskeez Dec 11, 2023
49eab64
KV Suggestion Input (#24447)
zofskeez Dec 11, 2023
bc4a067
Secrets Sync UI: Editing a destination (#24413)
hellobontempo Dec 12, 2023
5494d60
Sync Success Banner (#24491)
zofskeez Dec 12, 2023
b0fd407
use Sync secrets everywhere (remove new) (#24494)
hellobontempo Dec 13, 2023
b8c1d9d
Sync Destinations List Filter Bug (#24496)
zofskeez Dec 13, 2023
4b712b7
fixes Sync now action text alignment in destination secrets list
zofskeez Dec 13, 2023
5f98b44
UI Secrets sync: Add purge query param to delete endpoint (#24497)
hellobontempo Dec 13, 2023
341a9c1
adds updated_at to mirage set association handler
zofskeez Dec 13, 2023
e4b364f
adds changelog entry
zofskeez Dec 13, 2023
9c6d28e
Merge branch 'ui/VAULT-17968/secrets-sync' of github.com:hashicorp/va…
zofskeez Dec 13, 2023
a181036
Merge branch 'main' into ui/VAULT-17968/secrets-sync
zofskeez Dec 13, 2023
c1e8f76
add enterprise in parenthesis for changelog
hellobontempo Dec 13, 2023
aaf532b
addres a11y feedback
hellobontempo Dec 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/23667.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
**Secrets Sync UI (enterprise)**: Adds secret syncing for KV v2 secrets to external destinations using the UI.
```
11 changes: 7 additions & 4 deletions ui/app/adapters/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default RESTAdapter.extend({
return false;
},

addHeaders(url, options) {
addHeaders(url, options, method) {
const token = options.clientToken || this.auth.currentToken;
const headers = {};
if (token && !options.unauthenticated) {
Expand All @@ -45,6 +45,9 @@ export default RESTAdapter.extend({
if (options.wrapTTL) {
headers['X-Vault-Wrap-TTL'] = options.wrapTTL;
}
if (method === 'PATCH') {
headers['Content-Type'] = 'application/merge-patch+json';
}
const namespace =
typeof options.namespace === 'undefined' ? this.namespaceService.path : options.namespace;
if (namespace && !NAMESPACE_ROOT_URLS.some((str) => url.includes(str))) {
Expand All @@ -53,8 +56,8 @@ export default RESTAdapter.extend({
options.headers = assign(options.headers || {}, headers);
},

_preRequest(url, options) {
this.addHeaders(url, options);
_preRequest(url, options, method) {
this.addHeaders(url, options, method);
const isPolling = POLLING_URLS.some((str) => url.includes(str));
if (!isPolling) {
this.auth.setLastFetch(Date.now());
Expand Down Expand Up @@ -83,7 +86,7 @@ export default RESTAdapter.extend({
},
};
}
const opts = this._preRequest(url, options);
const opts = this._preRequest(url, options, method);

return this._super(url, type, opts).then((...args) => {
if (controlGroupToken) {
Expand Down
96 changes: 96 additions & 0 deletions ui/app/adapters/sync/association.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import ApplicationAdapter from 'vault/adapters/application';
import { assert } from '@ember/debug';
import { all } from 'rsvp';

export default class SyncAssociationAdapter extends ApplicationAdapter {
namespace = 'v1/sys/sync';

buildURL(modelName, id, snapshot, requestType, query = {}) {
const { destinationType, destinationName } = snapshot ? snapshot.attributes() : query;
if (!destinationType || !destinationName) {
return `${super.buildURL()}/associations`;
}
const { action } = snapshot?.adapterOptions || {};
const uri = action ? `/${action}` : '';
return `${super.buildURL()}/destinations/${destinationType}/${destinationName}/associations${uri}`;
}

query(store, { modelName }, query) {
// endpoint doesn't accept the typical list query param and we don't want to pass options from lazyPaginatedQuery
const url = this.buildURL(modelName, null, null, 'query', query);
return this.ajax(url, 'GET');
}

// typically associations are queried for a specific destination which is what the standard query method does
// in specific cases we can query all associations to access total_associations and total_secrets values
queryAll() {
return this.query(this.store, { modelName: 'sync/association' }).then((response) => {
const { total_associations, total_secrets } = response.data;
return { total_associations, total_secrets };
});
}

// fetch associations for many destinations
// returns aggregated association information for each destination
// information includes total associations, total unsynced and most recent updated datetime
async fetchByDestinations(destinations) {
const promises = destinations.map(({ name: destinationName, type: destinationType }) => {
return this.query(this.store, { modelName: 'sync/association' }, { destinationName, destinationType });
});
const queryResponses = await all(promises);
const serializer = this.store.serializerFor('sync/association');
return queryResponses.map((response) => serializer.normalizeFetchByDestinations(response));
}

// array of association data for each destination a secret is synced to
fetchSyncStatus({ mount, secretName }) {
const url = `${this.buildURL()}/${mount}/${secretName}`;
return this.ajax(url, 'GET').then((resp) => {
const { associated_destinations } = resp.data;
const syncData = [];
for (const key in associated_destinations) {
const data = associated_destinations[key];
// renaming keys to match query() response
syncData.push({
destinationType: data.type,
destinationName: data.name,
syncStatus: data.sync_status,
updatedAt: data.updated_at,
});
}
return syncData;
});
}

// snapshot is needed for mount and secret_name values which are used to parse response since all associations are returned
_setOrRemove(store, { modelName }, snapshot) {
assert(
"action type of set or remove required when saving association => association.save({ adapterOptions: { action: 'set' }})",
['set', 'remove'].includes(snapshot?.adapterOptions?.action)
);
const url = this.buildURL(modelName, null, snapshot);
const data = snapshot.serialize();
return this.ajax(url, 'POST', { data }).then((resp) => {
const id = `${data.mount}/${data.secret_name}`;
return {
...resp.data.associated_secrets[id],
id,
destinationName: resp.data.store_name,
destinationType: resp.data.store_type,
};
});
}

createRecord() {
return this._setOrRemove(...arguments);
}

updateRecord() {
return this._setOrRemove(...arguments);
}
}
43 changes: 43 additions & 0 deletions ui/app/adapters/sync/destination.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import ApplicationAdapter from 'vault/adapters/application';
import { pluralize } from 'ember-inflector';

export default class SyncDestinationAdapter extends ApplicationAdapter {
namespace = 'v1/sys';

pathForType(modelName) {
return modelName === 'sync/destination' ? pluralize(modelName) : modelName;
}

urlForCreateRecord(modelName, snapshot) {
const { name } = snapshot.attributes();
return `${super.urlForCreateRecord(modelName, snapshot)}/${name}`;
}

updateRecord(store, { modelName }, snapshot) {
const { name } = snapshot.attributes();
return this.ajax(`${this.buildURL(modelName)}/${name}`, 'PATCH', { data: snapshot.serialize() });
}

urlForDeleteRecord(id, modelName, snapshot) {
const { name, type } = snapshot.attributes();
// the only delete option in the UI is to purge which unsyncs all secrets prior to deleting
return `${this.buildURL('sync/destinations')}/${type}/${name}?purge=true`;
}

query(store, { modelName }) {
return this.ajax(this.buildURL(modelName), 'GET', { data: { list: true } });
}

// return normalized query response
// useful for fetching data directly without loading models into store
async normalizedQuery() {
const queryResponse = await this.query(this.store, { modelName: 'sync/destination' });
const serializer = this.store.serializerFor('sync/destination');
return serializer.extractLazyPaginatedData(queryResponse);
}
}
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destinations/aws-sm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import SyncDestinationAdapter from '../destination';

export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {}
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destinations/azure-kv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import SyncDestinationAdapter from '../destination';

export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {}
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destinations/gcp-sm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import SyncDestinationAdapter from '../destination';

export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {}
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destinations/gh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import SyncDestinationAdapter from '../destination';

export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {}
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destinations/vercel-project.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import SyncDestinationAdapter from '../destination';

export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {}
10 changes: 10 additions & 0 deletions ui/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export default class App extends Application {
],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
syncDestination: 'vault.cluster.sync.secrets.destinations.destination',
},
},
},
Expand All @@ -106,6 +107,15 @@ export default class App extends Application {
},
},
},
sync: {
dependencies: {
services: ['flash-messages', 'router', 'store', 'version'],
externalRoutes: {
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
clientCountDashboard: 'vault.cluster.clients.dashboard',
},
},
},
};
}

Expand Down
6 changes: 6 additions & 0 deletions ui/app/components/sidebar/nav/cluster.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
@text="Secrets Engines"
data-test-sidebar-nav-link="Secrets Engines"
/>
<Nav.Link
@route="vault.cluster.sync"
@text="Secrets Sync"
@badge={{unless this.version.isEnterprise "Enterprise"}}
data-test-sidebar-nav-link="Secrets Sync"
/>
{{#if (has-permission "access")}}
<Nav.Link
@route={{get (route-params-for "access") "route"}}
Expand Down
39 changes: 39 additions & 0 deletions ui/app/models/sync/association.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';

export default class SyncAssociationModel extends Model {
@attr mount;
@attr secretName;
@attr syncStatus;
@attr updatedAt;
// destination related properties that are not serialized to payload
@attr destinationName;
@attr destinationType;

@lazyCapabilities(
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/set`,
'destinationType',
'destinationName'
)
setAssociationPath;

@lazyCapabilities(
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/remove`,
'destinationType',
'destinationName'
)
removeAssociationPath;

get canSync() {
return this.setAssociationPath.get('canUpdate') !== false;
}

get canUnsync() {
return this.removeAssociationPath.get('canUpdate') !== false;
}
}
53 changes: 53 additions & 0 deletions ui/app/models/sync/destination.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Model, { attr } from '@ember-data/model';
import { findDestination } from 'vault/helpers/sync-destinations';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';

// Base model for all secret sync destination types
const validations = {
name: [{ type: 'presence', message: 'Name is required.' }],
};

@withModelValidations(validations)
export default class SyncDestinationModel extends Model {
@attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name;
@attr type;

// findDestination returns static attributes for each destination type
get icon() {
return findDestination(this.type)?.icon;
}

get typeDisplayName() {
return findDestination(this.type)?.name;
}

get maskedParams() {
return findDestination(this.type)?.maskedParams;
}

@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}`, 'type', 'name') destinationPath;
@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, 'type', 'name')
setAssociationPath;

get canCreate() {
return this.destinationPath.get('canCreate') !== false;
}
get canDelete() {
return this.destinationPath.get('canDelete') !== false;
}
get canEdit() {
return this.destinationPath.get('canUpdate') !== false;
}
get canRead() {
return this.destinationPath.get('canRead') !== false;
}
get canSync() {
return this.setAssociationPath.get('canUpdate') !== false;
}
}
37 changes: 37 additions & 0 deletions ui/app/models/sync/destinations/aws-sm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

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 formFieldGroups = [
{ default: ['name', 'region'] },
{ Credentials: ['accessKeyId', 'secretAccessKey'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsAwsSecretsManagerModel extends SyncDestinationModel {
@attr('string', {
label: 'Access key ID',
subText:
'Access key ID to authenticate against the secrets manager. If empty, Vault will use the AWS_ACCESS_KEY_ID environment variable if configured.',
})
accessKeyId; // obfuscated, never returned by API

@attr('string', {
label: 'Secret access key',
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this and accessKeyId be masked inputs?

Copy link
Contributor

Choose a reason for hiding this comment

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

They weren't in the designs 🤔 But this is a good question - we have a meeting scheduled to do a design review/walk through in the new year. I'll make a note to ask this!

subText:
'Secret access key to authenticate against the secrets manager. If empty, Vault will use the AWS_SECRET_ACCESS_KEY environment variable if configured.',
})
secretAccessKey; // obfuscated, never returned by API

@attr('string', {
subText:
'For AWS secrets manager, the name of the region must be supplied, something like “us-west-1.” If empty, Vault will use the AWS_REGION environment variable if configured.',
editDisabled: true,
})
region;
}
Loading