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
This commit is contained in:
claire bontempo 2021-10-07 14:00:42 -07:00 committed by GitHub
parent 1b54217094
commit 4b709e8b3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 254 additions and 142 deletions

3
changelog/12672.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
**Elasticsearch in the UI**: Elasticsearch DB is now supported by the UI
```

View File

@ -14,38 +14,12 @@
*/ */
import Component from '@glimmer/component'; 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 { export default class DatabaseRoleSettingForm extends Component {
get settingFields() { get settingFields() {
if (!this.args.roleType) return null; 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 this.args.attrs.filter(a => {
return dbValidFields.includes(a.name); return dbValidFields.includes(a.name);
}); });
@ -55,10 +29,7 @@ export default class DatabaseRoleSettingForm extends Component {
const type = this.args.roleType; const type = this.args.roleType;
const plugin = this.args.dbType; const plugin = this.args.dbType;
if (!type) return null; if (!type) return null;
let dbValidFields = STATEMENT_FIELDS[type].default; let dbValidFields = getStatementFields(type, plugin);
if (STATEMENT_FIELDS[type][plugin]) {
dbValidFields = STATEMENT_FIELDS[type][plugin];
}
return this.args.attrs.filter(a => { return this.args.attrs.filter(a => {
return dbValidFields.includes(a.name); return dbValidFields.includes(a.name);
}); });

View File

@ -121,6 +121,26 @@ const AVAILABLE_PLUGIN_TYPES = [
{ attr: 'root_rotation_statements', group: 'statements' }, { 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 // required
name: attr('string', { name: attr('string', {
label: 'Connection Name', label: 'Connection name',
}), }),
plugin_name: attr('string', { plugin_name: attr('string', {
label: 'Database plugin', label: 'Database plugin',
@ -177,22 +197,38 @@ export default Model.extend({
// common fields // common fields
connection_url: attr('string', { 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', { url: attr('string', {
subText: label: 'URL',
'The connection string used to connect to the database. This allows for simple templating of username and password of the root user.', subText: `The URL for Elasticsearch's API ("https://localhost:9200").`,
}), }),
username: attr('string', { 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', { password: attr('string', {
subText: subText: 'The password to use when connecting with the above username.',
'Optional. The password to use when connecting to the database. Typically used in the connection_url field via the templating directive {{password}}.',
editType: 'password', editType: 'password',
}), }),
// optional // 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', {}), hosts: attr('string', {}),
host: attr('string', {}), host: attr('string', {}),
port: attr('string', {}), port: attr('string', {}),
@ -220,6 +256,10 @@ export default Model.extend({
max_connection_lifetime: attr('string', { max_connection_lifetime: attr('string', {
defaultValue: '0s', defaultValue: '0s',
}), }),
insecure: attr('boolean', {
defaultValue: false,
label: 'Disable SSL verification',
}),
tls: attr('string', { tls: attr('string', {
label: 'TLS Certificate Key', label: 'TLS Certificate Key',
helpText: 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.', 'x509 CA file for validating the certificate presented by the MongoDB server. Must be PEM encoded.',
editType: 'file', 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({ 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.`, 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', editType: 'stringArray',
defaultShown: 'Default', defaultShown: 'Default',
}), }),
isAvailablePlugin: computed('plugin_name', function() {
return !!AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name);
}),
showAttrs: computed('plugin_name', function() { showAttrs: computed('plugin_name', function() {
const fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name) const fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name)
.fields.filter(f => f.show !== false) .fields.filter(f => f.show !== false)

View File

@ -3,6 +3,7 @@ import { computed } from '@ember/object';
import { alias } from '@ember/object/computed'; import { alias } from '@ember/object/computed';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { getRoleFields } from '../../utils/database-role-fields';
export default Model.extend({ export default Model.extend({
idPrefix: 'role/', idPrefix: 'role/',
@ -90,11 +91,7 @@ export default Model.extend({
get showFields() { get showFields() {
let fields = ['name', 'database', 'type']; let fields = ['name', 'database', 'type'];
if (this.type === 'dynamic') { fields = fields.concat(getRoleFields(this.type)).concat(['creation_statements', 'revocation_statements']);
fields = fields.concat(['ttl', 'max_ttl', 'creation_statements', 'revocation_statements']);
} else {
fields = fields.concat(['username', 'rotation_period']);
}
return expandAttributeMeta(this, fields); return expandAttributeMeta(this, fields);
}, },
@ -106,9 +103,9 @@ export default Model.extend({
'username', 'username',
'rotation_period', 'rotation_period',
'creation_statements', 'creation_statements',
'creation_statement', // only for MongoDB (styling difference) 'creation_statement', // for editType: JSON
'revocation_statements', 'revocation_statements',
'revocation_statement', // only for MongoDB (styling difference) 'revocation_statement', // only for MongoDB (editType: JSON)
'rotation_statements', 'rotation_statements',
'rollback_statements', 'rollback_statements',
'renew_statements', 'renew_statements',

View File

@ -42,6 +42,8 @@
.empty-state-actions { .empty-state-actions {
margin-top: $spacing-xs; margin-top: $spacing-xs;
display: flex;
justify-content: space-between;
a, a,
.link, .link,
@ -54,6 +56,7 @@
> * + * { > * + * {
margin-left: $spacing-s; margin-left: $spacing-s;
margin-right: $spacing-s;
} }
} }

View File

@ -15,7 +15,8 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
{{#if (eq @mode "show")}} {{#if @model.isAvailablePlugin}}
{{#if (eq @mode "show")}}
<Toolbar> <Toolbar>
<ToolbarActions> <ToolbarActions>
{{#if @model.canDelete}} {{#if @model.canDelete}}
@ -78,6 +79,7 @@
{{/if}} {{/if}}
</ToolbarActions> </ToolbarActions>
</Toolbar> </Toolbar>
{{/if}}
{{/if}} {{/if}}
{{#if (eq @mode 'create')}} {{#if (eq @mode 'create')}}
@ -174,7 +176,7 @@
</div> </div>
</div> </div>
</form> </form>
{{else if (eq @mode 'edit')}} {{else if (and (eq @mode 'edit') @model.isAvailablePlugin)}}
<form {{on 'submit' this.handleUpdateConnection}}> <form {{on 'submit' this.handleUpdateConnection}}>
{{#each @model.fieldAttrs as |attr|}} {{#each @model.fieldAttrs as |attr|}}
{{#if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}} {{#if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}}
@ -290,6 +292,19 @@
</div> </div>
</div> </div>
</form> </form>
{{else if (eq @model.isAvailablePlugin false)}}
<EmptyState
@title="Database type unavailable"
@subTitle="Not supported in the UI"
@icon="disabled"
@message="This database type cannot be viewed in the UI. You will have to use the API or CLI to perform actions here."
@bottomBorder={{true}}
>
<LinkTo @route="vault.cluster.secrets.backend.list-root" class="link">
<Chevron @direction="left"/> Go back
</LinkTo>
<a href="https://www.vaultproject.io/api/secret/databases" target="_blank" rel="noreferrer noopener">Documentation</a>
</EmptyState>
{{else}} {{else}}
{{#each @model.showAttrs as |attr|}} {{#each @model.showAttrs as |attr|}}
{{#let attr.options.defaultDisplay as |defaultDisplay|}} {{#let attr.options.defaultDisplay as |defaultDisplay|}}

View File

@ -1,11 +1,11 @@
{{#linked-block <LinkedBlock
"vault.cluster.secrets.backend.show" @params={{array "vault.cluster.secrets.backend.show"
(if this.keyTypeValue (concat 'role/' @item.id) @item.id) (if this.keyTypeValue (concat 'role/' @item.id) @item.id)}}
class="list-item-row" class="list-item-row"
data-test-secret-link=@item.id data-test-secret-link={{@item.id}}
encode=true encode={{true}}
queryParams=(secret-query-params @backendType @item.type) queryParams={{secret-query-params @backendType @item.type}}
}} >
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-10"> <div class="column is-10">
<LinkTo <LinkTo
@ -80,4 +80,4 @@
</PopupMenu> </PopupMenu>
</div> </div>
</div> </div>
{{/linked-block}} </LinkedBlock>

View File

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

View File

@ -1,4 +1,4 @@
<div class="test" ...attributes> <div ...attributes>
{{#if @disabled }} {{#if @disabled }}
<div class="list-item-row linkable-item is-no-underline"> <div class="list-item-row linkable-item is-no-underline">
{{yield (hash content=(component 'linkable-item/content'))}} {{yield (hash content=(component 'linkable-item/content'))}}

View File

@ -34,7 +34,7 @@ const newConnection = async (backend, plugin = 'mongodb-database-plugin') => {
await connectionPage.visitCreate({ backend }); await connectionPage.visitCreate({ backend });
await connectionPage.dbPlugin(plugin); await connectionPage.dbPlugin(plugin);
await connectionPage.name(name); 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.toggleVerify();
await connectionPage.save(); await connectionPage.save();
await connectionPage.enable(); await connectionPage.enable();
@ -42,6 +42,26 @@ const newConnection = async (backend, plugin = 'mongodb-database-plugin') => {
}; };
const connectionTests = [ 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', name: 'mongodb-connection',
plugin: 'mongodb-database-plugin', plugin: 'mongodb-database-plugin',
@ -208,7 +228,13 @@ module('Acceptance | secrets/database/*', function(hooks) {
await connectionPage.dbPlugin(testCase.plugin); await connectionPage.dbPlugin(testCase.plugin);
assert.dom('[data-test-empty-state]').doesNotExist('Empty state goes away after plugin selected'); assert.dom('[data-test-empty-state]').doesNotExist('Empty state goes away after plugin selected');
await connectionPage.name(testCase.name); await connectionPage.name(testCase.name);
if (testCase.plugin === 'elasticsearch-database-plugin') {
await connectionPage.url(testCase.url); 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); testCase.requiredFields(assert, testCase.name);
await connectionPage.toggleVerify(); await connectionPage.toggleVerify();
await connectionPage.save(); await connectionPage.save();
@ -253,8 +279,8 @@ module('Acceptance | secrets/database/*', function(hooks) {
plugin: 'mongodb-database-plugin', plugin: 'mongodb-database-plugin',
id: 'horses-db', id: 'horses-db',
fields: [ fields: [
{ label: 'Connection Name', name: 'name', value: 'horses-db' }, { label: 'Connection name', name: 'name', value: 'horses-db' },
{ label: 'Connection url', name: 'connection_url', value: 'mongodb://127.0.0.1:235/horses' }, { label: 'Connection URL', name: 'connection_url', value: 'mongodb://127.0.0.1:235/horses' },
{ label: 'Username', name: 'username', value: 'user', hideOnShow: true }, { label: 'Username', name: 'username', value: 'user', hideOnShow: true },
{ label: 'Password', name: 'password', password: 'so-secure', hideOnShow: true }, { label: 'Password', name: 'password', password: 'so-secure', hideOnShow: true },
{ label: 'Write concern', name: 'write_concern' }, { label: 'Write concern', name: 'write_concern' },

View File

@ -19,6 +19,11 @@ const testCases = [
'renew_statements', 'renew_statements',
], ],
}, },
{
pluginType: 'elasticsearch-database-plugin',
staticRoleFields: ['username', 'rotation_period'],
dynamicRoleFields: ['creation_statement', 'ttl', 'max_ttl'],
},
{ {
pluginType: 'mongodb-database-plugin', pluginType: 'mongodb-database-plugin',
staticRoleFields: ['username', 'rotation_period'], staticRoleFields: ['username', 'rotation_period'],

View File

@ -10,7 +10,10 @@ export default create({
dbPlugin: selectable('[data-test-input="plugin_name"]'), dbPlugin: selectable('[data-test-input="plugin_name"]'),
name: fillable('[data-test-input="name"]'), name: fillable('[data-test-input="name"]'),
toggleVerify: clickable('[data-test-input="verify_connection"]'), 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=""]'), save: clickable('[data-test-secret-save=""]'),
addRole: clickable('[data-test-secret-create="true"]'), // only from connection show addRole: clickable('[data-test-secret-create="true"]'), // only from connection show
enable: clickable('[data-test-enable-connection=""]'), enable: clickable('[data-test-enable-connection=""]'),