From 4b709e8b3b12e1bb6f8506003cfd82955b608f17 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Thu, 7 Oct 2021 14:00:42 -0700 Subject: [PATCH] UI/Add Elasticsearch DB (#12672) * displays empty state if database is not supported in the UI * adds elasticsearch db plugin * adds changelog * updates elasticsearch attrs * move tls_server_name to pluginConfig group * move role setting fields to util * updates comments and refactors using util function * adds tests for elasticsearch * fixes indentation * when local host needs https * adds line at bottom of hbs file --- changelog/12672.txt | 3 + .../components/database-role-setting-form.js | 35 +--- ui/app/models/database/connection.js | 62 +++++- ui/app/models/database/role.js | 11 +- ui/app/styles/components/empty-state.scss | 3 + .../components/database-connection.hbs | 179 ++++++++++-------- .../secret-list/database-list-item.hbs | 16 +- ui/app/utils/database-role-fields.js | 41 ++++ .../templates/components/linkable-item.hbs | 2 +- .../secrets/backend/database/secret-test.js | 34 +++- .../database-role-setting-form-test.js | 5 + .../secrets/backend/database/connection.js | 5 +- 12 files changed, 254 insertions(+), 142 deletions(-) create mode 100644 changelog/12672.txt create mode 100644 ui/app/utils/database-role-fields.js diff --git a/changelog/12672.txt b/changelog/12672.txt new file mode 100644 index 000000000..62ac12bdc --- /dev/null +++ b/changelog/12672.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Elasticsearch in the UI**: Elasticsearch DB is now supported by the UI +``` \ No newline at end of file diff --git a/ui/app/components/database-role-setting-form.js b/ui/app/components/database-role-setting-form.js index cdea6b57c..e7d9f6b4d 100644 --- a/ui/app/components/database-role-setting-form.js +++ b/ui/app/components/database-role-setting-form.js @@ -14,38 +14,12 @@ */ import Component from '@glimmer/component'; +import { getStatementFields, getRoleFields } from '../utils/database-role-fields'; -// Below fields are intended to be dynamic based on type of role and db. -// example of usage: FIELDS[roleType][db] -const ROLE_FIELDS = { - static: ['username', 'rotation_period'], - dynamic: ['ttl', 'max_ttl'], -}; - -const STATEMENT_FIELDS = { - static: { - default: ['rotation_statements'], - 'mongodb-database-plugin': [], - 'mssql-database-plugin': [], - 'mysql-database-plugin': [], - 'mysql-aurora-database-plugin': [], - 'mysql-rds-database-plugin': [], - 'mysql-legacy-database-plugin': [], - }, - dynamic: { - default: ['creation_statements', 'revocation_statements', 'rollback_statements', 'renew_statements'], - 'mongodb-database-plugin': ['creation_statement', 'revocation_statement'], - 'mssql-database-plugin': ['creation_statements', 'revocation_statements'], - 'mysql-database-plugin': ['creation_statements', 'revocation_statements'], - 'mysql-aurora-database-plugin': ['creation_statements', 'revocation_statements'], - 'mysql-rds-database-plugin': ['creation_statements', 'revocation_statements'], - 'mysql-legacy-database-plugin': ['creation_statements', 'revocation_statements'], - }, -}; export default class DatabaseRoleSettingForm extends Component { get settingFields() { if (!this.args.roleType) return null; - let dbValidFields = ROLE_FIELDS[this.args.roleType]; + let dbValidFields = getRoleFields(this.args.roleType); return this.args.attrs.filter(a => { return dbValidFields.includes(a.name); }); @@ -55,10 +29,7 @@ export default class DatabaseRoleSettingForm extends Component { const type = this.args.roleType; const plugin = this.args.dbType; if (!type) return null; - let dbValidFields = STATEMENT_FIELDS[type].default; - if (STATEMENT_FIELDS[type][plugin]) { - dbValidFields = STATEMENT_FIELDS[type][plugin]; - } + let dbValidFields = getStatementFields(type, plugin); return this.args.attrs.filter(a => { return dbValidFields.includes(a.name); }); diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js index e82dc5904..86ba691e0 100644 --- a/ui/app/models/database/connection.js +++ b/ui/app/models/database/connection.js @@ -121,6 +121,26 @@ const AVAILABLE_PLUGIN_TYPES = [ { attr: 'root_rotation_statements', group: 'statements' }, ], }, + { + value: 'elasticsearch-database-plugin', + displayName: 'Elasticsearch', + fields: [ + { attr: 'plugin_name' }, + { attr: 'name' }, + { attr: 'verify_connection' }, + { attr: 'password_policy' }, + { attr: 'url', group: 'pluginConfig' }, + { attr: 'username', group: 'pluginConfig', show: false }, + { attr: 'password', group: 'pluginConfig', show: false }, + { attr: 'ca_cert', group: 'pluginConfig' }, + { attr: 'ca_path', group: 'pluginConfig' }, + { attr: 'client_cert', group: 'pluginConfig' }, + { attr: 'client_key', group: 'pluginConfig' }, + { attr: 'tls_server_name', group: 'pluginConfig' }, + { attr: 'insecure', group: 'pluginConfig' }, + { attr: 'username_template', group: 'pluginConfig' }, + ], + }, ]; /** @@ -149,7 +169,7 @@ export default Model.extend({ }), // required name: attr('string', { - label: 'Connection Name', + label: 'Connection name', }), plugin_name: attr('string', { label: 'Database plugin', @@ -177,22 +197,38 @@ export default Model.extend({ // common fields connection_url: attr('string', { - subText: 'The connection string used to connect to the database.', + label: 'Connection URL', + subText: + 'The connection string used to connect to the database. This allows for simple templating of username and password of the root user in the {{field_name}} format.', }), 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.', + label: 'URL', + subText: `The URL for Elasticsearch's API ("https://localhost:9200").`, }), username: attr('string', { - subText: 'Optional. The name of the user to use as the "root" user when connecting to the database.', + subText: `The name of the user to use as the "root" user when connecting to the database.`, }), password: attr('string', { - subText: - 'Optional. The password to use when connecting to the database. Typically used in the connection_url field via the templating directive {{password}}.', + subText: 'The password to use when connecting with the above username.', editType: 'password', }), // optional + ca_cert: attr('string', { + label: 'CA certificate', + subText: `The path to a PEM-encoded CA cert file to use to verify the Elasticsearch server's identity.`, + }), + ca_path: attr('string', { + label: 'CA path', + subText: `The path to a directory of PEM-encoded CA cert files to use to verify the Elasticsearch server's identity.`, + }), + client_cert: attr('string', { + label: 'Client certificate', + subText: 'The path to the certificate for the Elasticsearch client to present for communication.', + }), + client_key: attr('string', { + subText: 'The path to the key for the Elasticsearch client to use for communication.', + }), hosts: attr('string', {}), host: attr('string', {}), port: attr('string', {}), @@ -220,6 +256,10 @@ export default Model.extend({ max_connection_lifetime: attr('string', { defaultValue: '0s', }), + insecure: attr('boolean', { + defaultValue: false, + label: 'Disable SSL verification', + }), tls: attr('string', { label: 'TLS Certificate Key', helpText: @@ -232,12 +272,20 @@ export default Model.extend({ 'x509 CA file for validating the certificate presented by the MongoDB server. Must be PEM encoded.', editType: 'file', }), + tls_server_name: attr('string', { + label: 'TLS server name', + subText: 'If set, this name is used to set the SNI host when connecting via 1TLS.', + }), root_rotation_statements: attr({ 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: 'stringArray', defaultShown: 'Default', }), + isAvailablePlugin: computed('plugin_name', function() { + return !!AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name); + }), + showAttrs: computed('plugin_name', function() { const fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name) .fields.filter(f => f.show !== false) diff --git a/ui/app/models/database/role.js b/ui/app/models/database/role.js index 17e84cf4d..3ed255a06 100644 --- a/ui/app/models/database/role.js +++ b/ui/app/models/database/role.js @@ -3,6 +3,7 @@ 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'; +import { getRoleFields } from '../../utils/database-role-fields'; export default Model.extend({ idPrefix: 'role/', @@ -90,11 +91,7 @@ export default Model.extend({ get showFields() { let fields = ['name', 'database', 'type']; - if (this.type === 'dynamic') { - fields = fields.concat(['ttl', 'max_ttl', 'creation_statements', 'revocation_statements']); - } else { - fields = fields.concat(['username', 'rotation_period']); - } + fields = fields.concat(getRoleFields(this.type)).concat(['creation_statements', 'revocation_statements']); return expandAttributeMeta(this, fields); }, @@ -106,9 +103,9 @@ export default Model.extend({ 'username', 'rotation_period', 'creation_statements', - 'creation_statement', // only for MongoDB (styling difference) + 'creation_statement', // for editType: JSON 'revocation_statements', - 'revocation_statement', // only for MongoDB (styling difference) + 'revocation_statement', // only for MongoDB (editType: JSON) 'rotation_statements', 'rollback_statements', 'renew_statements', diff --git a/ui/app/styles/components/empty-state.scss b/ui/app/styles/components/empty-state.scss index 7a90a73ea..a6da6d405 100644 --- a/ui/app/styles/components/empty-state.scss +++ b/ui/app/styles/components/empty-state.scss @@ -42,6 +42,8 @@ .empty-state-actions { margin-top: $spacing-xs; + display: flex; + justify-content: space-between; a, .link, @@ -54,6 +56,7 @@ > * + * { margin-left: $spacing-s; + margin-right: $spacing-s; } } diff --git a/ui/app/templates/components/database-connection.hbs b/ui/app/templates/components/database-connection.hbs index 61a968316..c8231368e 100644 --- a/ui/app/templates/components/database-connection.hbs +++ b/ui/app/templates/components/database-connection.hbs @@ -15,69 +15,71 @@ -{{#if (eq @mode "show")}} - - - {{#if @model.canDelete}} - - {{/if}} - {{#if @model.canReset}} - - Reset connection - - {{/if}} - {{#if (or @model.canReset @model.canDelete)}} -
- {{/if}} - {{#if @model.canRotateRoot }} - - Rotate root credentials - - {{/if}} - {{#if @model.canAddRole}} - - Add role - - {{/if}} - {{#if @model.canEdit}} - - Edit configuration - - {{/if}} - - +{{#if @model.isAvailablePlugin}} + {{#if (eq @mode "show")}} + + + {{#if @model.canDelete}} + + {{/if}} + {{#if @model.canReset}} + + Reset connection + + {{/if}} + {{#if (or @model.canReset @model.canDelete)}} +
+ {{/if}} + {{#if @model.canRotateRoot }} + + Rotate root credentials + + {{/if}} + {{#if @model.canAddRole}} + + Add role + + {{/if}} + {{#if @model.canEdit}} + + Edit configuration + + {{/if}} + + + {{/if}} {{/if}} {{#if (eq @mode 'create')}} @@ -102,24 +104,24 @@ {{#each-in fieldGroup as |group fields|}} {{#if (eq group "default")}}
- {{#each fields as |attr|}} - {{#if (contains - attr.name - (array - "max_open_connections" - "max_idle_connections" - "max_connection_lifetime" - ) - )}} -
- {{form-field data-test-field attr=attr model=@model}} -
- {{else}} -
- {{form-field data-test-field attr=attr model=@model}} -
- {{/if}} - {{/each}} + {{#each fields as |attr|}} + {{#if (contains + attr.name + (array + "max_open_connections" + "max_idle_connections" + "max_connection_lifetime" + ) + )}} +
+ {{form-field data-test-field attr=attr model=@model}} +
+ {{else}} +
+ {{form-field data-test-field attr=attr model=@model}} +
+ {{/if}} + {{/each}}
{{else}} @@ -174,7 +176,7 @@
-{{else if (eq @mode 'edit')}} +{{else if (and (eq @mode 'edit') @model.isAvailablePlugin)}}
{{#each @model.fieldAttrs as |attr|}} {{#if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}} @@ -290,6 +292,19 @@
+{{else if (eq @model.isAvailablePlugin false)}} + + + Go back + + Documentation + {{else}} {{#each @model.showAttrs as |attr|}} {{#let attr.options.defaultDisplay as |defaultDisplay|}} diff --git a/ui/app/templates/components/secret-list/database-list-item.hbs b/ui/app/templates/components/secret-list/database-list-item.hbs index 1a8fc0804..3f1c3da9d 100644 --- a/ui/app/templates/components/secret-list/database-list-item.hbs +++ b/ui/app/templates/components/secret-list/database-list-item.hbs @@ -1,11 +1,11 @@ -{{#linked-block - "vault.cluster.secrets.backend.show" - (if this.keyTypeValue (concat 'role/' @item.id) @item.id) +
-{{/linked-block}} +
diff --git a/ui/app/utils/database-role-fields.js b/ui/app/utils/database-role-fields.js new file mode 100644 index 000000000..b420644bf --- /dev/null +++ b/ui/app/utils/database-role-fields.js @@ -0,0 +1,41 @@ +export const ROLE_FIELDS = { + static: ['username', 'rotation_period'], + dynamic: ['ttl', 'max_ttl'], +}; + +export const STATEMENT_FIELDS = { + static: { + default: ['rotation_statements'], + 'mongodb-database-plugin': [], + 'mssql-database-plugin': [], + 'mysql-database-plugin': [], + 'mysql-aurora-database-plugin': [], + 'mysql-rds-database-plugin': [], + 'mysql-legacy-database-plugin': [], + 'elasticsearch-database-plugin': [], + }, + dynamic: { + default: ['creation_statements', 'revocation_statements', 'rollback_statements', 'renew_statements'], + 'mongodb-database-plugin': ['creation_statement', 'revocation_statement'], + 'mssql-database-plugin': ['creation_statements', 'revocation_statements'], + 'mysql-database-plugin': ['creation_statements', 'revocation_statements'], + 'mysql-aurora-database-plugin': ['creation_statements', 'revocation_statements'], + 'mysql-rds-database-plugin': ['creation_statements', 'revocation_statements'], + 'mysql-legacy-database-plugin': ['creation_statements', 'revocation_statements'], + 'elasticsearch-database-plugin': ['creation_statement'], + }, +}; + +export function getStatementFields(type, plugin) { + if (!type) return null; + let dbValidFields = STATEMENT_FIELDS[type].default; + if (STATEMENT_FIELDS[type][plugin]) { + dbValidFields = STATEMENT_FIELDS[type][plugin]; + } + return dbValidFields; +} + +export function getRoleFields(type) { + if (!type) return null; + return ROLE_FIELDS[type]; +} diff --git a/ui/lib/core/addon/templates/components/linkable-item.hbs b/ui/lib/core/addon/templates/components/linkable-item.hbs index 873697073..00f15f7d9 100644 --- a/ui/lib/core/addon/templates/components/linkable-item.hbs +++ b/ui/lib/core/addon/templates/components/linkable-item.hbs @@ -1,4 +1,4 @@ -
+
{{#if @disabled }}
{{yield (hash content=(component 'linkable-item/content'))}} diff --git a/ui/tests/acceptance/secrets/backend/database/secret-test.js b/ui/tests/acceptance/secrets/backend/database/secret-test.js index 2123bb802..e1aca5bc1 100644 --- a/ui/tests/acceptance/secrets/backend/database/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/database/secret-test.js @@ -34,7 +34,7 @@ const newConnection = async (backend, plugin = 'mongodb-database-plugin') => { await connectionPage.visitCreate({ backend }); await connectionPage.dbPlugin(plugin); await connectionPage.name(name); - await connectionPage.url(`mongodb://127.0.0.1:4321/${name}`); + await connectionPage.connectionUrl(`mongodb://127.0.0.1:4321/${name}`); await connectionPage.toggleVerify(); await connectionPage.save(); await connectionPage.enable(); @@ -42,6 +42,26 @@ const newConnection = async (backend, plugin = 'mongodb-database-plugin') => { }; const connectionTests = [ + { + name: 'elasticsearch-connection', + plugin: 'elasticsearch-database-plugin', + elasticUser: 'username', + elasticPassword: 'password', + url: 'http://127.0.0.1:9200', + requiredFields: async (assert, name) => { + assert.dom('[data-test-input="username"]').exists(`Username field exists for ${name}`); + assert.dom('[data-test-input="password"]').exists(`Password field exists for ${name}`); + assert.dom('[data-test-input="ca_cert"]').exists(`CA certificate field exists for ${name}`); + assert.dom('[data-test-input="ca_path"]').exists(`CA path field exists for ${name}`); + assert.dom('[data-test-input="client_cert"]').exists(`Client certificate field exists for ${name}`); + assert.dom('[data-test-input="client_key"]').exists(`Client key field exists for ${name}`); + assert.dom('[data-test-input="tls_server_name"]').exists(`TLS server name field exists for ${name}`); + assert.dom('[data-test-input="insecure"]').exists(`Insecure checkbox exists for ${name}`); + assert + .dom('[data-test-toggle-input="show-username_template"]') + .exists(`Username template toggle exists for ${name}`); + }, + }, { name: 'mongodb-connection', plugin: 'mongodb-database-plugin', @@ -208,7 +228,13 @@ module('Acceptance | secrets/database/*', function(hooks) { await connectionPage.dbPlugin(testCase.plugin); assert.dom('[data-test-empty-state]').doesNotExist('Empty state goes away after plugin selected'); await connectionPage.name(testCase.name); - await connectionPage.url(testCase.url); + if (testCase.plugin === 'elasticsearch-database-plugin') { + await connectionPage.url(testCase.url); + await connectionPage.username(testCase.elasticUser); + await connectionPage.password(testCase.elasticPassword); + } else { + await connectionPage.connectionUrl(testCase.url); + } testCase.requiredFields(assert, testCase.name); await connectionPage.toggleVerify(); await connectionPage.save(); @@ -253,8 +279,8 @@ module('Acceptance | secrets/database/*', function(hooks) { plugin: 'mongodb-database-plugin', id: 'horses-db', fields: [ - { label: 'Connection Name', name: 'name', value: 'horses-db' }, - { label: 'Connection url', name: 'connection_url', value: 'mongodb://127.0.0.1:235/horses' }, + { label: 'Connection name', name: 'name', value: 'horses-db' }, + { label: 'Connection URL', name: 'connection_url', value: 'mongodb://127.0.0.1:235/horses' }, { label: 'Username', name: 'username', value: 'user', hideOnShow: true }, { label: 'Password', name: 'password', password: 'so-secure', hideOnShow: true }, { label: 'Write concern', name: 'write_concern' }, diff --git a/ui/tests/integration/components/database-role-setting-form-test.js b/ui/tests/integration/components/database-role-setting-form-test.js index c73411951..f0b153bde 100644 --- a/ui/tests/integration/components/database-role-setting-form-test.js +++ b/ui/tests/integration/components/database-role-setting-form-test.js @@ -19,6 +19,11 @@ const testCases = [ 'renew_statements', ], }, + { + pluginType: 'elasticsearch-database-plugin', + staticRoleFields: ['username', 'rotation_period'], + dynamicRoleFields: ['creation_statement', 'ttl', 'max_ttl'], + }, { pluginType: 'mongodb-database-plugin', staticRoleFields: ['username', 'rotation_period'], diff --git a/ui/tests/pages/secrets/backend/database/connection.js b/ui/tests/pages/secrets/backend/database/connection.js index 9324da5f1..75073f860 100644 --- a/ui/tests/pages/secrets/backend/database/connection.js +++ b/ui/tests/pages/secrets/backend/database/connection.js @@ -10,7 +10,10 @@ export default create({ dbPlugin: selectable('[data-test-input="plugin_name"]'), name: fillable('[data-test-input="name"]'), toggleVerify: clickable('[data-test-input="verify_connection"]'), - url: fillable('[data-test-input="connection_url"'), + connectionUrl: fillable('[data-test-input="connection_url"]'), + url: fillable('[data-test-input="url"]'), + username: fillable('[data-test-input="username"]'), + password: fillable('[data-test-input="password"]'), save: clickable('[data-test-secret-save=""]'), addRole: clickable('[data-test-secret-create="true"]'), // only from connection show enable: clickable('[data-test-enable-connection=""]'),