From 59e83e2e6df2ed5d499f69bb365a998bedc94102 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Thu, 18 Feb 2021 09:36:31 -0700 Subject: [PATCH] UI Database Secrets Engine (MongoDB) (#10655) * move the ttls on enable for db to default and not as options * refactor form field to angle brackets * add database to supported backend * initial setup of components and models * setup selectable cards, need to make own component * styling setup * subtext and links * number styling * search select put in place and button, all pretty things * search label text * messy but closer to data configuration. making models and fetching those models on routes * connection adapter and serializer that is pulled in by the overview route * clean up and add new model params connections and roles to overview route hbs * setting up overview as route with SecretHeader component. TODO, show Overview tab, but have link to route. It's going be on the secret header list component * setup overview tab on secret-list-header to go to overview page * setup id in overview route * Correct link on secrets engine list for database and others * Roles tab on database fetches correct model * Update options for backend with hasOverview param so overview tab is rendered conditionally on secret list header * create new getCrendentialsComponent * Rename database connection parent component and start working on display * setup routing to credentials route for database from overview page * setup network request for the credentials of role * setup serializer for credentials * redirect previous route * fix border color on button disable * add margin to back button * change to glimmer component * glimmerize and clean up the get-credentials-card * Begin database connection show and create form * add component test for the get-credentials-card * Database connection model and field groups * add static roles to searhSelect * add staticRoles on overview page * Toolbar and tabs on database connection show view looks correct * combine static and dynamic role models for pagination * Update database-list-item with real link to connection * Add support for optionalText edit type on form-field * handle situation when no static and/or dynamic roles * turn partial into component so can handle computed and eventually click actions, similar to transform * glimmerize database-list-item * use lazy capabilities on list role and static-role actions * Create connection works and redirects to show page * creds request based on dynamic or static and unload the store by record creds when they transition away. * dynamcially add in backend for queries * fixes on overview page for get credentials with hardcoded backend and layout for static creds * Rotate and Reset connection actions working on connection * get credentials set the query params * setup async for handling permission errors on overivew * Move query logic to store for getting both types of role * Filtering works on combined role models * cleanup * Fix no meta on connections list * better handle the situation where you don't have access to list roles but do to generate * implment updated empty state component and add to credentials page when roleType is noRoleType * glimmerize the input search component * move logic for generate credentials urlto the generate creds component * remove query param for role type * handle permissions on the overview page * permissions for role list * New roles route for backends * handle different permissions for empty return on 404 vs 403 on overview page * fix links on overview page * Connetions WIP * setup lazy caps for the connections model and list * add computed to role and static role models to clean up permissions * setup actions for connections list * Update form-field to show password type and update json input to angle bracket syntax with optional theme option * setup capabilities on overview for empty state * fix hardcoded on the backend * toggle inner label has width 100% * Add custom update password togglable input on database connection edit form, and only submit defined attrs * Add updateRecord to connection adapter * glimmerize secret list header and make new component which either shows or does not show the tab based on permissions * Remove tabs on show connection * add peek record * Update database role to get both models on a single model, remove static-role model and adapter, remove roles route * fix creds permissions on database-list-item * add component info and rename for secret-list-header-tab * fix issues on overview page * Add path to individual role on serializer * add accetpance test for testing the engine * fix transform test * test fix * Update connection before role created, disable button with tooltip if user cannot update path * Add add-to-array and remove-from-array helpers with tests * Clean up connection update on delete or create role, cleanup logs, role create link works * Database role create and edit forms with readonly fields and validation. Add readonly-form-field * Add field div around ttl picker for correct spacing on form-field * fix the breadcrumbs * PLaceholder test for readonly form field * create new helper to format time duration * tooltip and formatting on static role * more on static roles time stuff * clean up * clean up * fixes on the test and addition of another helper test * fix secrets machine test * Add modal to connection creation flow * fix issue with readonly form field test * Add is-empty-object helper and tests * Role error handling * Remove Atlas option from connection list, add defaults to db role form * clean up stuff though might have made it uglier * clean up * Add capabilities checks on connection actions * Fix jsdocs on readonly-form-field * Fix json editor height on form field * Readonly form has notallowed cursor, readonly form field updates * Add blank field rendering to info-table-row * Start writing readonly form field tests * Address some PR comments * fix fallback action on search select * cleanup per comments * fix readonly form field test and lint * Cleanup string helpers * Replace renderBlank with alwaysRender logic * re-humanize label on readonly form field * Show defaultShown value on info-table-row if no value and always render * Show default on role and connection show table * Add changelog Co-authored-by: Chelsea Shaw --- changelog/10655.txt | 3 + ui/app/adapters/database/connection.js | 76 +++++ ui/app/adapters/database/credential.js | 17 ++ ui/app/adapters/database/role.js | 168 +++++++++++ ui/app/adapters/generated-item-list.js | 1 + ui/app/components/database-connection.js | 141 +++++++++ ui/app/components/database-list-item.js | 61 ++++ ui/app/components/database-role-edit.js | 81 ++++++ .../components/database-role-setting-form.js | 68 +++++ .../generate-credentials-database.js | 70 +++++ ui/app/components/get-credentials-card.js | 44 +++ ui/app/components/input-search.js | 18 ++ ui/app/components/secret-list-header-tab.js | 76 +++++ ui/app/components/secret-list-header.js | 20 +- ui/app/components/selectable-card.js | 4 + ui/app/helpers/add-to-array.js | 17 ++ ui/app/helpers/format-duration.js | 16 ++ ui/app/helpers/is-empty-object.js | 5 + ui/app/helpers/options-for-backend.js | 28 ++ ui/app/helpers/remove-from-array.js | 20 ++ ui/app/helpers/supported-secret-backends.js | 1 + ui/app/lib/attach-capabilities.js | 4 +- ui/app/models/database/connection.js | 167 +++++++++++ ui/app/models/database/credential.js | 11 + ui/app/models/database/role.js | 110 ++++++++ ui/app/models/secret-engine.js | 16 ++ ui/app/router.js | 2 + .../cluster/secrets/backend/create-root.js | 3 + .../cluster/secrets/backend/credentials.js | 13 +- .../vault/cluster/secrets/backend/list.js | 9 +- .../vault/cluster/secrets/backend/overview.js | 77 +++++ .../cluster/secrets/backend/secret-edit.js | 4 + ui/app/serializers/database/connection.js | 44 +++ ui/app/serializers/database/credential.js | 29 ++ ui/app/serializers/database/role.js | 70 +++++ ui/app/styles/components/codemirror.scss | 4 + ui/app/styles/components/empty-state.scss | 10 + .../styles/components/replication-page.scss | 5 - .../components/selectable-card-container.scss | 7 + ui/app/styles/components/selectable-card.scss | 20 ++ ui/app/styles/core/forms.scss | 1 + ui/app/styles/core/helpers.scss | 6 + ui/app/styles/core/toggle.scss | 4 + .../components/database-connection.hbs | 267 ++++++++++++++++++ .../components/database-list-item.hbs | 72 +++++ .../components/database-role-edit.hbs | 140 +++++++++ .../components/database-role-setting-form.hbs | 38 +++ .../generate-credentials-database.hbs | 102 +++++++ .../components/get-credentials-card.hbs | 27 ++ ui/app/templates/components/input-search.hbs | 10 + .../components/secret-list-header-tab.hbs | 7 + .../components/secret-list-header.hbs | 43 +-- .../templates/components/selectable-card.hbs | 29 +- .../secret-list/database-list-item.hbs | 4 + .../cluster/secrets/backend/credentials.hbs | 12 +- .../cluster/secrets/backend/overview.hbs | 53 ++++ .../vault/cluster/secrets/backend/roles.hbs | 116 ++++++++ .../vault/cluster/secrets/backends.hbs | 148 +++++----- ui/lib/core/addon/components/empty-state.js | 5 +- ui/lib/core/addon/components/form-field.js | 11 + .../core/addon/components/info-table-row.js | 6 +- .../addon/components/readonly-form-field.js | 36 +++ ui/lib/core/addon/components/search-select.js | 3 +- .../templates/components/empty-state.hbs | 12 +- .../components/form-field-groups.hbs | 14 +- .../addon/templates/components/form-field.hbs | 90 +++++- .../templates/components/info-table-row.hbs | 20 +- .../components/readonly-form-field.hbs | 56 ++++ .../addon/templates/components/toggle.hbs | 2 +- .../app/components/readonly-form-field.js | 1 + .../acceptance/enterprise-transform-test.js | 14 +- .../secrets/backend/database/secret-test.js | 85 ++++++ .../components/get-credentials-card-test.js | 53 ++++ .../components/readonly-form-field-test.js | 45 +++ .../integration/helpers/add-to-array-test.js | 36 +++ .../helpers/format-duration-test.js | 36 +++ .../helpers/is-empty-object-test.js | 39 +++ .../helpers/remove-from-array-test.js | 44 +++ ui/tests/pages/secrets/backend/list.js | 2 +- .../unit/machines/secrets-machine-test.js | 4 +- 80 files changed, 3082 insertions(+), 151 deletions(-) create mode 100644 changelog/10655.txt create mode 100644 ui/app/adapters/database/connection.js create mode 100644 ui/app/adapters/database/credential.js create mode 100644 ui/app/adapters/database/role.js create mode 100644 ui/app/components/database-connection.js create mode 100644 ui/app/components/database-list-item.js create mode 100644 ui/app/components/database-role-edit.js create mode 100644 ui/app/components/database-role-setting-form.js create mode 100644 ui/app/components/generate-credentials-database.js create mode 100644 ui/app/components/get-credentials-card.js create mode 100644 ui/app/components/input-search.js create mode 100644 ui/app/components/secret-list-header-tab.js create mode 100644 ui/app/helpers/add-to-array.js create mode 100644 ui/app/helpers/format-duration.js create mode 100644 ui/app/helpers/is-empty-object.js create mode 100644 ui/app/helpers/remove-from-array.js create mode 100644 ui/app/models/database/connection.js create mode 100644 ui/app/models/database/credential.js create mode 100644 ui/app/models/database/role.js create mode 100644 ui/app/routes/vault/cluster/secrets/backend/overview.js create mode 100644 ui/app/serializers/database/connection.js create mode 100644 ui/app/serializers/database/credential.js create mode 100644 ui/app/serializers/database/role.js create mode 100644 ui/app/templates/components/database-connection.hbs create mode 100644 ui/app/templates/components/database-list-item.hbs create mode 100644 ui/app/templates/components/database-role-edit.hbs create mode 100644 ui/app/templates/components/database-role-setting-form.hbs create mode 100644 ui/app/templates/components/generate-credentials-database.hbs create mode 100644 ui/app/templates/components/get-credentials-card.hbs create mode 100644 ui/app/templates/components/input-search.hbs create mode 100644 ui/app/templates/components/secret-list-header-tab.hbs create mode 100644 ui/app/templates/partials/secret-list/database-list-item.hbs create mode 100644 ui/app/templates/vault/cluster/secrets/backend/overview.hbs create mode 100644 ui/app/templates/vault/cluster/secrets/backend/roles.hbs create mode 100644 ui/lib/core/addon/components/readonly-form-field.js create mode 100644 ui/lib/core/addon/templates/components/readonly-form-field.hbs create mode 100644 ui/lib/core/app/components/readonly-form-field.js create mode 100644 ui/tests/acceptance/secrets/backend/database/secret-test.js create mode 100644 ui/tests/integration/components/get-credentials-card-test.js create mode 100644 ui/tests/integration/components/readonly-form-field-test.js create mode 100644 ui/tests/integration/helpers/add-to-array-test.js create mode 100644 ui/tests/integration/helpers/format-duration-test.js create mode 100644 ui/tests/integration/helpers/is-empty-object-test.js create mode 100644 ui/tests/integration/helpers/remove-from-array-test.js diff --git a/changelog/10655.txt b/changelog/10655.txt new file mode 100644 index 000000000..83ed76981 --- /dev/null +++ b/changelog/10655.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Database secrets engine, supporting MongoDB only +``` \ No newline at end of file diff --git a/ui/app/adapters/database/connection.js b/ui/app/adapters/database/connection.js new file mode 100644 index 000000000..6c6cb3eb3 --- /dev/null +++ b/ui/app/adapters/database/connection.js @@ -0,0 +1,76 @@ +import ApplicationAdapter from '../application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + urlFor(backend, id, type = '') { + if (type === 'ROTATE') { + return `${this.buildURL()}/${backend}/rotate-root/${id}`; + } else if (type === 'RESET') { + return `${this.buildURL()}/${backend}/reset/${id}`; + } + let url = `${this.buildURL()}/${backend}/config`; + if (id) { + url = `${this.buildURL()}/${backend}/config/${id}`; + } + return url; + }, + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + fetchByQuery(store, query) { + const { backend, id } = query; + return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + resp.backend = backend; + if (id) { + resp.id = id; + } + return resp; + }); + }, + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, + + createRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const id = snapshot.attr('name'); + const backend = snapshot.attr('backend'); + + return this.ajax(this.urlFor(backend, id), 'POST', { data }).then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: { + id, + ...data, + }, + }; + }); + }, + + updateRecord() { + return this.createRecord(...arguments); + }, + + deleteRecord(store, type, snapshot) { + const id = snapshot.id; + return this.ajax(this.urlFor('database', id), 'DELETE'); + }, + + rotateRootCredentials(backend, id) { + return this.ajax(this.urlFor('database', id, 'ROTATE'), 'POST'); + }, + + resetConnection(backend, id) { + return this.ajax(this.urlFor(backend, id, 'RESET'), 'POST'); + }, +}); diff --git a/ui/app/adapters/database/credential.js b/ui/app/adapters/database/credential.js new file mode 100644 index 000000000..e182ef8fc --- /dev/null +++ b/ui/app/adapters/database/credential.js @@ -0,0 +1,17 @@ +import ApplicationAdapter from '../application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + fetchByQuery(store, query) { + const { backend, roleType, secret } = query; + let creds = roleType === 'static' ? 'static-creds' : 'creds'; + return this.ajax( + `${this.buildURL()}/${encodeURIComponent(backend)}/${creds}/${encodeURIComponent(secret)}`, + 'GET' + ); + }, + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, +}); diff --git a/ui/app/adapters/database/role.js b/ui/app/adapters/database/role.js new file mode 100644 index 000000000..f7e6c7c27 --- /dev/null +++ b/ui/app/adapters/database/role.js @@ -0,0 +1,168 @@ +import { assign } from '@ember/polyfills'; +import { assert } from '@ember/debug'; +import ApplicationAdapter from '../application'; +import { allSettled } from 'rsvp'; +import { addToArray } from 'vault/helpers/add-to-array'; +import { removeFromArray } from 'vault/helpers/remove-from-array'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + pathForType() { + assert('Generate the url dynamically based on role type', false); + }, + + urlFor(backend, id, type = 'dynamic') { + let role = 'roles'; + if (type === 'static') { + role = 'static-roles'; + } + let url = `${this.buildURL()}/${backend}/${role}`; + if (id) { + url = `${this.buildURL()}/${backend}/${role}/${id}`; + } + return url; + }, + + staticRoles(backend, id) { + return this.ajax(this.urlFor(backend, id, 'static'), 'GET', this.optionsForQuery(id)); + }, + + dynamicRoles(backend, id) { + return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)); + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { backend, id } = query; + return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + resp.id = id; + resp.backend = backend; + return resp; + }); + }, + + queryRecord(store, type, query) { + const { backend, id } = query; + const staticReq = this.staticRoles(backend, id); + const dynamicReq = this.dynamicRoles(backend, id); + + return allSettled([staticReq, dynamicReq]).then(([staticResp, dynamicResp]) => { + if (!staticResp.value && !dynamicResp.value) { + // Throw error, both reqs failed + throw dynamicResp.reason; + } + // Names are distinct across both types of role, + // so only one request should ever come back with value + let type = staticResp.value ? 'static' : 'dynamic'; + let successful = staticResp.value || dynamicResp.value; + let resp = { + data: {}, + backend, + secret: id, + }; + + resp.data = assign({}, resp.data, successful.data, { backend, type, secret: id }); + + return resp; + }); + }, + + query(store, type, query) { + const { backend } = query; + const staticReq = this.staticRoles(backend); + const dynamicReq = this.dynamicRoles(backend); + + return allSettled([staticReq, dynamicReq]).then(([staticResp, dynamicResp]) => { + let resp = { + backend, + data: { keys: [] }, + }; + + if (staticResp.reason && dynamicResp.reason) { + // both failed, throw error + throw dynamicResp.reason; + } + // at least one request has data + let staticRoles = []; + let dynamicRoles = []; + + if (staticResp.value) { + staticRoles = staticResp.value.data.keys; + } + if (dynamicResp.value) { + dynamicRoles = dynamicResp.value.data.keys; + } + + resp.data = assign( + {}, + resp.data, + { keys: [...staticRoles, ...dynamicRoles] }, + { backend }, + { staticRoles, dynamicRoles } + ); + + return resp; + }); + }, + + async _updateAllowedRoles(store, { role, backend, db, type = 'add' }) { + const connection = await store.queryRecord('database/connection', { backend, id: db }); + let roles = [...connection.allowed_roles]; + const allowedRoles = type === 'add' ? addToArray([roles, role]) : removeFromArray([roles, role]); + connection.allowed_roles = allowedRoles; + return connection.save(); + }, + + async createRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const roleType = snapshot.attr('type'); + const backend = snapshot.attr('backend'); + const id = snapshot.attr('name'); + const db = snapshot.attr('database'); + await this._updateAllowedRoles(store, { + role: id, + backend, + db: db[0], + }); + + return this.ajax(this.urlFor(backend, id, roleType), 'POST', { data }).then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: assign({}, data, { id }), + }; + }); + }, + + async deleteRecord(store, type, snapshot) { + const roleType = snapshot.attr('type'); + const backend = snapshot.attr('backend'); + const id = snapshot.attr('name'); + const db = snapshot.attr('database'); + await this._updateAllowedRoles(store, { + role: id, + backend, + db: db[0], + type: 'remove', + }); + + return this.ajax(this.urlFor(backend, id, roleType), 'DELETE'); + }, + + async updateRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const roleType = snapshot.attr('type'); + const backend = snapshot.attr('backend'); + const id = snapshot.attr('name'); + + return this.ajax(this.urlFor(backend, id, roleType), 'POST', { data }).then(() => data); + }, +}); diff --git a/ui/app/adapters/generated-item-list.js b/ui/app/adapters/generated-item-list.js index 2deec43d6..c2009fd3e 100644 --- a/ui/app/adapters/generated-item-list.js +++ b/ui/app/adapters/generated-item-list.js @@ -7,6 +7,7 @@ export default ApplicationAdapter.extend({ urlForItem() {}, dynamicApiPath: '', getDynamicApiPath: task(function*(id) { + // TODO: remove yield at some point. let result = yield this.store.peekRecord('auth-method', id); this.dynamicApiPath = result.apiPath; return; diff --git a/ui/app/components/database-connection.js b/ui/app/components/database-connection.js new file mode 100644 index 000000000..bb5271b97 --- /dev/null +++ b/ui/app/components/database-connection.js @@ -0,0 +1,141 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +const getErrorMessage = errors => { + let errorMessage = errors?.join('. ') || 'Something went wrong. Check the Vault logs for more information.'; + if (errors?.join(' ').indexOf('failed to verify')) { + errorMessage = + 'There was a verification error for this connection. Check the Vault logs for more information.'; + } + return errorMessage; +}; + +export default class DatabaseConnectionEdit extends Component { + @service store; + @service router; + @service flashMessages; + + @tracked + showPasswordField = false; // used for edit mode + + @tracked + showSaveModal = false; // used for create mode + + rotateCredentials(backend, name) { + let adapter = this.store.adapterFor('database/connection'); + return adapter.rotateRootCredentials(backend, name); + } + + transitionToRoute() { + return this.router.transitionTo(...arguments); + } + + @action + updateShowPassword(showForm) { + this.showPasswordField = showForm; + if (!showForm) { + // unset password if hidden + this.args.model.password = undefined; + } + } + + @action + updatePassword(attr, evt) { + const value = evt.target.value; + this.args.model[attr] = value; + } + + @action + async handleCreateConnection(evt) { + evt.preventDefault(); + let secret = this.args.model; + let secretId = secret.name; + secret.set('id', secretId); + secret + .save() + .then(() => { + this.showSaveModal = true; + }) + .catch(e => { + const errorMessage = getErrorMessage(e.errors); + this.flashMessages.danger(errorMessage); + }); + } + + @action + continueWithoutRotate(evt) { + evt.preventDefault(); + const { name } = this.args.model; + this.transitionToRoute(SHOW_ROUTE, name); + } + + @action + continueWithRotate(evt) { + evt.preventDefault(); + const { backend, name } = this.args.model; + this.rotateCredentials(backend, name) + .then(() => { + this.flashMessages.success(`Successfully rotated root credentials for connection "${name}"`); + this.transitionToRoute(SHOW_ROUTE, name); + }) + .catch(e => { + this.flashMessages.danger(`Error rotating root credentials: ${e.errors}`); + this.transitionToRoute(SHOW_ROUTE, name); + }); + } + + @action + handleUpdateConnection(evt) { + evt.preventDefault(); + let secret = this.args.model; + let secretId = secret.name; + secret.save().then(() => { + this.transitionToRoute(SHOW_ROUTE, secretId); + }); + } + + @action + delete(evt) { + evt.preventDefault(); + const secret = this.args.model; + const backend = secret.backend; + secret.destroyRecord().then(() => { + this.transitionToRoute(LIST_ROOT_ROUTE, backend); + }); + } + + @action + reset() { + const { name, backend } = this.args.model; + let adapter = this.store.adapterFor('database/connection'); + adapter + .resetConnection(backend, name) + .then(() => { + // TODO: Why isn't the confirmAction closing? + this.flashMessages.success('Successfully reset connection'); + }) + .catch(e => { + const errorMessage = getErrorMessage(e.errors); + this.flashMessages.danger(errorMessage); + }); + } + + @action + rotate() { + const { name, backend } = this.args.model; + this.rotateCredentials(backend, name) + .then(() => { + // TODO: Why isn't the confirmAction closing? + this.flashMessages.success('Successfully rotated credentials'); + }) + .catch(e => { + const errorMessage = getErrorMessage(e.errors); + this.flashMessages.danger(errorMessage); + }); + } +} diff --git a/ui/app/components/database-list-item.js b/ui/app/components/database-list-item.js new file mode 100644 index 000000000..19aa96802 --- /dev/null +++ b/ui/app/components/database-list-item.js @@ -0,0 +1,61 @@ +/** + * @module DatabaseListItem + * DatabaseListItem components are used for the list items for the Database Secret Engines for Roles. + * This component automatically handles read-only list items if capabilities are not granted or the item is internal only. + * + * @example + * ```js + * + * ``` + * @param {object} item - item refers to the model item used on the list item partial + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class DatabaseListItem extends Component { + @tracked roleType = ''; + @service store; + @service flashMessages; + + get keyTypeValue() { + const item = this.args.item; + // basing this on path in case we want to remove 'type' later + if (item.path === 'roles') { + return 'dynamic'; + } else if (item.path === 'static-roles') { + return 'static'; + } else { + return ''; + } + } + + @action + resetConnection(id) { + const { backend } = this.args.item; + let adapter = this.store.adapterFor('database/connection'); + adapter + .resetConnection(backend, id) + .then(() => { + this.flashMessages.success(`Success: ${id} connection was reset`); + }) + .catch(e => { + this.flashMessages.danger(e.errors); + }); + } + @action + rotateRootCred(id) { + const { backend } = this.args.item; + let adapter = this.store.adapterFor('database/connection'); + adapter + .rotateRootCredentials(backend, id) + .then(() => { + this.flashMessages.success(`Success: ${id} connection was reset`); + }) + .catch(e => { + this.flashMessages.danger(e.errors); + }); + } +} diff --git a/ui/app/components/database-role-edit.js b/ui/app/components/database-role-edit.js new file mode 100644 index 000000000..113fbee43 --- /dev/null +++ b/ui/app/components/database-role-edit.js @@ -0,0 +1,81 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default class DatabaseRoleEdit extends Component { + @service router; + @service flashMessages; + + get warningMessages() { + let warnings = {}; + if (this.args.model.canUpdateDb === false) { + warnings.database = `You don’t have permissions to update this database connection, so this role cannot be created.`; + } + if ( + (this.args.model.type === 'dynamic' && this.args.model.canCreateDynamic === false) || + (this.args.model.type === 'static' && this.args.model.canCreateStatic === false) + ) { + warnings.type = `You don't have permissions to create this type of role.`; + } + return warnings; + } + + get databaseType() { + if (this.args.model?.database) { + // TODO: Calculate this + return 'mongodb-database-plugin'; + } + return null; + } + + @action + generateCreds(roleId) { + this.router.transitionTo('vault.cluster.secrets.backend.credentials', roleId); + } + + @action + delete() { + const secret = this.args.model; + const backend = secret.backend; + secret + .destroyRecord() + .then(() => { + this.router.transitionTo(LIST_ROOT_ROUTE, backend, { queryParams: { tab: 'role' } }); + }) + .catch(e => { + this.flashMessages.danger(e.errors?.join('. ')); + }); + } + + @action + handleCreateRole(evt) { + evt.preventDefault(); + let roleSecret = this.args.model; + let secretId = roleSecret.name; + roleSecret.set('id', secretId); + let path = roleSecret.type === 'static' ? 'static-roles' : 'roles'; + roleSecret.set('path', path); + roleSecret.save().then(() => { + this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`); + }); + } + + @action + handleCreateEditRole(evt) { + evt.preventDefault(); + const mode = this.args.mode; + let roleSecret = this.args.model; + let secretId = roleSecret.name; + if (mode === 'create') { + roleSecret.set('id', secretId); + let path = roleSecret.type === 'static' ? 'static-roles' : 'roles'; + roleSecret.set('path', path); + } + roleSecret.save().then(() => { + this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`); + }); + } +} diff --git a/ui/app/components/database-role-setting-form.js b/ui/app/components/database-role-setting-form.js new file mode 100644 index 000000000..97dc091ba --- /dev/null +++ b/ui/app/components/database-role-setting-form.js @@ -0,0 +1,68 @@ +/** + * @module DatabaseRoleSettingForm + * DatabaseRoleSettingForm components are used to handle the role settings section on the database/role form + * + * @example + * ```js + * + * ``` + * @param {Array} attrs - all available attrs from the model to iterate over + * @param {object} model - ember data model which should be updated on change + * @param {string} [roleType] - role type controls which attributes are shown + * @param {string} [mode=create] - mode of the form (eg. create or edit) + * @param {string} [dbType=default] - type of database, eg 'mongodb-database-plugin' + */ + +import Component from '@glimmer/component'; + +// Below fields are intended to be dynamic based on type of role and db. +// example of usage: FIELDS[roleType][db] +const ROLE_FIELDS = { + static: { + default: ['ttl', 'max_ttl', 'username', 'rotation_period'], + 'mongodb-database-plugin': ['username', 'rotation_period'], + }, + dynamic: { + default: ['ttl', 'max_ttl', 'username', 'rotation_period'], + 'mongodb-database-plugin': ['ttl', 'max_ttl'], + }, +}; + +const STATEMENT_FIELDS = { + static: { + default: ['creation_statements', 'revocation_statements', 'rotation_statements'], + 'mongodb-database-plugin': 'NONE', // will not show the section + }, + dynamic: { + default: ['creation_statements', 'revocation_statements', 'rotation_statements'], + 'mongodb-database-plugin': ['creation_statement'], + }, +}; + +export default class DatabaseRoleSettingForm extends Component { + get settingFields() { + const type = this.args.roleType; + if (!type) return null; + const db = this.args.dbType || 'default'; + const fields = ROLE_FIELDS[type][db]; + if (!Array.isArray(fields)) return fields; + const filtered = this.args.attrs.filter(a => { + const includes = fields.includes(a.name); + return includes; + }); + return filtered; + } + + get statementFields() { + const type = this.args.roleType; + if (!type) return null; + const db = this.args.dbType || 'default'; + const fields = STATEMENT_FIELDS[type][db]; + if (!Array.isArray(fields)) return fields; + const filtered = this.args.attrs.filter(a => { + const includes = fields.includes(a.name); + return includes; + }); + return filtered; + } +} diff --git a/ui/app/components/generate-credentials-database.js b/ui/app/components/generate-credentials-database.js new file mode 100644 index 000000000..f4e9cd85b --- /dev/null +++ b/ui/app/components/generate-credentials-database.js @@ -0,0 +1,70 @@ +/** + * @module GenerateCredentialsDatabase + * GenerateCredentialsDatabase component is used on the credentials route for the Database metrics. + * The component assumes that you will need to make an ajax request using queryRecord to return a model for the component that has username, password, leaseId and leaseDuration + * + * @example + * ```js + * + * ``` + * @param {string} backendPath - the secret backend name. This is used in the breadcrumb. + * @param {object} backendType - the secret type. Expected to be database. + * @param {string} roleName - the id of the credential returning. + */ + +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; +import { task } from 'ember-concurrency'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class GenerateCredentialsDatabase extends Component { + @service store; + // set on the component + backendType = null; + backendPath = null; + roleName = null; + @tracked roleType = ''; + @tracked model = null; + + constructor() { + super(...arguments); + this.fetchCredentials.perform(); + } + + @task(function*() { + let { roleName, backendPath } = this.args; + let errors = []; + try { + let newModel = yield this.store.queryRecord('database/credential', { + backend: backendPath, + secret: roleName, + roleType: 'static', + }); + // if successful will return result + this.model = newModel; + this.roleType = 'static'; + return; + } catch (error) { + errors.push(error.errors); + } + try { + let newModel = yield this.store.queryRecord('database/credential', { + backend: backendPath, + secret: roleName, + roleType: 'dynamic', + }); + this.model = newModel; + this.roleType = 'dynamic'; + return; + } catch (error) { + errors.push(error.errors); + } + this.roleType = 'noRoleFound'; + }) + fetchCredentials; + + @action redirectPreviousPage() { + window.history.back(); + } +} diff --git a/ui/app/components/get-credentials-card.js b/ui/app/components/get-credentials-card.js new file mode 100644 index 000000000..9bc650aac --- /dev/null +++ b/ui/app/components/get-credentials-card.js @@ -0,0 +1,44 @@ +/** + * @module GetCredentialsCard + * GetCredentialsCard components are card-like components that display a title, and SearchSelect component that sends you to a credentials route for the selected item. + * They are designed to be used in containers that act as flexbox or css grid containers. + * + * @example + * ```js + * + * ``` + * @param title=null {String} - The title displays the card title + * @param searchLabel=null {String} - The text above the searchSelect component + * @param models=null {Array} - An array of model types to fetch from the API. Passed through to SearchSelect component + */ + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +export default class GetCredentialsCard extends Component { + @service router; + @service store; + @tracked role = ''; + + @action + async transitionToCredential() { + const role = this.role; + if (role) { + this.router.transitionTo('vault.cluster.secrets.backend.credentials', role); + } + } + + get buttonDisabled() { + return !this.role; + } + @action + handleRoleInput(value) { + // if it comes in from the fallback component then the value is a string otherwise it's an array + let role = value; + if (Array.isArray(value)) { + role = value[0]; + } + this.role = role; + } +} diff --git a/ui/app/components/input-search.js b/ui/app/components/input-search.js new file mode 100644 index 000000000..d90abed33 --- /dev/null +++ b/ui/app/components/input-search.js @@ -0,0 +1,18 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class inputSelect extends Component { + /* + * @public + * @param Function + * + * Function called when any of the inputs change + * + */ + @tracked searchInput = ''; + @action + inputChanged() { + this.args.onChange(this.searchInput); + } +} diff --git a/ui/app/components/secret-list-header-tab.js b/ui/app/components/secret-list-header-tab.js new file mode 100644 index 000000000..14a2ae15f --- /dev/null +++ b/ui/app/components/secret-list-header-tab.js @@ -0,0 +1,76 @@ +/** + * @module SecretListHeaderTab + * SecretListHeaderTab component passes in properties that are used to check capabilities and either display or not display the component. + * Use case was first for the Database Secret Engine, but should be used in future iterations as we don't generally want to show things the user does not + * have access to + * + * + * @example + * ```js + * + * ``` + * @param {string} [displayName] - set on options-for-backend this sets a conditional to see if capabilities are being checked + * @param {string} [id] - if fetching capabilities used for making the query url. It is the name the user has assigned to the instance of the engine. + * @param {string} [path] - set on options-for-backend this tells us the specifics of the URL the query should hit. + * @param {string} label - The name displayed on the tab. Set on the options-for-backend. + * @param {string} [tab] - The name of the tab. Set on the options-for-backend. + * + */ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; + +export default class SecretListHeaderTab extends Component { + @service store; + @tracked dontShowTab; + constructor() { + super(...arguments); + this.fetchCapabilities(); + } + + pathQuery(backend, path) { + return { + id: `${backend}/${path}/`, + }; + } + + async fetchCapabilities() { + let capabilitiesArray = ['canList', 'canCreate', 'canUpdate']; + let checkCapabilities = function(object) { + let array = []; + // we only want to look at the canList, canCreate and canUpdate on the capabilities record + capabilitiesArray.forEach(item => { + array.push(object[item]); + }); + return array; + }; + let checker = arr => arr.every(item => !item); // same things as listing every item as !item && !item, etc. + // For now only check capabilities for the Database Secrets Engine + if (this.args.displayName === 'Database') { + let peekRecordRoles = this.store.peekRecord('capabilities', 'database/roles/'); + let peekRecordStaticRoles = this.store.peekRecord('capabilities', 'database/static-roles/'); + let peekRecordConnections = this.store.peekRecord('capabilities', 'database/config/'); + // peekRecord if the capabilities store data is there for the connections (config) and roles model + if ( + (peekRecordRoles && this.args.path === 'roles') || + (peekRecordStaticRoles && this.args.path === 'roles') + ) { + let roles = checker(checkCapabilities(peekRecordRoles)); + let staticRoles = checker(checkCapabilities(peekRecordStaticRoles)); + + this.dontShowTab = roles && staticRoles; + return; + } + if (peekRecordConnections && this.args.path === 'config') { + this.dontShowTab = checker(checkCapabilities(peekRecordConnections)); + return; + } + // otherwise queryRecord and create an instance on the capabilities. + let response = await this.store.queryRecord( + 'capabilities', + this.pathQuery(this.args.id, this.args.path) + ); + this.dontShowTab = checker(checkCapabilities(response)); + } + } +} diff --git a/ui/app/components/secret-list-header.js b/ui/app/components/secret-list-header.js index 12f3d2149..9b2495400 100644 --- a/ui/app/components/secret-list-header.js +++ b/ui/app/components/secret-list-header.js @@ -1,13 +1,11 @@ -import Component from '@ember/component'; - -export default Component.extend({ - tagName: '', +import Component from '@glimmer/component'; +export default class SecretListHeader extends Component { // api - isCertTab: false, - isConfigure: false, - baseKey: null, - backendCrumb: null, - model: null, - options: null, -}); + isCertTab = false; + isConfigure = false; + baseKey = null; + backendCrumb = null; + model = null; + options = null; +} diff --git a/ui/app/components/selectable-card.js b/ui/app/components/selectable-card.js index be61cd65c..aa94a9704 100644 --- a/ui/app/components/selectable-card.js +++ b/ui/app/components/selectable-card.js @@ -12,12 +12,16 @@ import { computed } from '@ember/object'; * @param cardTitle=null {String} - cardTitle displays the card title * @param total=0 {Number} - the Total number displays like a title, it's the largest text in the component * @param subText=null {String} - subText describes the total + * @param actionCard=false {Boolean} - false default selectable card container used in metrics, true a card that focus on actions as seen in database secret engine overview + * @param actionText=null {String} - that action that happens in an actionCard */ export default Component.extend({ cardTitle: '', total: 0, subText: '', + actionCard: false, + actionText: '', gridContainer: false, tagName: '', // do not wrap component with div formattedCardTitle: computed('total', function() { diff --git a/ui/app/helpers/add-to-array.js b/ui/app/helpers/add-to-array.js new file mode 100644 index 000000000..07968d275 --- /dev/null +++ b/ui/app/helpers/add-to-array.js @@ -0,0 +1,17 @@ +import { helper as buildHelper } from '@ember/component/helper'; +import { assert } from '@ember/debug'; + +function dedupe(items) { + return items.filter((v, i) => items.indexOf(v) === i); +} + +export function addToArray([array, string]) { + if (!Array.isArray(array)) { + assert(`Value provided is not an array`, false); + } + const newArray = [...array]; + newArray.push(string); + return dedupe(newArray); +} + +export default buildHelper(addToArray); diff --git a/ui/app/helpers/format-duration.js b/ui/app/helpers/format-duration.js new file mode 100644 index 000000000..515065bd1 --- /dev/null +++ b/ui/app/helpers/format-duration.js @@ -0,0 +1,16 @@ +import { helper } from '@ember/component/helper'; +import { assert } from '@ember/debug'; +import { formatDuration, intervalToDuration } from 'date-fns'; + +export function duration([time]) { + // intervalToDuration creates a durationObject that turns the seconds (ex 3600) to respective: + // { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 } + // then formatDuration returns the filled in keys of the durationObject + + // time must be in seconds + let duration = Number.parseInt(time, 10); + assert('could not parse time', !isNaN(duration)); + return formatDuration(intervalToDuration({ start: 0, end: duration * 1000 })); +} + +export default helper(duration); diff --git a/ui/app/helpers/is-empty-object.js b/ui/app/helpers/is-empty-object.js new file mode 100644 index 000000000..962f3722b --- /dev/null +++ b/ui/app/helpers/is-empty-object.js @@ -0,0 +1,5 @@ +import { helper } from '@ember/component/helper'; + +export default helper(function isEmptyObject([object] /*, hash*/) { + return Object.keys(object).length === 0; +}); diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index 9e8efa5a5..28c2a29ba 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -55,6 +55,34 @@ const SECRET_BACKENDS = { editComponent: 'role-ssh-edit', listItemPartial: 'partials/secret-list/ssh-role-item', }, + database: { + displayName: 'Database', + navigateTree: false, + listItemPartial: 'partials/secret-list/database-list-item', + hasOverview: true, + tabs: [ + { + name: 'connection', + label: 'Connections', + searchPlaceholder: 'Filter connections', + item: 'connection', + create: 'Create connection', + editComponent: 'database-connection', + checkCapabilitiesPath: 'config', + }, + { + name: 'role', + modelPrefix: 'role/', + label: 'Roles', + searchPlaceholder: 'Filter roles', + item: 'role', + create: 'Create role', + tab: 'role', + editComponent: 'database-role-edit', + checkCapabilitiesPath: 'roles', + }, + ], + }, transform: { displayName: 'Transformation', navigateTree: false, diff --git a/ui/app/helpers/remove-from-array.js b/ui/app/helpers/remove-from-array.js new file mode 100644 index 000000000..4e0fd77de --- /dev/null +++ b/ui/app/helpers/remove-from-array.js @@ -0,0 +1,20 @@ +import { helper as buildHelper } from '@ember/component/helper'; +import { assert } from '@ember/debug'; + +function dedupe(items) { + return items.filter((v, i) => items.indexOf(v) === i); +} + +export function removeFromArray([array, string]) { + if (!Array.isArray(array)) { + assert(`Value provided is not an array`, false); + } + const newArray = [...array]; + const idx = newArray.indexOf(string); + if (idx >= 0) { + newArray.splice(idx, 1); + } + return dedupe(newArray); +} + +export default buildHelper(removeFromArray); diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index 1cc6274e4..3adb3d32c 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -2,6 +2,7 @@ import { helper as buildHelper } from '@ember/component/helper'; const SUPPORTED_SECRET_BACKENDS = [ 'aws', + 'database', 'cubbyhole', 'generic', 'kv', diff --git a/ui/app/lib/attach-capabilities.js b/ui/app/lib/attach-capabilities.js index 4ca42e28b..aabd5574d 100644 --- a/ui/app/lib/attach-capabilities.js +++ b/ui/app/lib/attach-capabilities.js @@ -9,9 +9,9 @@ import { isArray } from '@ember/array'; * * @param modelClass = An Ember Data model class * @param capabilities - an Object whose keys will added to the model class as related 'capabilities' models - * and whose values should be functions that return the id of the related capabilites model + * and whose values should be functions that return the id of the related capabilities model * - * definition of capabilities be done shorthand with the apiPath tagged template funtion + * definition of capabilities be done shorthand with the apiPath tagged template function * * * @usage diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js new file mode 100644 index 000000000..976ea45c7 --- /dev/null +++ b/ui/app/models/database/connection.js @@ -0,0 +1,167 @@ +import Model, { attr } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +const AVAILABLE_PLUGIN_TYPES = [ + { + value: 'mongodb-database-plugin', + displayName: 'MongoDB', + fields: [ + { attr: 'name' }, + { attr: 'plugin_name' }, + { attr: 'password_policy' }, + { attr: 'username', group: 'pluginConfig' }, + { attr: 'password', group: 'pluginConfig' }, + { attr: 'connection_url', group: 'pluginConfig' }, + { attr: 'write_concern' }, + { attr: 'creation_statements' }, + ], + }, +]; + +export default Model.extend({ + backend: attr('string', { + readOnly: true, + }), + name: attr('string', { + label: 'Connection Name', + }), + plugin_name: attr('string', { + label: 'Database plugin', + possibleValues: AVAILABLE_PLUGIN_TYPES, + }), + verify_connection: attr('boolean', { + defaultValue: true, + }), + allowed_roles: attr('array', { + readOnly: true, + }), + + password_policy: attr('string', { + editType: 'optionalText', + subText: + 'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.', + }), + + hosts: attr('string', {}), + host: attr('string', {}), + url: attr('string', {}), + port: attr('string', {}), + // connection_details + username: attr('string', {}), + password: attr('string', { + editType: 'password', + }), + connection_url: attr('string', { + subText: + 'The connection string used to connect to the database. This allows for simple templating of username and password of the root user.', + }), + + write_concern: attr('string', { + subText: 'Optional. Must be in JSON. See our documentation for help.', + editType: 'json', + theme: 'hashi short', + defaultShown: 'Default', + // defaultValue: '# For example: { "wmode": "majority", "wtimeout": 5000 }', + }), + max_open_connections: attr('string', {}), + max_idle_connections: attr('string'), + max_connection_lifetime: attr('string'), + tls: attr('string', { + label: 'TLS Certificate Key', + subText: 'x509 certificate for connecting to the database.', + editType: 'file', + }), + tls_ca: attr('string', { + label: 'TLS CA', + subText: 'x509 CA file for validating the certificate presented by the MongoDB server.', + editType: 'file', + }), + root_rotation_statements: attr('string', { + subText: `The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.`, + editType: 'json', + theme: 'hashi short', + defaultShown: 'Default', + }), + + allowedFields: computed(function() { + return [ + // required + 'plugin_name', + 'name', + // fields + 'connection_url', // * MongoDB, HanaDB, MSSQL, MySQL/MariaDB, Oracle, PostgresQL, Redshift + 'verify_connection', // default true + 'password_policy', // default "" + + // plugin config + 'username', + 'password', + + 'hosts', + 'host', + 'url', + 'port', + 'write_concern', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'tls', + 'tls_ca', + ]; + }), + + // for both create and edit fields + mainFields: computed('plugin_name', function() { + return [ + 'plugin_name', + 'name', + 'connection_url', + 'verify_connection', + 'password_policy', + 'pluginConfig', + 'root_rotation_statements', + ]; + }), + + showAttrs: computed('plugin_name', function() { + const f = [ + 'name', + 'plugin_name', + 'connection_url', + 'write_concern', + 'verify_connection', + 'root_rotation_statements', + 'allowed_roles', + ]; + return expandAttributeMeta(this, f); + }), + + pluginFieldGroups: computed('plugin_name', function() { + let groups = [{ default: ['username', 'password', 'write_concern'] }]; + // TODO: Get plugin options based on plugin + groups.push({ + 'TLS options': ['tls', 'tls_ca'], + }); + return fieldToAttrs(this, groups); + }), + + fieldAttrs: computed('mainFields', function() { + // Main Field Attrs only + return expandAttributeMeta(this, this.mainFields); + }), + + /* CAPABILITIES */ + editConnectionPath: lazyCapabilities(apiPath`${'backend'}/config/${'id'}`, 'backend', 'id'), + canEdit: alias('editConnectionPath.canUpdate'), + canDelete: alias('editConnectionPath.canDelete'), + resetConnectionPath: lazyCapabilities(apiPath`${'backend'}/reset/${'id'}`, 'backend', 'id'), + canReset: computed.or('resetConnectionPath.canUpdate', 'resetConnectionPath.canCreate'), + rotateRootPath: lazyCapabilities(apiPath`${'backend'}/rotate-root/${'id'}`, 'backend', 'id'), + canRotateRoot: computed.or('rotateRootPath.canUpdate', 'rotateRootPath.canCreate'), + rolePath: lazyCapabilities(apiPath`${'backend'}/role/*`, 'backend'), + staticRolePath: lazyCapabilities(apiPath`${'backend'}/static-role/*`, 'backend'), + canAddRole: computed.or('rolePath.canCreate', 'staticRolePath.canCreate'), +}); diff --git a/ui/app/models/database/credential.js b/ui/app/models/database/credential.js new file mode 100644 index 000000000..114d36966 --- /dev/null +++ b/ui/app/models/database/credential.js @@ -0,0 +1,11 @@ +import Model, { attr } from '@ember-data/model'; + +export default Model.extend({ + username: attr('string'), + password: attr('string'), + leaseId: attr('string'), + leaseDuration: attr('string'), + lastVaultRotation: attr('string'), + rotationPeriod: attr('number'), + ttl: attr('number'), +}); diff --git a/ui/app/models/database/role.js b/ui/app/models/database/role.js new file mode 100644 index 000000000..70fa06fda --- /dev/null +++ b/ui/app/models/database/role.js @@ -0,0 +1,110 @@ +import Model, { attr } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +export default Model.extend({ + idPrefix: 'role/', + backend: attr('string', { readOnly: true }), + name: attr('string', { + label: 'Role name', + }), + database: attr('array', { + label: '', + editType: 'searchSelect', + fallbackComponent: 'string-list', + models: ['database/connection'], + selectLimit: 1, + onlyAllowExisting: true, + subLabel: 'Database name', + subText: 'The database for which credentials will be generated.', + }), + type: attr('string', { + label: 'Type of role', + possibleValues: ['static', 'dynamic'], + }), + ttl: attr({ + editType: 'ttl', + defaultValue: '1h', + label: 'Generated credentials’s Time-to-Live (TTL)', + subText: 'Vault will use the engine default of 1 hour', + defaultShown: 'Engine default', + }), + max_ttl: attr({ + editType: 'ttl', + defaultValue: '24h', + label: 'Generated credentials’s maximum Time-to-Live (Max TTL)', + subText: 'Vault will use the engine default of 24 hours', + defaultShown: 'Engine default', + }), + username: attr('string', { + subText: 'The database username that this Vault role corresponds to.', + }), + rotation_period: attr({ + editType: 'ttl', + defaultValue: '5s', + subText: + 'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.', + }), + creation_statements: attr('array', { + editType: 'stringArray', + defaultShown: 'Default', + }), + revocation_statements: attr('array', { + editType: 'stringArray', + defaultShown: 'Default', + }), + rotation_statements: attr('array', { + editType: 'stringArray', + defaultShown: 'Default', + }), + creation_statement: attr('string', { + editType: 'json', + theme: 'hashi short', + defaultShown: 'Default', + }), + rotation_statement: attr('string', { + editType: 'json', + theme: 'hashi short', + defaultShown: 'Default', + }), + + /* FIELD ATTRIBUTES */ + get fieldAttrs() { + let fields = ['database', 'name', 'type']; + return expandAttributeMeta(this, fields); + }, + + roleSettingAttrs: computed(function() { + // logic for which get displayed is on DatabaseRoleSettingForm + let allRoleSettingFields = [ + 'ttl', + 'max_ttl', + 'username', + 'rotation_period', + 'creation_statements', + 'creation_statement', // only for MongoDB (styling difference) + 'revocation_statements', + 'rotation_statements', + 'rotation_statement', // only for MongoDB (styling difference) + ]; + return expandAttributeMeta(this, allRoleSettingFields); + }), + + /* CAPABILITIES */ + // only used for secretPath + path: attr('string', { readOnly: true }), + + secretPath: lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id'), + canEditRole: alias('secretPath.canUpdate'), + canDelete: alias('secretPath.canDelete'), + dynamicPath: lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend'), + canCreateDynamic: alias('dynamicPath.canCreate'), + staticPath: lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend'), + canCreateStatic: alias('staticPath.canCreate'), + credentialPath: lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id'), + canGenerateCredentials: alias('credentialPath.canRead'), + databasePath: lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database'), + canUpdateDb: alias('databasePath.canUpdate'), +}); diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index e5e9cceb8..5d9147f8c 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -63,6 +63,22 @@ export default Model.extend({ if (type === 'kv' || type === 'generic') { defaultGroup.default.push('options.{version}'); } + if (type === 'database') { + // For the Database Secret Engine we want to highlight the defaultLeaseTtl and maxLeaseTtl, removing them from the options object + defaultGroup.default.push('config.{defaultLeaseTtl}', 'config.{maxLeaseTtl}'); + return [ + defaultGroup, + { + 'Method Options': [ + 'description', + 'config.listingVisibility', + 'local', + 'sealWrap', + 'config.{auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}', + ], + }, + ]; + } return [ defaultGroup, { diff --git a/ui/app/router.js b/ui/app/router.js index 650aa1237..cfa018b0c 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -118,6 +118,8 @@ Router.map(function() { // transit-specific routes this.route('actions-root', { path: '/actions/' }); this.route('actions', { path: '/actions/*secret' }); + // database specific route + this.route('overview'); }); }); this.route('policies', { path: '/policies/:type' }, function() { diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index 18388ee15..37bb71458 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -38,6 +38,9 @@ export default EditBase.extend({ if (modelType === 'transform') { modelType = transformModel(transition.to.queryParams); } + if (modelType === 'database/connection' && transition.to?.queryParams?.itemType === 'role') { + modelType = 'database/role'; + } if (modelType !== 'secret' && modelType !== 'secret-v2') { if (this.wizard.featureState === 'details' && this.wizard.componentState === 'transit') { this.wizard.transitionFeatureMachine('details', 'CONTINUE', 'transit'); diff --git a/ui/app/routes/vault/cluster/secrets/backend/credentials.js b/ui/app/routes/vault/cluster/secrets/backend/credentials.js index 1b8d4efaf..7ec251bbe 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/credentials.js +++ b/ui/app/routes/vault/cluster/secrets/backend/credentials.js @@ -2,11 +2,12 @@ import { resolve } from 'rsvp'; import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -const SUPPORTED_DYNAMIC_BACKENDS = ['ssh', 'aws', 'pki']; +const SUPPORTED_DYNAMIC_BACKENDS = ['database', 'ssh', 'aws', 'pki']; export default Route.extend({ templateName: 'vault/cluster/secrets/backend/credentials', pathHelp: service('path-help'), + store: service(), backendModel() { return this.modelFor('vault.cluster.secrets.backend'); @@ -26,6 +27,7 @@ export default Route.extend({ let backendModel = this.backendModel(); let backendPath = backendModel.get('id'); let backendType = backendModel.get('type'); + let roleType = params.roleType; if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendModel.get('type'))) { return this.transitionTo('vault.cluster.secrets.backend.list-root', backendPath); @@ -34,10 +36,19 @@ export default Route.extend({ backendPath, backendType, roleName: role, + roleType, }); }, resetController(controller) { controller.reset(); }, + + actions: { + willTransition() { + // we do not want to save any of the credential information in the store. + // once the user navigates away from this page, remove all credential info. + this.store.unloadAll('database/credential'); + }, + }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 7a4bf9ae7..640b8de8a 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -73,6 +73,7 @@ export default Route.extend({ let secretEngine = this.store.peekRecord('secret-engine', backend); let type = secretEngine.get('engineType'); let types = { + database: tab === 'role' ? 'database/role' : 'database/connection', transit: 'transit-key', ssh: 'role-ssh', transform: this.modelTypeForTransform(tab), @@ -86,15 +87,16 @@ export default Route.extend({ return types[type]; }, - model(params) { + async model(params) { const secret = this.secretParam() || ''; const backend = this.enginePathParam(); const backendModel = this.modelFor('vault.cluster.secrets.backend'); + const modelType = this.getModelType(backend, params.tab); return hash({ secret, secrets: this.store - .lazyPaginatedQuery(this.getModelType(backend, params.tab), { + .lazyPaginatedQuery(modelType, { id: secret, backend, responsePath: 'data.keys', @@ -151,7 +153,6 @@ export default Route.extend({ if (secret !== controller.get('baseKey.id')) { this.store.clearAllDatasets(); } - controller.set('hasModel', true); controller.setProperties({ model, @@ -171,7 +172,7 @@ export default Route.extend({ } controller.setProperties({ filter: filter || '', - page: model.get('meta.currentPage') || 1, + page: model.meta?.currentPage || 1, }); } }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/overview.js b/ui/app/routes/vault/cluster/secrets/backend/overview.js new file mode 100644 index 000000000..e3cbc947f --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/overview.js @@ -0,0 +1,77 @@ +import Route from '@ember/routing/route'; +import { hash } from 'rsvp'; + +export default Route.extend({ + type: '', + enginePathParam() { + let { backend } = this.paramsFor('vault.cluster.secrets.backend'); + return backend; + }, + async fetchConnection(queryOptions) { + try { + return await this.store.query('database/connection', queryOptions); + } catch (e) { + return e.httpStatus; + } + }, + async fetchAllRoles(queryOptions) { + try { + return await this.store.query('database/role', queryOptions); + } catch (e) { + return e.httpStatus; + } + }, + pathQuery(backend, endpoint) { + return { + id: `${backend}/${endpoint}/`, + }; + }, + async fetchCapabilitiesRole(queryOptions) { + return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'roles')); + }, + async fetchCapabilitiesStaticRole(queryOptions) { + return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'static-roles')); + }, + async fetchCapabilitiesConnection(queryOptions) { + return this.store.queryRecord('capabilities', this.pathQuery(queryOptions.backend, 'config')); + }, + model() { + let backend = this.enginePathParam(); + let queryOptions = { backend, id: '' }; + + let connection = this.fetchConnection(queryOptions); + let role = this.fetchAllRoles(queryOptions); + let roleCapabilities = this.fetchCapabilitiesRole(queryOptions); + let staticRoleCapabilities = this.fetchCapabilitiesStaticRole(queryOptions); + let connectionCapabilities = this.fetchCapabilitiesConnection(queryOptions); + + return hash({ + backend, + connections: connection, + roles: role, + engineType: 'database', + id: backend, + roleCapabilities, + staticRoleCapabilities, + connectionCapabilities, + }); + }, + setupController(controller, model) { + this._super(...arguments); + let showEmptyState = model.connections === 404 && model.roles === 404; + let noConnectionCapabilities = + !model.connectionCapabilities.canList && + !model.connectionCapabilities.canCreate && + !model.connectionCapabilities.canUpdate; + + let emptyStateMessage = function() { + if (noConnectionCapabilities) { + return 'You cannot yet generate credentials. Ask your administrator if you think you should have access.'; + } else { + return 'You can connect and external database to Vault. We recommend that you create a user for Vault rather than using the database root user.'; + } + }; + controller.set('showEmptyState', showEmptyState); + controller.set('emptyStateMessage', emptyStateMessage()); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index edf7647ec..543c7c6ac 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -95,6 +95,7 @@ export default Route.extend(UnloadModelRoute, { let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); let type = backendModel.get('engineType'); let types = { + database: secret && secret.startsWith('role/') ? 'database/role' : 'database/connection', transit: 'transit-key', ssh: 'role-ssh', transform: this.modelTypeForTransform(secret), @@ -227,6 +228,9 @@ export default Route.extend(UnloadModelRoute, { if (modelType.startsWith('transform/')) { secret = this.transformSecretName(secret, modelType); } + if (modelType === 'database/role') { + secret = secret.replace('role/', ''); + } let secretModel; let capabilities = this.capabilities(secret, modelType); diff --git a/ui/app/serializers/database/connection.js b/ui/app/serializers/database/connection.js new file mode 100644 index 000000000..7112920b3 --- /dev/null +++ b/ui/app/serializers/database/connection.js @@ -0,0 +1,44 @@ +import RESTSerializer from '@ember-data/serializer/rest'; + +export default RESTSerializer.extend({ + primaryKey: 'name', + + serializeAttribute(snapshot, json, key, attributes) { + // Don't send values that are undefined + if ( + undefined !== snapshot.attr(key) && + (snapshot.record.get('isNew') || snapshot.changedAttributes()[key]) + ) { + this._super(snapshot, json, key, attributes); + } + }, + + normalizeSecrets(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const connections = payload.data.keys.map(secret => ({ name: secret, backend: payload.backend })); + return connections; + } + // Query single record response: + return { + id: payload.id, + name: payload.id, + backend: payload.backend, + ...payload.data, + ...payload.data.connection_details, + }; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const connections = nullResponses.includes(requestType) + ? { name: id, backend: payload.backend } + : this.normalizeSecrets(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: connections }; + if (requestType === 'queryRecord') { + // comes back as object anyway + transformedPayload = { [modelName]: { id, ...connections } }; + } + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/database/credential.js b/ui/app/serializers/database/credential.js new file mode 100644 index 000000000..da4b9003a --- /dev/null +++ b/ui/app/serializers/database/credential.js @@ -0,0 +1,29 @@ +import RESTSerializer from '@ember-data/serializer/rest'; + +export default RESTSerializer.extend({ + primaryKey: 'request_id', + + normalizePayload(payload) { + if (payload.data) { + const credentials = { + request_id: payload.request_id, + username: payload.data.username, + password: payload.data.password, + leaseId: payload.lease_id, + leaseDuration: payload.lease_duration, + lastVaultRotation: payload.data.last_vault_rotation, + rotationPeriod: payload.data.rotation_period, + ttl: payload.data.ttl, + }; + return credentials; + } + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const credentials = this.normalizePayload(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: credentials }; + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/database/role.js b/ui/app/serializers/database/role.js new file mode 100644 index 000000000..1cd87ab53 --- /dev/null +++ b/ui/app/serializers/database/role.js @@ -0,0 +1,70 @@ +import RESTSerializer from '@ember-data/serializer/rest'; + +export default RESTSerializer.extend({ + primaryKey: 'name', + + normalizeSecrets(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const roles = payload.data.keys.map(secret => { + let type = 'dynamic'; + let path = 'roles'; + if (payload.data.staticRoles.includes(secret)) { + type = 'static'; + path = 'static-roles'; + } + return { name: secret, backend: payload.backend, type, path }; + }); + return roles; + } + let path = 'roles'; + if (payload.data.type === 'static') { + path = 'static-roles'; + } + let database = []; + if (payload.data.db_name) { + database = [payload.data.db_name]; + } + return { + id: payload.secret, + name: payload.secret, + backend: payload.backend, + database, + path, + ...payload.data, + }; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const roles = nullResponses.includes(requestType) + ? { name: id, backend: payload.backend } + : this.normalizeSecrets(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: roles }; + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: roles }; + } + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + // Don't send values that are undefined + if ( + undefined !== snapshot.attr(key) && + (snapshot.record.get('isNew') || snapshot.changedAttributes()[key]) + ) { + this._super(snapshot, json, key, attributes); + } + }, + + serialize(snapshot, requestType) { + let data = this._super(snapshot, requestType); + if (data.database) { + const db = data.database[0]; + data.db_name = db; + delete data.database; + } + + return data; + }, +}); diff --git a/ui/app/styles/components/codemirror.scss b/ui/app/styles/components/codemirror.scss index 9230dc6b8..aaa6b232b 100644 --- a/ui/app/styles/components/codemirror.scss +++ b/ui/app/styles/components/codemirror.scss @@ -198,3 +198,7 @@ $gutter-grey: #2a2f36; .cm-s-auto-height.CodeMirror { height: auto; } + +.cm-s-short.CodeMirror { + height: 100px; +} diff --git a/ui/app/styles/components/empty-state.scss b/ui/app/styles/components/empty-state.scss index fb4d4b6e6..9f685794b 100644 --- a/ui/app/styles/components/empty-state.scss +++ b/ui/app/styles/components/empty-state.scss @@ -20,6 +20,16 @@ margin-bottom: $spacing-xs; } +.empty-state-subTitle { + font-size: $size-7; + margin-top: -10px; + padding-bottom: $spacing-s; +} + +.empty-state-message.has-border-bottom-light { + padding-bottom: $spacing-s; +} + .empty-state-actions { margin-top: $spacing-xs; diff --git a/ui/app/styles/components/replication-page.scss b/ui/app/styles/components/replication-page.scss index 8809a1f6d..8a4f13353 100644 --- a/ui/app/styles/components/replication-page.scss +++ b/ui/app/styles/components/replication-page.scss @@ -1,10 +1,5 @@ .replication-page { .empty-state { background: none; - - .empty-state-message { - padding-bottom: $spacing-s; - border-bottom: 1px solid $grey-light; - } } } diff --git a/ui/app/styles/components/selectable-card-container.scss b/ui/app/styles/components/selectable-card-container.scss index 8504a3dab..51c302d6e 100644 --- a/ui/app/styles/components/selectable-card-container.scss +++ b/ui/app/styles/components/selectable-card-container.scss @@ -3,6 +3,13 @@ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-rows: 1fr; grid-gap: 2rem; + + &.one-card { + max-width: 33%; + min-width: 350px; + margin-left: auto; + margin-right: auto; + } } .selectable-card-container.has-grid { diff --git a/ui/app/styles/components/selectable-card.scss b/ui/app/styles/components/selectable-card.scss index 3012f90f1..4b5ae9d31 100644 --- a/ui/app/styles/components/selectable-card.scss +++ b/ui/app/styles/components/selectable-card.scss @@ -5,6 +5,20 @@ padding: $spacing-l 0 $spacing-l $spacing-l; line-height: 0; + &.no-flex { + padding: $spacing-l; + display: initial; + line-height: initial; + + .title-number { + padding-top: $spacing-s; + } + + .search-label { + margin-bottom: -$spacing-xs; + } + } + &:hover { box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle; } @@ -13,6 +27,12 @@ text-decoration: none; } + .button { + &:disabled { + border-color: $vault-gray-300; + } + } + .card-details { grid-column-start: 2; grid-row-start: 3; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 106353c71..93956908b 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -218,6 +218,7 @@ label { // cannot use :read-only selector because tag used for other purposes &.is-readOnly { background-color: $ui-gray-100; + cursor: not-allowed; } } .field.has-addons { diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index e92d58205..74715cbf0 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -98,6 +98,12 @@ .has-short-padding { padding: 0.25rem 1.25rem; } +.has-tall-padding { + padding: 2.25rem; +} +.has-top-bottom-margin { + margin: 1.25rem 0rem; +} .is-sideless.has-short-padding { padding: 0.25rem 1.25rem; diff --git a/ui/app/styles/core/toggle.scss b/ui/app/styles/core/toggle.scss index 9b92d31e8..1dbe5a2bb 100644 --- a/ui/app/styles/core/toggle.scss +++ b/ui/app/styles/core/toggle.scss @@ -100,3 +100,7 @@ .toggle[type='checkbox'].is-success:checked + label::before { background: $blue; } + +.toggle-label { + width: 100%; +} diff --git a/ui/app/templates/components/database-connection.hbs b/ui/app/templates/components/database-connection.hbs new file mode 100644 index 000000000..d79cbbef9 --- /dev/null +++ b/ui/app/templates/components/database-connection.hbs @@ -0,0 +1,267 @@ + + + + + +

+ {{#if (eq @mode "create") }} + Create Connection + {{else if (eq @mode "edit")}} + Edit Connection + {{else}} + {{@model.id}} + {{/if}} +

+
+
+ +{{#if (eq @mode "show")}} + + + {{#if @model.canDelete}} + + {{/if}} + {{#if @model.canReset}} + + Reset connection + + {{/if}} + {{#if (and @model.canReset @model.canDelete)}} +
+ {{/if}} + {{#if @model.canRotate }} + + Rotate root credentials + + {{/if}} + {{#if @model.canAddRole}} + + Add role + + {{/if}} + {{#if @model.canEdit}} + + Edit configuration + + {{/if}} + + +{{/if}} + +{{#if (eq @mode 'create')}} +
+ {{#each @model.fieldAttrs as |attr|}} + {{#if (eq attr.name "pluginConfig")}} + {{!-- Plugin Config Section --}} +
+

Plugin config

+ {{#unless @model.plugin_name}} + + {{else}} + {{#each @model.pluginFieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (eq group "default")}} + {{#each fields as |attr|}} + {{!-- TODO: special password edit mode --}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} + {{else}} + + {{#if (get this (concat "show" (camelize group)))}} +
+ {{#each fields as |attr|}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} +
+ {{/if}} + {{/if}} + {{/each-in}} + {{/each}} + {{/unless}} +
+ {{else if (not-eq attr.options.readOnly true)}} + {{form-field data-test-field attr=attr model=@model}} + {{/if}} + {{/each}} + +
+
+
+ +
+
+ + Cancel + +
+
+
+
+{{else if (eq @mode 'edit')}} +
+ {{#each @model.fieldAttrs as |attr|}} + {{#if (eq attr.name "pluginConfig")}} +
+

Plugin config

+ {{#each @model.pluginFieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (eq group "default")}} + {{#each fields as |attr|}} + {{#if (eq attr.name "password")}} + +
+ + Update password
+
+ {{if this.showPasswordField 'The new password that will be used when connecting to the database' 'Vault will use the existing password'}} +
+ {{#if this.showPasswordField}} + + {{/if}} +
+
+ {{else}} + {{form-field data-test-field attr=attr model=@model}} + {{/if}} + {{/each}} + {{else}} + + {{#if (get this (concat "show" (camelize group)))}} +
+ {{#each fields as |attr|}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} +
+ {{/if}} + {{/if}} + {{/each-in}} + {{/each}} +
+ {{else if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}} + + {{else if (not-eq attr.options.readOnly true)}} + {{form-field data-test-field attr=attr model=@model}} + {{/if}} + {{/each}} + +
+
+
+ +
+
+ + Cancel + +
+
+
+ +{{else}} + {{#each @model.showAttrs as |attr|}} + {{#let attr.options.defaultDisplay as |defaultDisplay|}} + {{#if (eq attr.type "object")}} + + {{else}} + + {{/if}} + {{/let}} + {{/each}} +{{/if}} + + + +
+ + +
+
diff --git a/ui/app/templates/components/database-list-item.hbs b/ui/app/templates/components/database-list-item.hbs new file mode 100644 index 000000000..13dd33e9a --- /dev/null +++ b/ui/app/templates/components/database-list-item.hbs @@ -0,0 +1,72 @@ +{{#linked-block + "vault.cluster.secrets.backend.show" + (if keyTypeValue (concat 'role/' @item.id) @item.id) + class="list-item-row" + data-test-secret-link=item.id + encode=true + queryParams=(secret-query-params backendType) +}} +
+
+ + +
+ {{if (eq @item.id ' ') '(self)' (or @item.keyWithoutParent @item.id)}} + {{this.keyTypeValue}} +
+
+
+
+ + + +
+
+{{/linked-block}} diff --git a/ui/app/templates/components/database-role-edit.hbs b/ui/app/templates/components/database-role-edit.hbs new file mode 100644 index 000000000..93552f7a7 --- /dev/null +++ b/ui/app/templates/components/database-role-edit.hbs @@ -0,0 +1,140 @@ + + + + + +

+ {{#if (eq @mode "create") }} + Create Role + {{else if (eq @mode "edit")}} + Edit Role + {{else}} + {{@model.id}} + {{/if}} +

+
+
+ +{{#if (eq @mode 'show')}} + + + {{#if @model.canGenerateCredentials}} + + {{/if}} + {{#if @model.canDelete}} + + Delete role + + {{/if}} + {{#if @model.canEditRole}} + + Edit role + + {{/if}} + + + {{#each @model.fieldAttrs as |attr|}} + {{#let attr.options.defaultDisplay as |defaultDisplay|}} + {{#if (eq attr.type "object")}} + + {{else}} + + {{/if}} + {{/let}} + {{/each}} +{{else}} + {{!-- Edit or Create --}} +
+
+ {{#each @model.fieldAttrs as |attr|}} + {{#if (eq @mode 'edit')}} + + {{else if (not-eq attr.options.readOnly true)}} + {{form-field data-test-field attr=attr model=@model}} + {{!-- TODO: If database && !updateDB show warning --}} + {{#if (get this.warningMessages attr.name)}} + + {{/if}} + {{/if}} + {{/each}} + + + +
+
+
+ {{#if (is-empty-object this.warningMessages)}} + + {{else}} + + + + + +
+ You don't have permissions required to {{if (eq @mode 'create') "create" "update"}} this role. See form for details. +
+
+
+ {{/if}} +
+
+ + Cancel + +
+
+
+ +
+{{/if}} diff --git a/ui/app/templates/components/database-role-setting-form.hbs b/ui/app/templates/components/database-role-setting-form.hbs new file mode 100644 index 000000000..5aa0d7de5 --- /dev/null +++ b/ui/app/templates/components/database-role-setting-form.hbs @@ -0,0 +1,38 @@ +
+

Role Settings

+ {{#unless this.settingFields}} + + {{else}} +
+ {{#each this.settingFields as |attr|}} + {{#if (and (eq @mode 'edit') (eq attr.name 'username'))}} + + {{else}} + {{form-field data-test-field attr=attr model=@model}} + {{/if}} + + {{/each}} +
+ {{/unless}} +
+ +{{#unless (eq this.statementFields 'NONE')}} +
+

Statements

+ {{#unless this.statementFields}} + + {{else}} +
+ {{#each this.statementFields as |attr|}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} +
+ {{/unless}} +
+{{/unless}} diff --git a/ui/app/templates/components/generate-credentials-database.hbs b/ui/app/templates/components/generate-credentials-database.hbs new file mode 100644 index 000000000..c715e669b --- /dev/null +++ b/ui/app/templates/components/generate-credentials-database.hbs @@ -0,0 +1,102 @@ + + + + + +

+ {{@roleName}} +

+
+
+ +
+ {{!-- ROLE TYPE NOT FOUND, returned when query on the creds and static creds both returned error --}} + {{#if (eq this.roleType 'noRoleFound') }} + + + + {{/if}} + {{#unless (or model.errorMessage (eq this.roleType 'noRoleFound'))}} + + {{/unless}} + {{!-- DYNAMIC ROLE --}} + {{#if (and (eq this.roleType 'dynamic') model.username)}} + + + + + + + + + {{/if}} + {{!-- STATIC ROLE --}} + {{#if (and (eq this.roleType 'static') model.username)}} + + + + + + + + {{/if}} +
+
+ +
diff --git a/ui/app/templates/components/get-credentials-card.hbs b/ui/app/templates/components/get-credentials-card.hbs new file mode 100644 index 000000000..f45b22d4e --- /dev/null +++ b/ui/app/templates/components/get-credentials-card.hbs @@ -0,0 +1,27 @@ +
+
+

{{@title}}

+
+
+

{{@searchLabel}}

+
+ + +
diff --git a/ui/app/templates/components/input-search.hbs b/ui/app/templates/components/input-search.hbs new file mode 100644 index 000000000..c5942b5bf --- /dev/null +++ b/ui/app/templates/components/input-search.hbs @@ -0,0 +1,10 @@ +
+
+ +
+
diff --git a/ui/app/templates/components/secret-list-header-tab.hbs b/ui/app/templates/components/secret-list-header-tab.hbs new file mode 100644 index 000000000..84c5dd221 --- /dev/null +++ b/ui/app/templates/components/secret-list-header-tab.hbs @@ -0,0 +1,7 @@ +{{#unless this.dontShowTab}} + + + {{@label}} + + +{{/unless}} diff --git a/ui/app/templates/components/secret-list-header.hbs b/ui/app/templates/components/secret-list-header.hbs index e4c7df23d..ed8206c12 100644 --- a/ui/app/templates/components/secret-list-header.hbs +++ b/ui/app/templates/components/secret-list-header.hbs @@ -1,7 +1,7 @@ -{{#with (options-for-backend model.engineType) as |options|}} +{{#with (options-for-backend @model.engineType) as |options|}} - +
  • / @@ -14,9 +14,9 @@

    - - {{model.id}} - {{#if (eq model.options.version 2)}} + + {{@model.id}} + {{#if (eq @model.options.version 2)}} Version 2 @@ -28,19 +28,30 @@

  • + {{#if subTitle}} +

    + {{subTitle}} +

    + {{/if}} {{else}}

    {{title}}

    + {{#if subTitle}} +

    + {{subTitle}} +

    + {{/if}} {{/if}} {{#if message}} -

    +

    {{message}}

    {{/if}} diff --git a/ui/lib/core/addon/templates/components/form-field-groups.hbs b/ui/lib/core/addon/templates/components/form-field-groups.hbs index ee0329d9e..44fc0c5c6 100644 --- a/ui/lib/core/addon/templates/components/form-field-groups.hbs +++ b/ui/lib/core/addon/templates/components/form-field-groups.hbs @@ -4,8 +4,12 @@ {{#if (eq group "default")}} {{#each fields as |attr|}} {{#unless (and (not-eq mode "create") (eq attr.name "name"))}} - {{form-field data-test-field attr=attr model=model onChange=onChange - }} + {{/unless}} {{/each}} {{else}} @@ -20,7 +24,11 @@ {{#if (get this (concat "show" (camelize group)))}}
    {{#each fields as |attr|}} - {{form-field data-test-field attr=attr model=model}} + {{/each}}
    {{/if}} diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs index fd9c3fe0e..5bba74133 100644 --- a/ui/lib/core/addon/templates/components/form-field.hbs +++ b/ui/lib/core/addon/templates/components/form-field.hbs @@ -5,6 +5,7 @@ attr.options.editType (array "boolean" + "optionalText" "searchSelect" "mountAccessor" "kv" @@ -37,6 +38,11 @@ (action "setAndBroadcast" valuePath) value="target.value" }} data-test-input={{attr.name}}> + {{#unless attr.options.defaultValue}} + + {{/unless}} {{#each (path-or-array attr.options.possibleValues model) as |val|}}