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

View File

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

View File

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

View File

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

View File

@ -15,69 +15,71 @@
</p.levelLeft>
</PageHeader>
{{#if (eq @mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<button
type="button"
class="toolbar-link"
{{on 'click' this.delete}}
data-test-database-connection-delete
>
Delete connection
</button>
{{/if}}
{{#if @model.canReset}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action 'reset'}}
@confirmTitle="Reset connection?"
@confirmMessage="This will close the connection and its underlying plugin and restart it with the configuration stored in the barrier."
@confirmButtonText="Reset"
data-test-database-connection-reset
>
Reset connection
</ConfirmAction>
{{/if}}
{{#if (or @model.canReset @model.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if @model.canRotateRoot }}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{this.rotate}}
@confirmTitle="Rotate credentials?"
@confirmMessage={{'This will rotate the "root" user credentials stored for the database connection. The password will not be accessible once rotated.'}}
@confirmButtonText="Rotate"
data-test-database-connection-rotate
>
Rotate root credentials
</ConfirmAction>
{{/if}}
{{#if @model.canAddRole}}
<ToolbarSecretLink
@secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=@model.name itemType="role"}}
@data-test-secret-create=true
>
Add role
</ToolbarSecretLink>
{{/if}}
{{#if @model.canEdit}}
<ToolbarSecretLink
@secret={{@model.id}}
@mode="edit"
@data-test-edit-link=true
@replace=true
>
Edit configuration
</ToolbarSecretLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#if @model.isAvailablePlugin}}
{{#if (eq @mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<button
type="button"
class="toolbar-link"
{{on 'click' this.delete}}
data-test-database-connection-delete
>
Delete connection
</button>
{{/if}}
{{#if @model.canReset}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action 'reset'}}
@confirmTitle="Reset connection?"
@confirmMessage="This will close the connection and its underlying plugin and restart it with the configuration stored in the barrier."
@confirmButtonText="Reset"
data-test-database-connection-reset
>
Reset connection
</ConfirmAction>
{{/if}}
{{#if (or @model.canReset @model.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if @model.canRotateRoot }}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{this.rotate}}
@confirmTitle="Rotate credentials?"
@confirmMessage={{'This will rotate the "root" user credentials stored for the database connection. The password will not be accessible once rotated.'}}
@confirmButtonText="Rotate"
data-test-database-connection-rotate
>
Rotate root credentials
</ConfirmAction>
{{/if}}
{{#if @model.canAddRole}}
<ToolbarSecretLink
@secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=@model.name itemType="role"}}
@data-test-secret-create=true
>
Add role
</ToolbarSecretLink>
{{/if}}
{{#if @model.canEdit}}
<ToolbarSecretLink
@secret={{@model.id}}
@mode="edit"
@data-test-edit-link=true
@replace=true
>
Edit configuration
</ToolbarSecretLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{/if}}
{{/if}}
{{#if (eq @mode 'create')}}
@ -102,24 +104,24 @@
{{#each-in fieldGroup as |group fields|}}
{{#if (eq group "default")}}
<div class="columns is-desktop is-multiline">
{{#each fields as |attr|}}
{{#if (contains
attr.name
(array
"max_open_connections"
"max_idle_connections"
"max_connection_lifetime"
)
)}}
<div class="column is-one-third">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{else}}
<div class="column is-full">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{/if}}
{{/each}}
{{#each fields as |attr|}}
{{#if (contains
attr.name
(array
"max_open_connections"
"max_idle_connections"
"max_connection_lifetime"
)
)}}
<div class="column is-one-third">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{else}}
<div class="column is-full">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{/if}}
{{/each}}
</div>
{{else}}
<ToggleButton @class="is-block" @toggleAttr={{concat "show" (camelize group)}} @toggleTarget={{this}} @openLabel={{concat "Hide " group}} @closedLabel={{group}} @data-test-toggle-group={{group}} />
@ -174,7 +176,7 @@
</div>
</div>
</form>
{{else if (eq @mode 'edit')}}
{{else if (and (eq @mode 'edit') @model.isAvailablePlugin)}}
<form {{on 'submit' this.handleUpdateConnection}}>
{{#each @model.fieldAttrs as |attr|}}
{{#if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}}
@ -290,6 +292,19 @@
</div>
</div>
</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}}
{{#each @model.showAttrs as |attr|}}
{{#let attr.options.defaultDisplay as |defaultDisplay|}}

View File

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

View File

@ -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'],

View File

@ -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=""]'),