Secrets Sync (#23667)

* Ember Engine Setup for Secrets Sync (#23653)

* ember engine setup for secrets sync

* Update ui/lib/sync/addon/routes.js

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Sync Mirage Setup (#23683)

* adds mirage setup for sync endpoints

* updates secret_name default in sync-association mirage factory

* UI Secrets Sync: Ember data sync destinations (#23674)

* add models

* adapters

* base model adapter

* update test response

* add sync destinations helper

* finish renaming base destination model/adapter

* add comment

* add serializer

* use normalizeItems instead

* destination serializer test

* add destination find method;

* add conditional operand

* UI Secrets Sync: Overview landing page (#23696)

* add models

* adapters

* base model adapter

* update test response

* add sync destinations helper

* finish renaming base destination model/adapter

* add comment

* add serializer

* doc-link helper

* add version service

* landing and overview component

* overview page

* add tests

* UI Secrets Sync: Destinations adapter add LIST (#23716)

* add models

* adapters

* base model adapter

* update test response

* add sync destinations helper

* finish renaming base destination model/adapter

* add comment

* add serializer

* doc-link helper

* add version service

* landing and overview component

* overview page

* build out serializer and adapters

* update mirage

* fix merge conflicts

* one more conflict!

* pull transformQueryResponse to separate method in adapter

* move data transforming all to serializer and tests

* add note to paginationd ocs
docs

* conditionally render CTA

* add lazyPaginatedQuery method to destinations route

* remove partial error

* Secrets Sync: Destinations create - select type (#23792)

* add category to destinations

* build select type page

* refactor prompt config situation

* routing for destinations

* update select-type routing

* make card width fixed

* revert CTA routing change, keep shouldRenderOverview

* add header for gif demo to form

* cleanup scope

* more scope cleanup

* add test

* add type selector

* rename components

* rename again

* remove async

* fix tests

* fix select type rename in test

* delete renamed test

* fix import of general selectors

* rename using component syntax

* UI Secrets Sync: Create destination form and route (#23806)

* add model attribute metadata

* add form and save url, remove name and type from serializer

* move checkbox list to form field helper

* add styling to alert inline

* use newly made class

* fix cancel action and cleanup form

* change quotes

* remove checkbox action from form component

* add tests

* address feedback

* add API error test

* use create record method instead

* adapter test for create record

* return from find method if type is undefined

* cleanup test selectors

* secrets sync: refactor sync destinations helper (#23839)

* refactor getter in base destination model

* add getters back to model

* Secrets sync UI: Destination details page (#23842)

* change labels to match params

* add maskedParams to base model

* add details route

* add details view;

* update mirage

* fix secrets sync link;

* delete parent destination route

* add copyright header

* add secrets route

* move sync route outside of secrets/ route

* upate mirage

* export to-label

* finish tests

* make ternary

* rename header tabs

* fix selector in test

* Secrets Sync UI: Cleanup headers + tabs (#23873)

* remove destination header component, add headers/tabs to all routes

* fix header padding

* move tabs + toolbar back into component...

* add copyright header

* add delete modal

* lol revert again

* add extra line after copyright header

* Secrets Sync Destinations List View (#23949)

* adds route and page component for sync destinations list view

* filters by type first for sync destinations

* adds test for store.filterData method

* Update ui/app/services/store.js

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>

* updates nav link label for secrets sync

* moves sync destinations types out of app-types

* moves loading-dropdown-option component to core addon and adds to destination list item menu

* change true assertion to deepEqual in sync destinations test

* adds copyright header to sync-destinations type file

* clear store dataset on sync destination create

---------

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>

* Sync Destinations Capabilities (#23953)

* adds route and page component for sync destinations list view

* filters by type first for sync destinations

* adds test for store.filterData method

* adds capabilities checks for sync destinations

* removes canList from sync destinations capabilities

* updates sync header tests

* Update ui/tests/integration/components/sync/sync-header-test.js

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* updates sync destination response serialization

* updates sync destination serializer test

* updates sync destinations page test assertions

* fixes mirage sync destinations payload issue

* removes commented out method in sync destination adapter

* fixes inconsistencies with url generation for sync destinations delete

* fixes sync destinations page test

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Sync Associations Ember Data Setup (#24132)

* adds model, adapter and serializer for sync associations

* updates sync association adapter save methods to use adapterOptions to determine action

* Sync Destination Secrets Route and Page Component (#24155)

* renames sync destination header component and adds tests

* adds destination secrets route and page component

* adds setup-models helper for sync testing

* moves destination details test into subdir

* adds destination secrets page component tests

* adds controller for destination secrets route

* fixes pagination route on destination secrets view

* fixes sync association updated_at assertion based on timezone

* updates kv secret details external route name

* updates usage of old spacing style variable after merge

* use confirm action instead of contextual confirm (old) component (#24189)

* UI Secrets Sync: Adds secret status to kv v2 details page (#24208)

* woops! missed this styling for confirm action swap

* update link to go to destination secrets

* change edit to view secret from destination secrets list

* add synDestination to external routes for kv engine

* add sync status badge component

* export from addon

* splaattributes

* poll sync status for kv secret details and render

* move from controller to component

* update name to new destinationName key

* reorder list view items

* add refresh button

* add mirage data

* change to loading static

* update icons to be sync specific

* change name

* move button and change fetch to concurrency task

* add tests to kv details

* add color assertion

* add copyright header

* small test tweaks

* Update ui/tests/integration/components/sync-status-badge-test.js

* fixes test

---------

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>

* Sync Secrets to Destination (#24247)

* fixes issue with filter-input debounce and updates to spread attributes for input rather than use args

* adds destination sync page component

* removes unused var in sync component

* adds test for manual mount path input in sync view

* updates mount filtering in destinations sync page to target kv v2

* Secrets Sync Landing Page Images (#24277)

* updates sync landing page to add marketing images

* removes top margin from sync landing-cta

* adds aria-describedby to sync landing images

* UI Secrets Sync: Serialize trailing slash from destination type  (#24294)

* remove trailing slash from type in  destination LIST response

* update keys in mirage and tests

* Sync Overview (#24340)

* updates landing-cta image to png with matching height

* adds ts definitons for sync adapters

* updates sync adapters and serializers to add methods for fetching overview data

* adds sync associations list handler to mirage and seeds more associations in scenario

* adds table and totals cards to sync overview page

* adds sync overview page component tests

* fixes tests

* changes lastSync key to lastUpdated for sync fetchByDestinations response

* adds emdash as placeholder for lastUpdated null value in secrets by destination table

* updates to handle 0 associations state for destination in overview table

* Secrets Sync UI: Add loading and error substates (#24353)

* add error substate

* add loading substates

* delete loading from secrets route

* Remove is-version Helper (#24388)

* removes is-version helper and injects service into components

* updates sync tests using version service to new API

* adds comment back for tracked property in secret detials page component

* updates sync tests to use common selectors (#24397)

* update capitalization to consistently be titlecase, fix breadcrumb selector

* clears sync associations from store on destination sync page component destroy (#24450)

* KV Suggestion Input (#24447)

* updates filter-input component to conditionally show search icon

* adds kv-suggestion-input component to core addon

* updates destination sync page component to use KvSuggestionInput component

* fixes issue in kv-suggestion-input where a partial search term was not replaced with the selected suggestion value

* updates kv-suggestion-input to retain focus on suggestion click

* fixes test

* updates kv-suggestion-input to conditionally render label component

* adds comments to kv-suggestion-input regarding trigger

* moves alert banner in sync page below button set

* moves inputId from getter to class property on kv-suggestion-input

* Secrets Sync UI: Editing a destination (#24413)

* add form field groups to sync models

* update create-and-edit form to use confirmLeave and enableInput component

* enable input component

* add more stars

* update css comments

* Update ui/app/styles/helper-classes/flexbox-and-grid.scss

* make attrOptions optional

* remove decorator

* add env variables to subtexr

* add subtext to textfile

* fix overviwe transition bug

* remove breadcrumbs to getter

* WIP adapter update

* update mirage response

* add update method with PATCH

* add patch to application adapter

* fix typo

* finish tests

* remove validations because could use environment variables

* use getter and setter in model

* move update record business to serializer

* rest of logic in serializer;
gp
;
gp

* add model validation warnings

* cleanup getters

* pull create/update logic into method for mirage

* add test for validation warning

* update KV copy

* Sync Success Banner (#24491)

* adds success banner to destination sync page

* move submit disabled logic to getter in destination sync page

* adds id and for attributes to kv mount input in sync page

* hides sync success banner on submit

* use Sync secrets everywhere (remove new) (#24494)

* use Sync secrets everywhere (remove new)

* revert test name change

* Sync Destinations List Filter Bug (#24496)

* fixes issues filtering destinations list

* adds test

* fixes Sync now action text alignment in destination secrets list

* UI Secrets sync: Add purge query param to delete endpoint (#24497)

* adds updated_at to mirage set association handler

* adds changelog entry

* add enterprise in parenthesis for changelog

* addres a11y feedback

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com>
Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Jordan Reimer
2023-12-13 12:16:44 -07:00
committed by GitHub
parent 0fdd8237a6
commit 99445dbfd4
142 changed files with 5438 additions and 107 deletions

3
changelog/23667.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
**Secrets Sync UI (enterprise)**: Adds secret syncing for KV v2 secrets to external destinations using the UI.
```

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {}

View File

@@ -83,6 +83,7 @@ export default class App extends Application {
],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
syncDestination: 'vault.cluster.sync.secrets.destinations.destination',
},
},
},
@@ -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',
},
},
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
/**
* 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', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'clientSecret'];
const formFieldGroups = [
{ default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId'] },
{ Credentials: ['clientSecret'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsAzureKeyVaultModel extends SyncDestinationModel {
@attr('string', {
label: 'Key Vault URI',
subText:
'URI of an existing Azure Key Vault instance. If empty, Vault will use the KEY_VAULT_URI environment variable if configured.',
editDisabled: true,
})
keyVaultUri;
@attr('string', {
label: 'Client ID',
subText:
'Client ID of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_ID environment variable if configured.',
})
clientId;
@attr('string', {
subText:
'Client secret of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_SECRET environment variable if configured.',
})
clientSecret; // obfuscated, never returned by API
@attr('string', {
label: 'Tenant ID',
subText:
'ID of the target Azure tenant. If empty, Vault will use the AZURE_TENANT_ID environment variable if configured.',
editDisabled: true,
})
tenantId;
@attr('string', {
subText: 'Specifies a cloud for the client. The default is Azure Public Cloud.',
defaultValue: 'cloud',
editDisabled: true,
})
cloud;
}

View File

@@ -0,0 +1,24 @@
/**
* 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', 'credentials'];
const formFieldGroups = [{ default: ['name'] }, { Credentials: ['credentials'] }];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel {
@attr('string', {
label: 'JSON credentials',
subText:
'If empty, Vault will use the GOOGLE_APPLICATION_CREDENTIALS environment variable if configured.',
editType: 'file',
docLink: '/vault/docs/secrets/gcp#authentication',
})
credentials; // obfuscated, never returned by API
// TODO - confirm if project_id is going to be added to READ response (not editable)
}

View File

@@ -0,0 +1,36 @@
/**
* 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', 'repositoryOwner', 'repositoryName', 'accessToken'];
const formFieldGroups = [
{ default: ['name', 'repositoryOwner', 'repositoryName'] },
{ Credentials: ['accessToken'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsGithubModel extends SyncDestinationModel {
@attr('string', {
subText:
'Personal access token to authenticate to the GitHub repository. If empty, Vault will use the GITHUB_ACCESS_TOKEN environment variable if configured.',
})
accessToken; // obfuscated, never returned by API
@attr('string', {
subText:
'Github organization or username that owns the repository. If empty, Vault will use the GITHUB_REPOSITORY_OWNER environment variable if configured.',
editDisabled: true,
})
repositoryOwner;
@attr('string', {
subText:
'The name of the Github repository to connect to. If empty, Vault will use the GITHUB_REPOSITORY_NAME environment variable if configured.',
editDisabled: true,
})
repositoryName;
}

View File

@@ -0,0 +1,70 @@
/**
* 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';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [{ type: 'presence', message: 'Name is required.' }],
teamId: [
{
validator: (model) =>
!model.isNew && Object.keys(model.changedAttributes()).includes('teamId') ? false : true,
message: 'Team ID should only be updated if the project was transferred to another account.',
level: 'warn',
},
],
// 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 formFieldGroups = [
{ default: ['name', 'projectId', 'teamId', 'deploymentEnvironments'] },
{ Credentials: ['accessToken'] },
];
@withModelValidations(validations)
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsVercelProjectModel extends SyncDestinationModel {
@attr('string', {
subText: 'Vercel API access token with the permissions to manage environment variables.',
})
accessToken; // obfuscated, never returned by API
@attr('string', {
label: 'Project ID',
subText: 'Project ID where to manage environment variables.',
editDisabled: true,
})
projectId;
@attr('string', {
label: 'Team ID',
subText: 'Team ID the project belongs to. Optional.',
})
teamId;
// comma separated string, updated as array using deploymentEnvironmentsArray
@attr({
subText: 'Deployment environments where the environment variables are available.',
editType: 'checkboxList',
possibleValues: ['development', 'preview', 'production'],
fieldValue: 'deploymentEnvironmentsArray', // getter/setter used to update value
})
deploymentEnvironments;
// Instead of using the 'array' attr transform, we keep deploymentEnvironments a string to leverage Ember's changedAttributes()
// which only tracks updates to string types. However, arrays are easier for managing multi-option selection so
// the fieldValue is used to get/set the deploymentEnvironments attribute to/from an array
get deploymentEnvironmentsArray() {
// if undefined or an empty string, return empty array
return !this.deploymentEnvironments ? [] : this.deploymentEnvironments.split(',');
}
set deploymentEnvironmentsArray(value) {
this.deploymentEnvironments = value.join(',');
}
}

View File

@@ -15,6 +15,7 @@ Router.map(function () {
this.route('vault', { path: '/' }, function () {
this.route('cluster', { path: '/:cluster_name' }, function () {
this.route('dashboard');
this.mount('sync');
this.route('oidc-provider-ns', { path: '/*namespace/identity/oidc/provider/:provider_name/authorize' });
this.route('oidc-provider', { path: '/identity/oidc/provider/:provider_name/authorize' });
this.route('oidc-callback', { path: '/auth/*auth_path/oidc/callback' });

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from 'vault/serializers/application';
import { findDestination } from 'core/helpers/sync-destinations';
export default class SyncAssociationSerializer extends ApplicationSerializer {
attrs = {
destinationName: { serialize: false },
destinationType: { serialize: false },
syncStatus: { serialize: false },
updatedAt: { serialize: false },
};
extractLazyPaginatedData(payload) {
if (payload) {
const { store_name, store_type, associated_secrets } = payload.data;
const secrets = [];
for (const key in associated_secrets) {
const data = associated_secrets[key];
data.id = key;
const association = {
destinationName: store_name,
destinationType: store_type,
...data,
};
secrets.push(association);
}
return secrets;
}
return payload;
}
normalizeFetchByDestinations(payload) {
const { store_name, store_type, associated_secrets } = payload.data;
const unsynced = [];
let lastUpdated;
for (const key in associated_secrets) {
const association = associated_secrets[key];
// for display purposes, any status other than SYNCED is considered unsynced
if (association.sync_status !== 'SYNCED') {
unsynced.push(association.sync_status);
}
// use the most recent updated_at value as the last synced date
const updated = new Date(association.updated_at);
if (!lastUpdated || updated > lastUpdated) {
lastUpdated = updated;
}
}
const associationCount = Object.entries(associated_secrets).length;
return {
icon: findDestination(store_type).icon,
name: store_name,
type: store_type,
associationCount,
status: associationCount ? (unsynced.length ? `${unsynced.length} Unsynced` : 'All synced') : null,
lastUpdated,
};
}
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from 'vault/serializers/application';
import { decamelize } from '@ember/string';
export default class SyncDestinationSerializer extends ApplicationSerializer {
attrs = {
name: { serialize: false },
type: { serialize: false },
};
serialize(snapshot) {
// special serialization only for PATCH requests
if (snapshot.isNew) return super.serialize(snapshot);
// only send changed values
const data = {};
for (const attr in snapshot.changedAttributes()) {
// first array element is the old value
const [, newValue] = snapshot.changedAttributes()[attr];
data[decamelize(attr)] = newValue;
}
return data;
}
// interrupt application's normalizeItems, which is called in normalizeResponse by application serializer
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const transformedPayload = this._normalizePayload(payload, requestType);
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
}
extractLazyPaginatedData(payload) {
const transformedPayload = [];
// loop through each destination type (keys in key_info)
for (const key in payload.data.key_info) {
// iterate through each type's destination names
payload.data.key_info[key].forEach((name) => {
// remove trailing slash from key
const type = key.replace(/\/$/, '');
const id = `${type}/${name}`;
// create object with destination's id and attributes, add to payload
transformedPayload.pushObject({ id, name, type });
});
}
return transformedPayload;
}
_normalizePayload(payload, requestType) {
// if request is from lazyPaginatedQuery it will already have been extracted and meta will be set on object
// for store.query it will be the raw response which will need to be extracted
if (requestType === 'query') {
return payload.meta ? payload : this.extractLazyPaginatedData(payload);
} 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 || {};
data.id = data.name;
delete data.connection_details;
return { data: { ...data, ...connection_details } };
}
return payload;
}
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsAwsSecretsManagerSerializer extends SyncDestinationSerializer {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsAzureKeyVaultSerializer extends SyncDestinationSerializer {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationGoogleCloudSecretManagerSerializer extends SyncDestinationSerializer {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsGithubSerializer extends SyncDestinationSerializer {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsVercelProjectSerializer extends SyncDestinationSerializer {}

View File

@@ -64,8 +64,9 @@ export default class StoreService extends Store {
// the array of items will be found
// page: the page number to return
// size: the size of the page
// pageFilter: a string that will be used to do a fuzzy match against the
// results, this is done pre-pagination
// pageFilter: a string that will be used to do a fuzzy match against the results,
// OR a function to be executed that will receive the dataset as the lone arg.
// Filter is done pre-pagination.
lazyPaginatedQuery(modelType, query, adapterOptions) {
const skipCache = query.skipCache;
// We don't want skipCache to be part of the actual query key, so remove it
@@ -103,10 +104,14 @@ export default class StoreService extends Store {
filterData(filter, dataset) {
let newData = dataset || [];
if (filter) {
newData = dataset.filter(function (item) {
const id = item.id || item.name || item;
return id.toLowerCase().includes(filter.toLowerCase());
});
if (filter instanceof Function) {
newData = filter(dataset);
} else {
newData = dataset.filter((item) => {
const id = item.id || item.name || item;
return id.toLowerCase().includes(filter.toLowerCase());
});
}
}
return newData;
}

View File

@@ -20,4 +20,8 @@
width: 6.5rem;
min-height: 8rem;
}
&.card-width-20 {
width: 20rem;
}
}

View File

@@ -44,7 +44,8 @@
.has-error-border,
select.has-error-border,
.ttl-picker-form-field-error input,
.string-list-form-field-error .field:first-of-type textarea {
.string-list-form-field-error .field:first-of-type textarea,
.hds-form-checkbox.has-error-border {
border: 1px solid $red-500;
}

View File

@@ -53,6 +53,10 @@
padding-bottom: $spacing-24;
}
.has-top-padding-xs {
padding-top: $spacing-8;
}
.has-top-padding-s {
padding-top: $spacing-12;
}
@@ -167,6 +171,10 @@
margin-top: $spacing-48;
}
.has-top-margin-xxxl {
margin-top: $spacing-64;
}
.has-top-margin-negative-s {
margin-top: (-1 * $spacing-12);
}

View File

@@ -33,6 +33,7 @@ $spacing-24: 24px;
$spacing-32: 32px;
$spacing-36: 36px;
$spacing-48: 48px;
$spacing-64: 64px;
/* Border radius */
$radius: 2px;

View File

@@ -43,7 +43,7 @@ The `size` param defaults to the default page size set in [the app config](../co
### Serializing
In order to interrupt the regular serialization when using `lazyPaginatedData`, define `extractLazyPaginatedData` on the modelType's serializer. This will be called with the raw response before being cached on the store.
In order to interrupt the regular serialization when using `lazyPaginatedData`, define `extractLazyPaginatedData` on the modelType's serializer. This will be called with the raw response before being cached on the store. `extractLazyPaginatedData` should return an array of objects.
## Gotchas

View File

@@ -160,6 +160,17 @@ loadInitializers(App, config.modulePrefix);
If you used `ember g in-repo-engine <engine-name>` to generate the engines blueprint, it should have added `this.mount(<engine-name>)` to the main apps `router.js` file (this adds your engine and its associated routes). \*Move `this.mount(<engine-name>)` to match your engines route structure. For more information about [Routable Engines](https://ember-engines.com/docs/quickstart#routable-engines).
## Add engine path to ember-addon section of main app package.json
```json
"ember-addon": {
"paths": [
"lib/core",
"lib/your-new-engine"
]
},
```
### Important Notes:
- Anytime a new engine is created, you will need to `yarn install` and **RESTART** ember server!

View File

@@ -3,16 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="field">
<p class="control has-icons-left">
<input
class="filter input"
placeholder={{this.placeholder}}
data-test-filter-input
value={{this.value}}
{{on "input" this.onInput}}
{{did-insert this.focus}}
/>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
<div class="control {{unless @hideIcon 'has-icons-left'}}" data-test-filter-input-container>
<input class="filter input" ...attributes {{on "input" this.onInput}} {{did-insert this.focus}} data-test-filter-input />
{{#unless @hideIcon}}
<Icon @name="search" class="search-icon has-text-grey-light" data-test-filter-input-icon />
{{/unless}}
</div>

View File

@@ -1,6 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
@@ -10,25 +10,13 @@ import { debounce, next } from '@ember/runloop';
import type { HTMLElementEvent } from 'vault/forms';
interface Args {
value?: string; // initial value
placeholder?: string; // defaults to Type to filter results
wait?: number; // defaults to 200
wait?: number; // defaults to 500
autofocus?: boolean; // initially focus the input on did-insert
onInput(value: string): void;
hideIcon?: boolean; // hide the search icon in the input
onInput(value: string): void; // invoked with input value after debounce timer expires
}
export default class FilterInputComponent extends Component<Args> {
value: string | undefined;
constructor(owner: unknown, args: Args) {
super(owner, args);
this.value = this.args.value;
}
get placeholder() {
return this.args.placeholder || 'Type to filter results';
}
@action
focus(elem: HTMLElement) {
if (this.args.autofocus) {
@@ -38,11 +26,10 @@ export default class FilterInputComponent extends Component<Args> {
@action
onInput(event: HTMLElementEvent<HTMLInputElement>) {
const callback = () => {
this.args.onInput(event.target.value);
};
const wait = this.args.wait || 200;
const wait = this.args.wait || 500;
// ts complains when trying to pass object of optional args to callback as 3rd arg to debounce
debounce(this, callback, wait);
// eslint-disable-next-line
// @ts-ignore
debounce(this, this.args.onInput, event.target.value, wait);
}
}

View File

@@ -35,6 +35,21 @@
</div>
{{/each}}
</div>
{{else if (eq @attr.options.editType "checkboxList")}}
<Hds::Form::Checkbox::Group @name={{@attr.name}} data-test-input={{@attr.name}} as |G|>
{{#each @attr.options.possibleValues as |option|}}
<G.Checkbox::Field
class={{if this.validationError "has-error-border"}}
checked={{includes option (get @model this.valuePath)}}
@value={{option}}
@id={{option}}
{{on "change" this.handleChecklist}}
as |F|
>
<F.Label>{{option}}</F.Label>
</G.Checkbox::Field>
{{/each}}
</Hds::Form::Checkbox::Group>
{{else}}
<div class="control is-expanded">
<div class="select is-fullwidth">
@@ -101,7 +116,7 @@
/>
{{else if (eq @attr.options.editType "file")}}
{{! File Input }}
<div class="has-bottom-margin-m">
<div class="has-bottom-margin-m" data-test-input={{@attr.name}}>
<TextFile
@label={{this.labelString}}
@subText={{@attr.options.subText}}
@@ -325,7 +340,7 @@
@type="danger"
@message={{this.validationError}}
@paddingTop={{not-eq @attr.options.editType "ttl"}}
data-test-field-validation={{@attr.name}}
data-test-field-validation={{this.valuePath}}
class={{if (eq @attr.options.editType "stringArray") "has-top-margin-negative-xxl"}}
/>
{{/if}}
@@ -334,7 +349,7 @@
@type="warning"
@message={{this.validationWarning}}
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
data-test-validation-warning={{@attr.name}}
data-test-validation-warning={{this.valuePath}}
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
/>
{{/if}}

View File

@@ -184,4 +184,12 @@ export default class FormFieldComponent extends Component {
const prop = event.target.type === 'checkbox' ? 'checked' : 'value';
this.setAndBroadcast(event.target[prop]);
}
@action
handleChecklist(event) {
const valueArray = this.args.model[this.valuePath];
const method = event.target.checked ? 'addObject' : 'removeObject';
valueArray[method](event.target.value);
this.setAndBroadcast(valueArray);
}
}

View File

@@ -0,0 +1,35 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div ...attributes {{did-update this.updateSuggestions @mountPath}}>
{{#if @label}}
<FormFieldLabel for={{this.inputId}} @label={{@label}} @subText={{@subText}} />
{{/if}}
<FilterInput
id={{this.inputId}}
placeholder="Path to secret"
value={{@value}}
disabled={{not @mountPath}}
@hideIcon={{true}}
@onInput={{this.onInput}}
{{! used to trigger dropdown to open }}
{{on "click" this.onInputClick}}
data-test-kv-suggestion-input
/>
<PowerSelect
@eventType="click"
@options={{this.secrets}}
@onChange={{this.onSuggestionSelect}}
@renderInPlace={{true}}
@disabled={{not @mountPath}}
@registerAPI={{fn (mut this.powerSelectAPI)}}
{{! hide trigger component and use API to toggle dropdown }}
@triggerClass="is-hidden"
@noMatchesMessage="No suggestions for this path"
data-test-kv-suggestion-select
as |secret|
>
{{secret.path}}
</PowerSelect>
</div>

View File

@@ -0,0 +1,145 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { run } from '@ember/runloop';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
import type StoreService from 'vault/services/store';
import type KvSecretMetadataModel from 'vault/models/kv/metadata';
/**
* @module KvSuggestionInput
* Input component that fetches secrets at a provided mount path and displays them as suggestions in a dropdown
* As the user types the result set will be filtered providing suggestions for the user to select
* After the input debounce wait time (500ms), if the value ends in a slash, secrets will be fetched at that path
* The new result set will then be displayed in the dropdown as suggestions for the newly inputted path
* Selecting a suggestion will append it to the input value
* This allows the user to build a full path to a secret for the provided mount
* This is useful for helping the user find deeply nested secrets given the path based policy system
* If the user does not have list permission they are still able to enter a path to a secret but will not see suggestions
*
* @example
* <KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}} // input disabled when mount path is not provided
@onChange={{fn (mut this.secretPath)}}
/>
*/
interface Args {
label: string;
subText?: string;
mountPath: string;
value: string;
onChange: CallableFunction;
}
interface PowerSelectAPI {
actions: {
open(): void;
close(): void;
};
}
export default class KvSuggestionInputComponent extends Component<Args> {
@service declare readonly store: StoreService;
@tracked secrets: KvSecretMetadataModel[] = [];
powerSelectAPI: PowerSelectAPI | undefined;
_cachedSecrets: KvSecretMetadataModel[] = []; // cache the response for filtering purposes
inputId = `suggestion-input-${guidFor(this)}`; // add unique segment to id in case multiple instances of component are used on the same page
constructor(owner: unknown, args: Args) {
super(owner, args);
if (this.args.mountPath) {
this.updateSuggestions();
}
}
async fetchSecrets(isDirectory: boolean) {
const { mountPath } = this.args;
try {
const backend = keyIsFolder(mountPath) ? mountPath.slice(0, -1) : mountPath;
const parentDirectory = parentKeyForKey(this.args.value);
const pathToSecret = isDirectory ? this.args.value : parentDirectory;
const kvModels = (await this.store.query('kv/metadata', {
backend,
pathToSecret,
})) as unknown;
// this will be used to filter the existing result set when the search term changes within the same path
this._cachedSecrets = kvModels as KvSecretMetadataModel[];
return this._cachedSecrets;
} catch (error) {
console.log(error); // eslint-disable-line
return [];
}
}
filterSecrets(kvModels: KvSecretMetadataModel[] | undefined = [], isDirectory: boolean) {
const { value } = this.args;
const secretName = keyWithoutParentKey(value) || '';
return kvModels.filter((model) => {
if (!value || isDirectory) {
return true;
}
if (value === model.fullSecretPath) {
// don't show suggestion if it's currently selected
return false;
}
return model.path.toLowerCase().includes(secretName.toLowerCase());
});
}
@action
async updateSuggestions() {
const isFirstUpdate = !this._cachedSecrets.length;
const isDirectory = keyIsFolder(this.args.value);
if (!this.args.mountPath) {
this.secrets = [];
} else if (this.args.value && !isDirectory && this.secrets) {
// if we don't need to fetch from a new path, filter the previous result set with the updated search term
this.secrets = this.filterSecrets(this._cachedSecrets, isDirectory);
} else {
const kvModels = await this.fetchSecrets(isDirectory);
this.secrets = this.filterSecrets(kvModels, isDirectory);
}
// don't do anything on first update -- allow dropdown to open on input click
if (!isFirstUpdate) {
const action = this.secrets.length ? 'open' : 'close';
this.powerSelectAPI?.actions[action]();
}
}
@action
onInput(value: string) {
this.args.onChange(value);
this.updateSuggestions();
}
@action
onInputClick() {
if (this.secrets.length) {
this.powerSelectAPI?.actions.open();
}
}
@action
onSuggestionSelect(secret: KvSecretMetadataModel) {
// user may partially type a value to filter result set and then select a suggestion
// in this case the partially typed value must be replaced with suggestion value
// the fullSecretPath contains the previous selections or typed path segments
this.args.onChange(secret.fullSecretPath);
this.updateSuggestions();
// refocus the input after selection
run(() => document.getElementById(this.inputId)?.focus());
}
}

View File

@@ -18,6 +18,7 @@
@iconPosition="trailing"
@text={{@actionText}}
@route={{@actionTo}}
@isRouteExternal={{@actionExternal}}
@query={{@actionQuery}}
data-test-action-text={{@actionText}}
/>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Hds::Badge @text={{to-label @status}} @icon={{this.state.icon}} @color={{this.state.color}} ...attributes />

View File

@@ -0,0 +1,62 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
interface Args {
status: string; // https://developer.hashicorp.com/vault/docs/sync#sync-statuses
}
export default class SyncStatusBadge extends Component<Args> {
get state() {
switch (this.args.status) {
case 'SYNCING':
return {
icon: 'sync',
color: 'neutral',
};
case 'SYNCED':
return {
icon: 'check-circle',
color: 'success',
};
case 'UNSYNCING':
return {
icon: 'sync-reverse',
color: 'neutral',
};
case 'UNSYNCED':
return {
icon: 'sync-alert',
color: 'warning',
};
case 'INTERNAL_VAULT_ERROR':
return {
icon: 'x-circle',
color: 'critical',
};
case 'CLIENT_SIDE_ERROR':
return {
icon: 'x-circle',
color: 'critical',
};
case 'EXTERNAL_SERVICE_ERROR':
return {
icon: 'x-circle',
color: 'critical',
};
case 'UNKNOWN':
return {
icon: 'help',
color: 'neutral',
};
default:
return {
icon: 'help',
color: 'neutral',
};
}
}
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { helper as buildHelper } from '@ember/component/helper';
import type { SyncDestination, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
/*
This helper is referenced in the base sync destination model
to return static display attributes that rely on type
maskedParams: attributes for sensitive data, the API returns these values as '*****'
*/
const SYNC_DESTINATIONS: Array<SyncDestination> = [
{
name: 'AWS Secrets Manager',
type: 'aws-sm',
icon: 'aws-color',
category: 'cloud',
maskedParams: ['accessKeyId', 'secretAccessKey'],
},
{
name: 'Azure Key Vault',
type: 'azure-kv',
icon: 'azure-color',
category: 'cloud',
maskedParams: ['clientSecret'],
},
{
name: 'Google Secret Manager',
type: 'gcp-sm',
icon: 'gcp-color',
category: 'cloud',
maskedParams: ['credentials'],
},
{
name: 'Github Actions',
type: 'gh',
icon: 'github-color',
category: 'dev-tools',
maskedParams: ['accessToken'],
},
{
name: 'Vercel Project',
type: 'vercel-project',
icon: 'vercel-color',
category: 'dev-tools',
maskedParams: ['accessToken'],
},
];
export function syncDestinations(): Array<SyncDestination> {
return [...SYNC_DESTINATIONS];
}
export function destinationTypes(): Array<SyncDestinationType> {
return SYNC_DESTINATIONS.map((d) => d.type);
}
export function findDestination(type: SyncDestinationType | undefined): SyncDestination | undefined {
return SYNC_DESTINATIONS.find((d) => d.type === type);
}
export default buildHelper(syncDestinations);

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/kv-suggestion-input';

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/sync-status-badge';

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default, syncDestinations, destinationTypes, findDestination } from 'core/helpers/sync-destinations';

View File

@@ -3,4 +3,4 @@
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/helpers/to-label';
export { default, toLabel } from 'core/helpers/to-label';

View File

@@ -20,6 +20,10 @@
</p.levelLeft>
</PageHeader>
{{#if (has-block "syncDetails")}}
{{yield to="syncDetails"}}
{{/if}}
{{#if (has-block "tabLinks")}}
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="kv tabs">

View File

@@ -4,6 +4,46 @@
~}}
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:syncDetails>
{{#if this.syncStatus}}
<Hds::Alert data-test-sync-alert @type="page" @color="neutral" @icon={{false}} as |A|>
<A.Title>
This secret has been synced from Vault to other destinations, updates to the secret will get automatically synced
to destinations.
</A.Title>
{{#each this.syncStatus as |status|}}
<A.Description data-test-sync-alert={{status.destinationName}}>
<SyncStatusBadge @status={{status.syncStatus}} />
<Hds::Link::Inline
@route="syncDestination"
@color="secondary"
@isRouteExternal={{true}}
@models={{array status.destinationType status.destinationName}}
>
{{status.destinationName}}
</Hds::Link::Inline>
- last updated
{{date-format status.updatedAt "MMMM do yyyy, h:mm:ss a"}}
</A.Description>
{{/each}}
<A.Button
@icon={{if this.fetchSyncStatus.isRunning "loading"}}
@text="Refresh"
@color="secondary"
{{on "click" (perform this.fetchSyncStatus)}}
/>
<A.Link::Standalone
@isHrefExternal={{true}}
@icon="docs-link"
@text="About sync statuses"
@href={{doc-link "/vault/docs/sync#sync-statuses"}}
/>
</Hds::Alert>
{{/if}}
</:syncDetails>
<:tabLinks>
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>

View File

@@ -35,10 +35,12 @@ export default class KvSecretDetails extends Component {
@tracked showJsonView = false;
@tracked wrappedData = null;
@tracked syncStatus = null; // array of association sync status info by destination
secretDataIsAdvanced;
constructor() {
super(...arguments);
this.fetchSyncStatus.perform();
this.originalSecret = JSON.stringify(this.args.secret.secretData || {});
if (this.originalSecret.lastIndexOf('{') > 0) {
// Dumb way to check if there's a nested object in the secret
@@ -73,6 +75,18 @@ export default class KvSecretDetails extends Component {
}
}
@task
@waitFor
*fetchSyncStatus() {
const { backend, path } = this.args.secret;
const syncAdapter = this.store.adapterFor('sync/association');
try {
this.syncStatus = yield syncAdapter.fetchSyncStatus({ mount: backend, secretName: path });
} catch (e) {
// silently error
}
}
@action
async undelete() {
const { secret } = this.args;

View File

@@ -25,7 +25,7 @@ export default class KvEngine extends Engine {
'flash-messages',
'control-group',
],
externalRoutes: ['secrets'],
externalRoutes: ['secrets', 'syncDestination'],
};
}

View File

@@ -15,7 +15,6 @@ export default class KvSecretDetailsIndexRoute extends Route {
{ label: resolvedModel.backend, route: 'list' },
...breadcrumbsForSecret(resolvedModel.path, true),
];
controller.breadcrumbs = breadcrumbsArray;
}
}

View File

@@ -6,7 +6,12 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarFilters>
{{#if (and (not @promptConfig) @libraries)}}
<FilterInput @placeholder="Filter libraries" @onInput={{fn (mut this.filterValue)}} />
<FilterInput
placeholder="Filter libraries"
value={{this.filterValue}}
@wait={{200}}
@onInput={{fn (mut this.filterValue)}}
/>
{{/if}}
</:toolbarFilters>
<:toolbarActions>

View File

@@ -6,13 +6,7 @@
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
<:toolbarFilters>
{{#if (and (not @promptConfig) @roles.meta.total)}}
<FilterInput
@placeholder="Filter roles"
@value={{@pageFilter}}
@wait={{500}}
@autofocus={{true}}
@onInput={{this.onFilterChange}}
/>
<FilterInput placeholder="Filter roles" value={{@pageFilter}} @autofocus={{true}} @onInput={{this.onFilterChange}} />
{{/if}}
</:toolbarFilters>
<:toolbarActions>

View File

@@ -0,0 +1,75 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader
@icon={{@destination.icon}}
@title={{@destination.name}}
@breadcrumbs={{array
(hash label="Secrets Sync" route="secrets.overview")
(hash label="Destinations" route="secrets.destinations")
(hash label="Destination")
}}
/>
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="destination tabs">
<ul>
<LinkTo @route="secrets.destinations.destination.secrets" data-test-tab="Secrets">Secrets</LinkTo>
<LinkTo @route="secrets.destinations.destination.details" data-test-tab="Details">Details</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarActions>
{{#if @destination.canDelete}}
<Hds::Button
data-test-toolbar="Delete destination"
@text="Delete destination"
@color="secondary"
class="toolbar-button"
{{on "click" (fn (mut this.isDeleteModalOpen) true)}}
/>
{{#if (or @destination.canSync @destination.canEdit)}}
<div class="toolbar-separator"></div>
{{/if}}
{{/if}}
{{#if @destination.canSync}}
<Hds::Button
data-test-toolbar="Sync secrets"
@text="Sync secrets"
@icon="chevron-right"
@iconPosition="trailing"
@color="secondary"
class="toolbar-button"
@route="secrets.destinations.destination.sync"
/>
{{/if}}
{{#if @destination.canEdit}}
<Hds::Button
data-test-toolbar="Edit destination"
@text="Edit destination"
@icon="chevron-right"
@iconPosition="trailing"
@color="secondary"
class="toolbar-button"
@route="secrets.destinations.destination.edit"
/>
{{/if}}
</ToolbarActions>
</Toolbar>
<ConfirmationModal
@title="Delete destination?"
@onClose={{fn (mut this.isDeleteModalOpen) false}}
@isActive={{this.isDeleteModalOpen}}
@confirmText="DELETE"
@toConfirmMsg="— this is case-sensitive."
@onConfirm={{this.deleteDestination}}
>
<p>
The destination will be permanently deleted and all of its secrets will be un-synced. This cannot be undone.
</p>
</ConfirmationModal>

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
interface Args {
destination: SyncDestinationModel;
}
export default class DestinationsTabsToolbar extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
@action
async deleteDestination() {
try {
const { destination } = this.args;
const message = `Successfully deleted destination ${destination.name}.`;
await destination.destroyRecord();
this.store.clearDataset('sync/destination');
this.router.transitionTo('vault.cluster.sync.secrets.destinations');
this.flashMessages.success(message);
} catch (error) {
this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`);
}
}
}

View File

@@ -0,0 +1,48 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="box is-fullwidth is-sideless is-flex-between is-shadowless" data-test-cta-container>
{{#if this.version.isEnterprise}}
<p>
Sync secrets to platforms and tools across your stack to get secrets when and where you need them.
<Hds::Link::Standalone
@icon="learn-link"
@text="Secrets sync tutorial"
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
data-test-cta-doc-link
/>
</p>
{{else}}
<p>
This enterprise feature allows you to sync secrets to platforms and tools across your stack to get secrets when and
where you need them.
</p>
<Hds::Button
@text="Learn more about secrets sync"
@icon="docs-link"
@iconPosition="trailing"
@isHrefExternal={{true}}
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
data-test-cta-doc-link
/>
{{/if}}
</div>
<div class="is-flex-row has-gap-l has-bottom-margin-m">
<div>
<img src={{img-path "~/sync-landing-1.png"}} alt="Secrets sync destinations diagram" aria-describedby="sync-step-1" />
<p id="sync-step-1" class="has-top-margin-m">
<b>Step 1:</b>
Create a destination, and set up the connection details to allow Vault access.
</p>
</div>
<div>
<img src={{img-path "~/sync-landing-2.png"}} alt="Syncing secrets diagram" aria-describedby="sync-step-2" />
<p id="sync-step-2" class="has-top-margin-m">
<b>Step 2:</b>
Select secrets from your KV v2 engine and sync secrets to your destination.
</p>
</div>
</div>

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import type VersionService from 'vault/services/version';
export default class SyncLandingCtaComponent extends Component {
@service declare readonly version: VersionService;
}

View File

@@ -0,0 +1,124 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title="Secrets Sync" />
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="destination tabs">
<ul>
<LinkTo @route="secrets.overview" data-test-tab="Overview">Overview</LinkTo>
<LinkTo @route="secrets.destinations" data-test-tab="Destinations">Destinations</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarFilters>
<SearchSelect
@options={{this.destinationTypes}}
@objectKeys={{array "id" "name"}}
@passObject={{true}}
@selectLimit={{1}}
@disallowNewItems={{true}}
@placeholder="Filter by type"
@inputValue={{if this.typeFilter (array this.typeFilter)}}
@onChange={{fn this.onFilterChange "type"}}
class="is-marginless"
data-test-filter="type"
/>
<SearchSelect
@options={{this.destinationNames}}
@objectKeys={{array "id" "name"}}
@passObject={{true}}
@selectLimit={{1}}
@disallowNewItems={{true}}
@placeholder="Filter by name"
@inputValue={{if @nameFilter (array @nameFilter)}}
@onChange={{fn this.onFilterChange "name"}}
class="is-marginless has-left-padding-s"
data-test-filter="name"
/>
</ToolbarFilters>
<ToolbarActions>
<ToolbarLink @route="secrets.destinations.create" @type="add" data-test-create-destination>
Create new destination
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#if @destinations.meta.filteredTotal}}
<div class="has-bottom-margin-s">
{{#each @destinations as |destination index|}}
<ListItem
@linkPrefix={{this.mountPoint}}
@linkParams={{array "secrets.destinations.destination.secrets" destination.type destination.name}}
as |Item|
>
<Item.content>
<div>
<Icon @name={{destination.icon}} data-test-destination-icon={{index}} />
<span data-test-destination-name={{index}}>
{{destination.name}}
</span>
</div>
<code class="has-text-grey is-size-8" data-test-destination-type={{index}}>
{{destination.typeDisplayName}}
</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"
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}}
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
<Hds::Pagination::Numbered
@currentPage={{@destinations.meta.currentPage}}
@currentPageSize={{@destinations.meta.pageSize}}
@route="secrets.destinations"
@showSizeSelector={{false}}
@totalItems={{@destinations.meta.filteredTotal}}
@queryFunction={{this.paginationQueryParams}}
data-test-pagination
/>
</div>
{{else}}
<EmptyState @title={{this.noResultsMessage}} />
{{/if}}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { getOwner } from '@ember/application';
import errorMessage from 'vault/utils/error-message';
import { findDestination } from 'core/helpers/sync-destinations';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { EngineOwner } from 'vault/vault/app-types';
import type { SyncDestinationName, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
interface Args {
destinations: Array<SyncDestinationModel>;
nameFilter: SyncDestinationName;
typeFilter: SyncDestinationType;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
// typeFilter arg comes in as destination type but we need to pass the name of the type into the SearchSelect
get typeFilter() {
return findDestination(this.args.typeFilter)?.name;
}
get destinationNames() {
return this.args.destinations.map((destination) => ({ id: destination.name, name: destination.name }));
}
get destinationTypes() {
return this.args.destinations.reduce((types: Array<{ id: string; name: string }>, destination) => {
const { typeDisplayName } = destination;
const isUnique = !types.find((type) => type.id === typeDisplayName);
if (isUnique) {
types.push({ id: typeDisplayName, name: destination.type });
}
return types;
}, []);
}
get mountPoint(): string {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
}
get paginationQueryParams() {
return (page: number) => ({ page });
}
get noResultsMessage() {
const { nameFilter, typeFilter } = this.args;
if (nameFilter && typeFilter) {
return `There are no ${typeFilter} destinations matching "${nameFilter}".`;
}
if (nameFilter) {
return `There are no destinations matching "${nameFilter}".`;
}
if (typeFilter) {
return `There are no ${typeFilter} destinations.`;
}
return '';
}
@action
onFilterChange(key: string, selectObject: Array<{ id: string; name: string } | undefined>) {
this.router.transitionTo('vault.cluster.sync.secrets.destinations', {
queryParams: { [key]: selectObject[0]?.name },
});
}
@action
async onDelete(destination: SyncDestinationModel) {
try {
const { name } = destination;
const message = `Successfully deleted destination ${name}.`;
await destination.destroyRecord();
this.store.clearDataset('sync/destination');
this.router.transitionTo('vault.cluster.sync.secrets.destinations');
this.flashMessages.success(message);
} catch (error) {
this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`);
}
}
}

View File

@@ -0,0 +1,60 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title={{this.header.title}} @breadcrumbs={{this.header.breadcrumbs}} />
<hr class="is-marginless has-background-gray-200" />
<form {{on "submit" (perform this.save)}} class="has-top-margin-l">
<MessageError @errorMessage={{this.error}} />
{{#each @destination.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (and (eq group "Credentials") (not @destination.isNew))}}
<hr class="has-top-margin-xl has-bottom-margin-l has-background-gray-200" />
<Hds::Text::Display @tag="h2" @size="400" @weight="bold">Credentials</Hds::Text::Display>
<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))}}
<EnableInput data-test-enable-field={{attr.name}} class="field" @attr={{attr}}>
<FormField @attr={{attr}} @model={{@destination}} @modelValidations={{this.modelValidations}} />
</EnableInput>
{{else}}
<FormField
@attr={{attr}}
@model={{@destination}}
@modelValidations={{this.modelValidations}}
@onKeyUp={{this.updateWarningValidation}}
/>
{{/if}}
{{/each}}
{{/each-in}}
{{/each}}
<hr class="has-background-gray-200 has-top-margin-l" />
<Hds::ButtonSet>
<Hds::Button
@text={{if @model.isNew "Create destination" "Save"}}
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-cancel
/>
{{#if this.invalidFormMessage}}
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormMessage}} @mimicRefresh={{true}} />
{{/if}}
</Hds::ButtonSet>
</form>

View File

@@ -0,0 +1,103 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { inject as service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
import type SyncDestinationModel from 'vault/models/sync/destination';
import { ValidationMap } from 'vault/vault/app-types';
import type FlashMessageService from 'vault/services/flash-messages';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
interface Args {
destination: SyncDestinationModel;
}
export default class DestinationsCreateForm extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@tracked modelValidations: ValidationMap | null = null;
@tracked invalidFormMessage = '';
@tracked error = '';
get header() {
const { isNew, typeDisplayName, name } = this.args.destination;
return isNew
? {
title: `Create Destination for ${typeDisplayName}`,
breadcrumbs: [
{ label: 'Secrets Sync', route: 'secrets.overview' },
{ label: 'Select Destination', route: 'secrets.destinations.create' },
{ label: 'Create Destination' },
],
}
: {
title: `Edit ${name}`,
breadcrumbs: [
{ label: 'Secrets Sync', route: 'secrets.overview' },
{
label: 'Destination',
route: 'secrets.destinations.destination.secrets',
model: this.args.destination,
},
{ label: 'Edit Destination' },
],
};
}
@task
@waitFor
*save(event: Event) {
event.preventDefault();
// clear out validation warnings
this.modelValidations = null;
const { destination } = this.args;
const { isValid, state, invalidFormMessage } = destination.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormMessage = isValid ? '' : invalidFormMessage;
if (isValid) {
try {
const verb = destination.isNew ? 'created' : 'updated';
yield destination.save();
this.flashMessages.success(`Successfully ${verb} the destination ${destination.name}`);
this.store.clearDataset('sync/destination');
this.router.transitionTo(
'vault.cluster.sync.secrets.destinations.destination.details',
destination.type,
destination.name
);
} catch (error) {
this.error = errorMessage(error, 'Error saving destination. Please try again or contact support.');
}
}
}
@action
updateWarningValidation() {
if (this.args.destination.isNew) return;
// check for warnings on change
const { state } = this.args.destination.validate();
this.modelValidations = state;
}
@action
cancel() {
const { isNew } = this.args.destination;
const method = isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.destination[method]();
this.router.transitionTo(`vault.cluster.sync.secrets.destinations.${isNew ? 'create' : 'destination'}`);
}
}

View File

@@ -0,0 +1,18 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::DestinationHeader @destination={{@destination}} />
{{#each @destination.formFields as |field|}}
{{#let (get @destination field.name) as |value|}}
{{#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" />
</InfoTableRow>
{{else}}
<InfoTableRow @label={{or field.options.label (to-label field.name)}} @value={{value}} />
{{/if}}
{{/let}}
{{/each}}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import type SyncDestinationModel from 'vault/models/sync/destination';
interface Args {
destination: SyncDestinationModel;
}
export default class DestinationDetailsPage extends Component<Args> {
credentialValue = (value: string) => {
// if this value is empty, a destination uses globally set environment variables
return value ? 'Destination credentials added' : 'Using environment variable';
};
}

View File

@@ -0,0 +1,90 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::DestinationHeader @destination={{@destination}} />
{{#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}}/" />
<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>
<div>
<SyncStatusBadge @status={{association.syncStatus}} data-test-association-status={{index}} />
<code class="has-text-grey is-size-8" data-test-association-updated={{index}}>
last updated on
{{date-format association.updatedAt "MMMM do yyyy, h:mm:ss a"}}
</code>
</div>
</div>
</Item.content>
<Item.menu>
{{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}}
<li class="action">
<LoadingDropdownOption />
</li>
{{else}}
<li class="is-inline-block">
<Hds::Button
@text="Sync now"
class="link"
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"
data-test-association-action="view"
@route="kvSecretDetails"
@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 all destinations."
@onConfirmAction={{fn this.update association "remove"}}
/>
{{/if}}
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
<Hds::Pagination::Numbered
@currentPage={{@associations.meta.currentPage}}
@currentPageSize={{@associations.meta.pageSize}}
@route="secrets.destinations.destination.secrets"
@showSizeSelector={{false}}
@totalItems={{@associations.meta.filteredTotal}}
@queryFunction={{this.paginationQueryParams}}
data-test-pagination
/>
</div>
{{else}}
<EmptyState
@title="No synced secrets yet"
@message="Select secrets from existing KV version 2 engines and sync them to the destination."
>
<LinkTo class="has-top-margin-xs" @route="secrets.destinations.destination.sync">
Sync secrets
</LinkTo>
</EmptyState>
{{/if}}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { getOwner } from '@ember/application';
import errorMessage from 'vault/utils/error-message';
import SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncAssociationModel from 'vault/vault/models/sync/association';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { EngineOwner } from 'vault/vault/app-types';
interface Args {
destination: SyncDestinationModel;
associations: Array<SyncAssociationModel>;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
get mountPoint(): string {
const owner = getOwner(this) as EngineOwner;
return owner.mountPoint;
}
get paginationQueryParams() {
return (page: number) => ({ page });
}
@action
async update(association: SyncAssociationModel, operation: string) {
try {
await association.save({ adapterOptions: { action: operation } });
// this message can be expanded after testing -- deliberately generic for now
this.flashMessages.success(
'Sync operation successfully initiated. Status will be updated on secret when complete.'
);
} catch (error) {
this.flashMessages.danger(`Sync operation error: \n ${errorMessage(error)}`);
}
}
}

View File

@@ -0,0 +1,88 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader
@title="Sync Secrets to {{@destination.typeDisplayName}}"
@breadcrumbs={{array
(hash label="Secrets Sync" route="secrets.overview")
(hash label="Destinations" route="secrets.destinations")
(hash label="Destination" route="secrets.destinations.destination")
(hash label="Sync")
}}
/>
<form {{on "submit" (perform this.setAssociation)}} class={{unless (or this.error this.syncedSecret) "has-top-margin-m"}}>
<MessageError @errorMessage={{this.error}} />
{{#if this.syncedSecret}}
<Hds::Alert @type="inline" @color="success" as |A|>
<A.Title>Successfully synced a secret</A.Title>
<A.Description data-test-sync-success-message>
Sync operation successfully initiated for "{{this.syncedSecret}}". You can continue on this page to sync more
secrets.
</A.Description>
</Hds::Alert>
{{/if}}
<div class="has-top-margin-l">
<p class="is-label">Which secrets would you like us to sync?</p>
<p class="sub-text">
Select a KV engine mount and path to sync a secret to the destination.
</p>
<div class="has-top-margin-l">
{{#if this.mounts}}
<SearchSelect
@label="Select a mount for the KV engine"
@options={{this.mounts}}
@selectLimit={{1}}
@disallowNewItems={{true}}
@onChange={{this.setMount}}
data-test-sync-mount-select
/>
{{else}}
<FormFieldLabel for="kv-mount-input" @label="Enter an existing KV engine mount" />
<FilterInput
id="kv-mount-input"
placeholder="KV engine mount path"
value={{this.mountPath}}
@onInput={{fn (mut this.mountPath)}}
data-test-sync-mount-input
/>
{{/if}}
<KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}}
@onChange={{fn (mut this.secretPath)}}
/>
<div class="field box is-fullwidth is-bottomless has-top-margin-xxxl">
<Hds::ButtonSet>
<Hds::Button
@text="Sync to destination"
@color="primary"
@icon={{if this.setAssociation.isRunning "loading"}}
type="submit"
disabled={{this.isSubmitDisabled}}
data-test-sync-submit
/>
<Hds::Button @text="Back" @color="secondary" {{on "click" this.back}} data-test-sync-cancel />
</Hds::ButtonSet>
{{#if this.isSecretDirectory}}
<AlertInline
@type="warning"
@message="Syncing secret directories is not available at this time, please type a path to a single secret"
class="has-top-margin-s"
/>
{{/if}}
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { keyIsFolder } from 'core/utils/key-utils';
import errorMessage from 'vault/utils/error-message';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { SearchSelectOption } from 'vault/vault/app-types';
interface Args {
destination: SyncDestinationModel;
}
export default class DestinationSyncPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
constructor(owner: unknown, args: Args) {
super(owner, args);
this.fetchMounts();
}
@tracked mounts: SearchSelectOption[] = [];
@tracked mountPath = '';
@tracked secretPath = '';
@tracked error = '';
@tracked syncedSecret = '';
get isSecretDirectory() {
return this.secretPath && keyIsFolder(this.secretPath);
}
get isSubmitDisabled() {
return !this.mountPath || !this.secretPath || this.isSecretDirectory || this.setAssociation.isRunning;
}
willDestroy(): void {
this.store.clearDataset('sync/association');
super.willDestroy();
}
// unable to use built-in fetch functionality of SearchSelect since we need to filter by kv type
async fetchMounts() {
try {
const secretEngines = await this.store.query('secret-engine', {});
this.mounts = secretEngines.reduce((filtered, model) => {
if (model.type === 'kv' && model.version === 2) {
filtered.push({ name: model.path, id: model.path });
}
return filtered;
}, []);
} catch (error) {
// the user is still able to manually enter the mount path
// InputSearch component will render in this case
}
}
@action
back() {
this.router.transitionTo('vault.cluster.sync.secrets.destinations.destination.secrets');
}
@action
setMount(selected: Array<string>) {
this.mountPath = selected[0] || '';
}
setAssociation = task({}, async (event: Event) => {
event.preventDefault();
try {
this.syncedSecret = '';
const { name: destinationName, type: destinationType } = this.args.destination;
const mount = keyIsFolder(this.mountPath) ? this.mountPath.slice(0, -1) : this.mountPath; // strip trailing slash from mount path
const association = this.store.createRecord('sync/association', {
destinationName,
destinationType,
mount,
secretName: this.secretPath,
});
await association.save({ adapterOptions: { action: 'set' } });
this.syncedSecret = this.secretPath;
} catch (error) {
this.error = `Sync operation error: \n ${errorMessage(error)}`;
}
});
}

View File

@@ -0,0 +1,33 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader
@title="Select a Destination"
@breadcrumbs={{array (hash label="Secrets Sync" route="secrets.overview") (hash label="Select Destination")}}
/>
{{#each (array "cloud" "dev-tools") as |category|}}
<Hds::Text::Display @tag="h2" @size="300" class="has-top-padding-l has-bottom-padding-l">
{{if (eq category "cloud") "Cloud service providers" "Developer tools"}}
</Hds::Text::Display>
<div class="flex row-wrap row-gap-8 column-gap-16 has-bottom-padding-m">
{{#each (filter-by "category" category (sync-destinations)) as |d|}}
<SelectableCard
class="has-padding-l flex card-width-20"
@onClick={{transition-to "vault.cluster.sync.secrets.destinations.create.destination" d.type}}
data-test-select-destination={{d.type}}
>
<Icon @name={{d.icon}} @size="24" />
<Hds::Text::Display @tag="h3" @size="300">
{{d.name}}
</Hds::Text::Display>
</SelectableCard>
{{/each}}
</div>
{{/each}}
<hr class="has-background-gray-100" />
<Hds::Button @text="Cancel" @color="secondary" @route="secrets.overview" />

View File

@@ -0,0 +1,157 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title="Secrets Sync">
<:actions>
{{#unless @destinations}}
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
{{/unless}}
</:actions>
</SyncHeader>
{{#if @destinations}}
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="destination tabs">
<ul>
<li>
<LinkTo @route="secrets.overview" data-test-tab="Overview">Overview</LinkTo>
</li>
<li>
<LinkTo @route="secrets.destinations" data-test-tab="Destinations">Destinations</LinkTo>
</li>
</ul>
</nav>
</div>
<Toolbar aria-label="toolbar for sync overview page">
<ToolbarActions aria-label="toolbar action links">
<ToolbarLink
aria-label="toolbar link navigates to create a new destination"
@route="secrets.destinations.create"
@type="add"
data-test-create-destination
>
Create new destination
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<OverviewCard @cardTitle="Secrets by destination" class="has-top-margin-l">
{{#if this.fetchAssociationsForDestinations.isRunning}}
<div data-test-sync-overview-loading>
<Icon @name="loading" @size="24" />
Loading destinations...
</div>
{{else if (not this.destinationMetrics)}}
<EmptyState
@title="Error fetching information"
@message="Ensure that the policy has access to read sync associations."
>
<DocLink @path="/vault/api-docs/system/secrets-sync#read-associations">
API reference
</DocLink>
</EmptyState>
{{else}}
<Hds::Table>
<:head as |H|>
<H.Tr>
<H.Th>Sync destination</H.Th>
<H.Th @align="right"># of secrets</H.Th>
<H.Th @align="right">Last updated</H.Th>
<H.Th @align="right">Actions</H.Th>
</H.Tr>
</:head>
<:body as |B|>
{{#each this.destinationMetrics as |data index|}}
<B.Tr data-test-overview-table-row>
<B.Td>
<Icon @name={{data.icon}} data-test-overview-table-icon={{index}} />
<span data-test-overview-table-name={{index}}>{{data.name}}</span>
{{#if data.status}}
<Hds::Badge
@text={{data.status}}
@color={{if (eq data.status "All synced") "success"}}
data-test-overview-table-badge={{index}}
/>
{{/if}}
</B.Td>
<B.Td @align="right" data-test-overview-table-total={{index}}>
{{data.associationCount}}
</B.Td>
<B.Td @align="right" data-test-overview-table-updated={{index}}>
{{#if data.lastUpdated}}
{{date-format data.lastUpdated "MMMM do yyyy, h:mm:ss a"}}
{{else}}
&mdash;
{{/if}}
</B.Td>
<B.Td @align="right">
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="Actions"
@hasChevron={{false}}
@size="small"
data-test-overview-table-action-toggle={{index}}
/>
<dd.Interactive
@route="secrets.destinations.destination.sync"
@model={{data}}
@text="Sync secrets"
data-test-overview-table-action="sync"
/>
<dd.Interactive
@route="secrets.destinations.destination.secrets"
@models={{array data.type data.name}}
@text="Details"
data-test-overview-table-action="details"
/>
</Hds::Dropdown>
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
<Hds::Pagination::Numbered
@totalItems={{@destinations.length}}
@currentPage={{this.page}}
@currentPageSize={{this.pageSize}}
@showSizeSelector={{false}}
@onPageChange={{perform this.fetchAssociationsForDestinations}}
/>
{{/if}}
</OverviewCard>
<div class="is-grid grid-2-columns grid-gap-2 has-top-margin-l has-bottom-margin-l">
<OverviewCard
@cardTitle="Total destinations"
@subText="The total number of connected destinations"
@actionText="Create new"
@actionTo="secrets.destinations.create"
class="is-flex-half"
>
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-overview-card-content="Total destinations">
{{or @destinations.length "None"}}
</h2>
</OverviewCard>
<OverviewCard
@cardTitle="Total sync associations"
@subText="Total sync associations that count towards client count"
@actionText="View billing"
@actionTo="clientCountDashboard"
@actionExternal={{true}}
class="is-flex-half"
>
<h2
class="title is-2 has-font-weight-normal has-top-margin-m"
data-test-overview-card-content="Total sync associations"
>
{{or @totalAssociations "None"}}
</h2>
</OverviewCard>
</div>
{{else}}
<Secrets::LandingCta />
{{/if}}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import Ember from 'ember';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { SyncDestinationAssociationMetrics } from 'vault/vault/adapters/sync/association';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
interface Args {
destinations: Array<SyncDestinationModel>;
totalAssociations: number;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
@tracked destinationMetrics: SyncDestinationAssociationMetrics[] = [];
@tracked page = 1;
pageSize = Ember.testing ? 3 : 5; // lower in tests to test pagination without seeding more data
constructor(owner: unknown, args: Args) {
super(owner, args);
if (this.args.destinations.length) {
this.fetchAssociationsForDestinations.perform();
}
}
fetchAssociationsForDestinations = task(this, {}, async (page = 1) => {
try {
const total = page * this.pageSize;
const paginatedDestinations = this.args.destinations.slice(total - this.pageSize, total);
this.destinationMetrics = await this.store
.adapterFor('sync/association')
.fetchByDestinations(paginatedDestinations);
this.page = page;
} catch (error) {
this.destinationMetrics = [];
}
});
}

View File

@@ -0,0 +1,26 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{or @breadcrumbs (array (hash label="Secrets Sync"))}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3 has-bottom-margin-m" data-test-page-title>
{{#if @icon}}
<Icon @name={{@icon}} @size="24" />
{{/if}}
{{@title}}
{{#if this.version.isCommunity}}
<Hds::Badge @text="Enterprise feature" @color="highlight" @size="large" />
{{/if}}
</h1>
</p.levelLeft>
<p.levelRight>
{{yield to="actions"}}
</p.levelRight>
</PageHeader>

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import type VersionService from 'vault/services/version';
import type { Breadcrumb } from 'vault/vault/app-types';
interface Args {
title: string;
icon?: string;
breadcrumbs?: Breadcrumb[];
}
export default class SyncHeaderComponent extends Component<Args> {
@service declare readonly version: VersionService;
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class SyncDestinationSecretsController extends Controller {
queryParams = ['page'];
}

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class SyncSecretsDestinationsIndexController extends Controller {
queryParams = ['name', 'type', 'page'];
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Engine from 'ember-engines/engine';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';
import config from './config/environment';
const { modulePrefix } = config;
export default class SyncEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['flash-messages', 'router', 'store', 'version'],
externalRoutes: ['kvSecretDetails', 'clientCountDashboard'],
};
}
loadInitializers(SyncEngine, modulePrefix);

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import buildRoutes from 'ember-engines/routes';
export default buildRoutes(function () {
this.route('secrets', function () {
this.route('overview');
this.route('destinations', function () {
this.route('create', function () {
this.route('destination', { path: '/:type' });
});
this.route('destination', { path: '/:type/:name' }, function () {
this.route('edit');
this.route('details');
this.route('secrets');
this.route('sync');
});
});
});
});

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
export default class SyncIndexRoute extends Route {
@service declare readonly router: RouterService;
redirect() {
this.router.transitionTo('vault.cluster.sync.secrets.overview');
}
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import type StoreService from 'vault/services/store';
interface Params {
type: string;
}
export default class SyncSecretsDestinationsCreateDestinationRoute extends Route {
@service declare readonly store: StoreService;
model(params: Params) {
const { type } = params;
return this.store.createRecord(`sync/destinations/${type}`, { type });
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import type Store from '@ember-data/store';
interface RouteParams {
name: string;
type: string;
}
export default class SyncSecretsDestinationsDestinationRoute extends Route {
@service declare readonly store: Store;
model(params: RouteParams) {
const { name, type } = params;
return this.store.findRecord(`sync/destinations/${type}`, name);
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
export default class SyncSecretsDestinationsDestinationIndexRoute extends Route {
@service declare readonly router: RouterService;
redirect() {
this.router.transitionTo('vault.cluster.sync.secrets.destinations.destination.details');
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import SyncDestinationModel from 'vault/vault/models/sync/destination';
interface SyncDestinationSecretsRouteParams {
page: string;
}
export default class SyncDestinationSecretsRoute extends Route {
@service declare readonly store: StoreService;
model(params: SyncDestinationSecretsRouteParams) {
const destination = this.modelFor('secrets.destinations.destination') as SyncDestinationModel;
return hash({
destination,
associations: this.store.lazyPaginatedQuery('sync/association', {
responsePath: 'data.keys',
page: Number(params.page) || 1,
destinationType: destination.type,
destinationName: destination.name,
}),
});
}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
interface SyncSecretsDestinationsIndexRouteParams {
name: string;
type: string;
page: string;
}
export default class SyncSecretsDestinationsIndexRoute extends Route {
@service declare readonly store: StoreService;
filterData(dataset: Array<SyncDestinationModel>, name: string, type: string): Array<SyncDestinationModel> {
let filteredDataset = dataset;
const filter = (key: keyof SyncDestinationModel, value: string) => {
return dataset.filter((model) => {
return model[key].toLowerCase().includes(value.toLowerCase());
});
};
if (type) {
filteredDataset = filter('type', type);
}
if (name) {
filteredDataset = filter('name', name);
}
return filteredDataset;
}
async model(params: SyncSecretsDestinationsIndexRouteParams) {
const { name, type, page } = params;
return hash({
destinations: this.store.lazyPaginatedQuery('sync/destination', {
page: Number(page) || 1,
pageFilter: (dataset: Array<SyncDestinationModel>) => this.filterData(dataset, name, type),
responsePath: 'data.keys',
}),
nameFilter: params.name,
typeFilter: params.type,
});
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
export default class SyncSecretsOverviewRoute extends Route {
@service declare readonly store: StoreService;
async model() {
return hash({
destinations: this.store.query('sync/destination', {}).catch(() => []),
associations: this.store
.adapterFor('sync/association')
.queryAll()
.catch(() => []),
});
}
}

View File

@@ -0,0 +1,8 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title="Secrets Sync" @breadcrumbs={{array (hash label="Secrets Sync" route="secrets.overview")}} />
<Page::Error @error={{this.model}} />

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::SelectType />

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::Destination::Details @destination={{this.model}} />

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />

View File

@@ -0,0 +1,9 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::Destination::Secrets
@destination={{this.model.destination}}
@associations={{this.model.associations}}
/>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations::Destination::Sync @destination={{this.model}} />

View File

@@ -0,0 +1,10 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Destinations
@destinations={{this.model.destinations}}
@nameFilter={{this.model.nameFilter}}
@typeFilter={{this.model.typeFilter}}
/>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<LayoutLoading />

View File

@@ -0,0 +1,9 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Secrets::Page::Overview
@destinations={{this.model.destinations}}
@totalAssociations={{this.model.associations.total_associations}}
/>

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
/* eslint-env node */
'use strict';
module.exports = function (environment) {
const ENV = {
modulePrefix: 'sync',
environment,
};
return ENV;
};

22
ui/lib/sync/index.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
/* eslint-env node */
/* eslint-disable n/no-extraneous-require */
'use strict';
const { buildEngine } = require('ember-engines/lib/engine-addon');
module.exports = buildEngine({
name: 'sync',
lazyLoading: Object.freeze({
enabled: false,
}),
isDevelopingAddon() {
return true;
},
});

29
ui/lib/sync/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "sync",
"keywords": [
"ember-addon",
"ember-engine"
],
"dependencies": {
"@hashicorp/design-system-components": "*",
"ember-cli-htmlbars": "*",
"ember-cli-babel": "*",
"ember-concurrency": "*",
"@ember/test-waiters": "*",
"ember-cli-typescript": "*",
"@types/ember": "latest",
"@types/ember-data": "latest",
"@types/ember-data__store": "latest",
"@types/ember__array": "latest",
"@types/ember__component": "latest",
"@types/ember__controller": "latest",
"@types/ember__engine": "latest",
"@types/ember__routing": "latest",
"@types/rsvp": "latest"
},
"ember-addon": {
"paths": [
"../core"
]
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
accessor: 'kv_eb4acbae', // mount will be added to API response for use in the ui but leaving since it is a documented property
mount: 'my-kv',
secret_name: 'my-path/my-secret-1',
sync_status: 'SYNCED',
updated_at: '2023-09-20T10:51:53.961861096-04:00',
// set on create for lookup by destination
type: null,
name: null,
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Factory, trait } from 'ember-cli-mirage';
export default Factory.extend({
['aws-sm']: trait({
type: 'aws-sm',
name: 'destination-aws',
access_key_id: '*****',
secret_access_key: '*****',
region: 'us-west-1',
}),
['azure-kv']: trait({
type: 'azure-kv',
name: 'destination-azure',
key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net',
subscription_id: 'subscription-id',
tenant_id: 'tenant-id',
client_id: 'azure-client-id',
client_secret: '*****',
}),
['gcp-sm']: trait({
type: 'gcp-sm',
name: 'destination-gcp',
credentials: '*****',
project_id: 'gcp-project-id', // TODO backend will add, doesn't exist yet
}),
gh: trait({
type: 'gh',
name: 'destination-gh',
access_token: '*****',
repository_owner: 'my-organization-or-username',
repository_name: 'my-repository',
}),
['vercel-project']: trait({
type: 'vercel-project',
name: 'destination-vercel',
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
}),
});

View File

@@ -17,6 +17,7 @@ import mfaConfig from './mfa-config';
import mfaLogin from './mfa-login';
import oidcConfig from './oidc-config';
import reducedDisclosure from './reduced-disclosure';
import sync from './sync';
export {
base,
@@ -31,4 +32,5 @@ export {
mfaLogin,
oidcConfig,
reducedDisclosure,
sync,
};

Some files were not shown because too many files have changed in this diff Show More