diff --git a/changelog/23667.txt b/changelog/23667.txt new file mode 100644 index 0000000000..63cd2cf2c3 --- /dev/null +++ b/changelog/23667.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Secrets Sync UI (enterprise)**: Adds secret syncing for KV v2 secrets to external destinations using the UI. +``` \ No newline at end of file diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index a02298456b..751b0bad6e 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -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) { diff --git a/ui/app/adapters/sync/association.js b/ui/app/adapters/sync/association.js new file mode 100644 index 0000000000..7057e23927 --- /dev/null +++ b/ui/app/adapters/sync/association.js @@ -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); + } +} diff --git a/ui/app/adapters/sync/destination.js b/ui/app/adapters/sync/destination.js new file mode 100644 index 0000000000..82ea90f7c3 --- /dev/null +++ b/ui/app/adapters/sync/destination.js @@ -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); + } +} diff --git a/ui/app/adapters/sync/destinations/aws-sm.js b/ui/app/adapters/sync/destinations/aws-sm.js new file mode 100644 index 0000000000..f046baa915 --- /dev/null +++ b/ui/app/adapters/sync/destinations/aws-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/azure-kv.js b/ui/app/adapters/sync/destinations/azure-kv.js new file mode 100644 index 0000000000..3b0b61ed93 --- /dev/null +++ b/ui/app/adapters/sync/destinations/azure-kv.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/gcp-sm.js b/ui/app/adapters/sync/destinations/gcp-sm.js new file mode 100644 index 0000000000..5229562ab1 --- /dev/null +++ b/ui/app/adapters/sync/destinations/gcp-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/gh.js b/ui/app/adapters/sync/destinations/gh.js new file mode 100644 index 0000000000..df83b4229a --- /dev/null +++ b/ui/app/adapters/sync/destinations/gh.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/vercel-project.js b/ui/app/adapters/sync/destinations/vercel-project.js new file mode 100644 index 0000000000..253ab72c9a --- /dev/null +++ b/ui/app/adapters/sync/destinations/vercel-project.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/app.js b/ui/app/app.js index 2fe614d04c..ca5ec0a052 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -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', + }, + }, + }, }; } diff --git a/ui/app/components/sidebar/nav/cluster.hbs b/ui/app/components/sidebar/nav/cluster.hbs index bb3c142de0..2b0ecff88d 100644 --- a/ui/app/components/sidebar/nav/cluster.hbs +++ b/ui/app/components/sidebar/nav/cluster.hbs @@ -13,6 +13,12 @@ @text="Secrets Engines" data-test-sidebar-nav-link="Secrets Engines" /> + {{#if (has-permission "access")}} + !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(','); + } +} diff --git a/ui/app/router.js b/ui/app/router.js index 2390c757fb..51396f4870 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -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' }); diff --git a/ui/app/serializers/sync/association.js b/ui/app/serializers/sync/association.js new file mode 100644 index 0000000000..a0f83e1a9e --- /dev/null +++ b/ui/app/serializers/sync/association.js @@ -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, + }; + } +} diff --git a/ui/app/serializers/sync/destination.js b/ui/app/serializers/sync/destination.js new file mode 100644 index 0000000000..8215ea4c50 --- /dev/null +++ b/ui/app/serializers/sync/destination.js @@ -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; + } +} diff --git a/ui/app/serializers/sync/destinations/aws-sm.js b/ui/app/serializers/sync/destinations/aws-sm.js new file mode 100644 index 0000000000..9ca35debaf --- /dev/null +++ b/ui/app/serializers/sync/destinations/aws-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsAwsSecretsManagerSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/azure-kv.js b/ui/app/serializers/sync/destinations/azure-kv.js new file mode 100644 index 0000000000..f733cc2b4f --- /dev/null +++ b/ui/app/serializers/sync/destinations/azure-kv.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsAzureKeyVaultSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/gcp-sm.js b/ui/app/serializers/sync/destinations/gcp-sm.js new file mode 100644 index 0000000000..d454ee7c77 --- /dev/null +++ b/ui/app/serializers/sync/destinations/gcp-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationGoogleCloudSecretManagerSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/gh.js b/ui/app/serializers/sync/destinations/gh.js new file mode 100644 index 0000000000..b5f75ad37e --- /dev/null +++ b/ui/app/serializers/sync/destinations/gh.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsGithubSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/vercel-project.js b/ui/app/serializers/sync/destinations/vercel-project.js new file mode 100644 index 0000000000..38e16e10b2 --- /dev/null +++ b/ui/app/serializers/sync/destinations/vercel-project.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsVercelProjectSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/services/store.js b/ui/app/services/store.js index f3497eae57..c2bc4fd713 100644 --- a/ui/app/services/store.js +++ b/ui/app/services/store.js @@ -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; } diff --git a/ui/app/styles/components/selectable-card.scss b/ui/app/styles/components/selectable-card.scss index 3b8ad37971..cb3fef378c 100644 --- a/ui/app/styles/components/selectable-card.scss +++ b/ui/app/styles/components/selectable-card.scss @@ -20,4 +20,8 @@ width: 6.5rem; min-height: 8rem; } + + &.card-width-20 { + width: 20rem; + } } diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss index 4cf691112b..7f31e1df38 100644 --- a/ui/app/styles/helper-classes/colors.scss +++ b/ui/app/styles/helper-classes/colors.scss @@ -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; } diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 3aa227a02c..a208705622 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -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); } diff --git a/ui/app/styles/utils/_size_variables.scss b/ui/app/styles/utils/_size_variables.scss index 2109662eb7..97d1a53203 100644 --- a/ui/app/styles/utils/_size_variables.scss +++ b/ui/app/styles/utils/_size_variables.scss @@ -33,6 +33,7 @@ $spacing-24: 24px; $spacing-32: 32px; $spacing-36: 36px; $spacing-48: 48px; +$spacing-64: 64px; /* Border radius */ $radius: 2px; diff --git a/ui/docs/client-pagination.md b/ui/docs/client-pagination.md index 082e99c597..7fd0197c6c 100644 --- a/ui/docs/client-pagination.md +++ b/ui/docs/client-pagination.md @@ -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 diff --git a/ui/docs/ember-engines.md b/ui/docs/ember-engines.md index e2728d2dcc..56cd5841e3 100644 --- a/ui/docs/ember-engines.md +++ b/ui/docs/ember-engines.md @@ -160,6 +160,17 @@ loadInitializers(App, config.modulePrefix); If you used `ember g in-repo-engine ` to generate the engine’s blueprint, it should have added `this.mount()` to the main app’s `router.js` file (this adds your engine and its associated routes). \*Move `this.mount()` to match your engine’s 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! diff --git a/ui/lib/core/addon/components/filter-input.hbs b/ui/lib/core/addon/components/filter-input.hbs index 5b8740c59f..2f01821626 100644 --- a/ui/lib/core/addon/components/filter-input.hbs +++ b/ui/lib/core/addon/components/filter-input.hbs @@ -3,16 +3,9 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
-

- - -

+
+ + {{#unless @hideIcon}} + + {{/unless}}
\ No newline at end of file diff --git a/ui/lib/core/addon/components/filter-input.ts b/ui/lib/core/addon/components/filter-input.ts index 667f07298e..00ba0bf585 100644 --- a/ui/lib/core/addon/components/filter-input.ts +++ b/ui/lib/core/addon/components/filter-input.ts @@ -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 { - 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 { @action onInput(event: HTMLElementEvent) { - 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); } } diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index d47ad19d10..33a67116ac 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -35,6 +35,21 @@
{{/each}} + {{else if (eq @attr.options.editType "checkboxList")}} + + {{#each @attr.options.possibleValues as |option|}} + + {{option}} + + {{/each}} + {{else}}
@@ -101,7 +116,7 @@ /> {{else if (eq @attr.options.editType "file")}} {{! File Input }} -
+
{{/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}} diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 5552b8bc6f..f1aa94376c 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -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); + } } diff --git a/ui/lib/core/addon/components/kv-suggestion-input.hbs b/ui/lib/core/addon/components/kv-suggestion-input.hbs new file mode 100644 index 0000000000..f277a5afd7 --- /dev/null +++ b/ui/lib/core/addon/components/kv-suggestion-input.hbs @@ -0,0 +1,35 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} +
+ {{#if @label}} + + {{/if}} + + + {{secret.path}} + +
\ No newline at end of file diff --git a/ui/lib/core/addon/components/kv-suggestion-input.ts b/ui/lib/core/addon/components/kv-suggestion-input.ts new file mode 100644 index 0000000000..55bb7fdcbc --- /dev/null +++ b/ui/lib/core/addon/components/kv-suggestion-input.ts @@ -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 + * + */ + +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 { + @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()); + } +} diff --git a/ui/lib/core/addon/components/overview-card.hbs b/ui/lib/core/addon/components/overview-card.hbs index d57304a5a8..29bcee56d9 100644 --- a/ui/lib/core/addon/components/overview-card.hbs +++ b/ui/lib/core/addon/components/overview-card.hbs @@ -18,6 +18,7 @@ @iconPosition="trailing" @text={{@actionText}} @route={{@actionTo}} + @isRouteExternal={{@actionExternal}} @query={{@actionQuery}} data-test-action-text={{@actionText}} /> diff --git a/ui/lib/core/addon/components/sync-status-badge.hbs b/ui/lib/core/addon/components/sync-status-badge.hbs new file mode 100644 index 0000000000..e3111f9d18 --- /dev/null +++ b/ui/lib/core/addon/components/sync-status-badge.hbs @@ -0,0 +1,6 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + \ No newline at end of file diff --git a/ui/lib/core/addon/components/sync-status-badge.ts b/ui/lib/core/addon/components/sync-status-badge.ts new file mode 100644 index 0000000000..754ad9242d --- /dev/null +++ b/ui/lib/core/addon/components/sync-status-badge.ts @@ -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 { + 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', + }; + } + } +} diff --git a/ui/lib/core/addon/helpers/sync-destinations.ts b/ui/lib/core/addon/helpers/sync-destinations.ts new file mode 100644 index 0000000000..1ce6c23659 --- /dev/null +++ b/ui/lib/core/addon/helpers/sync-destinations.ts @@ -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 = [ + { + 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 { + return [...SYNC_DESTINATIONS]; +} + +export function destinationTypes(): Array { + 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); diff --git a/ui/lib/core/app/components/kv-suggestion-input.js b/ui/lib/core/app/components/kv-suggestion-input.js new file mode 100644 index 0000000000..9d577d015b --- /dev/null +++ b/ui/lib/core/app/components/kv-suggestion-input.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/kv-suggestion-input'; diff --git a/ui/lib/core/app/components/sync-status-badge.js b/ui/lib/core/app/components/sync-status-badge.js new file mode 100644 index 0000000000..4e857d6656 --- /dev/null +++ b/ui/lib/core/app/components/sync-status-badge.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/sync-status-badge'; diff --git a/ui/lib/core/app/helpers/sync-destinations.js b/ui/lib/core/app/helpers/sync-destinations.js new file mode 100644 index 0000000000..8e7f0a6571 --- /dev/null +++ b/ui/lib/core/app/helpers/sync-destinations.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default, syncDestinations, destinationTypes, findDestination } from 'core/helpers/sync-destinations'; diff --git a/ui/lib/core/app/helpers/to-label.js b/ui/lib/core/app/helpers/to-label.js index 699d9ed77c..3df5eae56f 100644 --- a/ui/lib/core/app/helpers/to-label.js +++ b/ui/lib/core/app/helpers/to-label.js @@ -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'; diff --git a/ui/lib/kv/addon/components/kv-page-header.hbs b/ui/lib/kv/addon/components/kv-page-header.hbs index a61861bf09..dcc73621df 100644 --- a/ui/lib/kv/addon/components/kv-page-header.hbs +++ b/ui/lib/kv/addon/components/kv-page-header.hbs @@ -20,6 +20,10 @@ +{{#if (has-block "syncDetails")}} + {{yield to="syncDetails"}} +{{/if}} + {{#if (has-block "tabLinks")}}