From a3c396991c0cb3cd13b5a91f16a6c956bf4f61c6 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Wed, 14 Apr 2021 16:07:07 -0500 Subject: [PATCH] UI/database mssql (#11231) Add MSSQL plugin support in database secrets engine --- changelog/11231.txt | 3 + ui/app/components/database-role-edit.js | 13 +- .../components/database-role-setting-form.js | 34 ++- ui/app/models/database/connection.js | 185 +++++++++------ ui/app/models/database/role.js | 13 +- ui/app/styles/core/forms.scss | 4 + .../components/database-connection.hbs | 211 ++++++++++++------ .../components/database-role-edit.hbs | 2 +- .../components/database-role-setting-form.hbs | 2 +- ui/lib/core/addon/components/form-field.js | 12 +- .../addon/templates/components/form-field.hbs | 14 +- .../components/readonly-form-field.hbs | 44 +--- .../templates/components/string-list.hbs | 2 +- ui/package.json | 1 + .../secrets/backend/database/secret-test.js | 153 +++++++------ .../database-role-setting-form-test.js | 148 ++++++------ .../secrets/backend/database/connection.js | 1 + ui/yarn.lock | 7 + 18 files changed, 498 insertions(+), 351 deletions(-) create mode 100644 changelog/11231.txt diff --git a/changelog/11231.txt b/changelog/11231.txt new file mode 100644 index 000000000..a51bb2d1c --- /dev/null +++ b/changelog/11231.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Add database secret engine support for MSSQL +``` \ No newline at end of file diff --git a/ui/app/components/database-role-edit.js b/ui/app/components/database-role-edit.js index b6820ea82..a8fa82d0f 100644 --- a/ui/app/components/database-role-edit.js +++ b/ui/app/components/database-role-edit.js @@ -10,6 +10,7 @@ export default class DatabaseRoleEdit extends Component { @service router; @service flashMessages; @service wizard; + @service store; constructor() { super(...arguments); @@ -41,11 +42,15 @@ export default class DatabaseRoleEdit extends Component { } get databaseType() { - if (this.args.model?.database) { - // TODO: Calculate this - return 'mongodb-database-plugin'; + const backend = this.args.model?.backend; + const dbs = this.args.model?.database || []; + if (!backend || dbs.length === 0) { + return null; } - return null; + return this.store + .queryRecord('database/connection', { id: dbs[0], backend }) + .then(record => record.plugin_name) + .catch(() => null); } @action diff --git a/ui/app/components/database-role-setting-form.js b/ui/app/components/database-role-setting-form.js index 44ba2f8f2..d90009feb 100644 --- a/ui/app/components/database-role-setting-form.js +++ b/ui/app/components/database-role-setting-form.js @@ -18,34 +18,26 @@ 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'], - }, + static: ['username', 'rotation_period'], + dynamic: ['ttl', 'max_ttl'], }; const STATEMENT_FIELDS = { static: { - default: ['creation_statements', 'revocation_statements', 'rotation_statements'], - 'mongodb-database-plugin': 'NONE', // will not show the section + default: ['rotation_statements'], + 'mongodb-database-plugin': [], + 'mssql-database-plugin': [], }, dynamic: { - default: ['creation_statements', 'revocation_statements', 'rotation_statements'], + default: ['creation_statements', 'revocation_statements', 'rollback_statements', 'renew_statements'], 'mongodb-database-plugin': ['creation_statement', 'revocation_statement'], + 'mssql-database-plugin': ['creation_statements', 'revocation_statements'], }, }; - export default class DatabaseRoleSettingForm extends Component { get settingFields() { - const type = this.args.roleType; - if (!type) return null; - const db = this.args.dbType || 'default'; - const dbValidFields = ROLE_FIELDS[type][db]; - if (!Array.isArray(dbValidFields)) return dbValidFields; + if (!this.args.roleType) return null; + let dbValidFields = ROLE_FIELDS[this.args.roleType]; return this.args.attrs.filter(a => { return dbValidFields.includes(a.name); }); @@ -53,10 +45,12 @@ export default class DatabaseRoleSettingForm extends Component { get statementFields() { const type = this.args.roleType; + const plugin = this.args.dbType; if (!type) return null; - const db = this.args.dbType || 'default'; - const dbValidFields = STATEMENT_FIELDS[type][db]; - if (!Array.isArray(dbValidFields)) return dbValidFields; + let dbValidFields = STATEMENT_FIELDS[type].default; + if (STATEMENT_FIELDS[type][plugin]) { + dbValidFields = STATEMENT_FIELDS[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 84995b51b..e4fd2fcff 100644 --- a/ui/app/models/database/connection.js +++ b/ui/app/models/database/connection.js @@ -9,22 +9,63 @@ const AVAILABLE_PLUGIN_TYPES = [ value: 'mongodb-database-plugin', displayName: 'MongoDB', fields: [ - { attr: 'name' }, { attr: 'plugin_name' }, + { attr: 'name' }, + { attr: 'connection_url' }, + { attr: 'verify_connection' }, { attr: 'password_policy' }, - { attr: 'username', group: 'pluginConfig' }, - { attr: 'password', group: 'pluginConfig' }, - { attr: 'connection_url', group: 'pluginConfig' }, - { attr: 'write_concern' }, - { attr: 'creation_statements' }, + { attr: 'username', group: 'pluginConfig', show: false }, + { attr: 'password', group: 'pluginConfig', show: false }, + { attr: 'write_concern', group: 'pluginConfig' }, + { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, + { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, + ], + }, + { + value: 'mssql-database-plugin', + displayName: 'MSSQL', + fields: [ + { attr: 'plugin_name' }, + { attr: 'name' }, + { attr: 'connection_url' }, + { attr: 'verify_connection' }, + { attr: 'password_policy' }, + { attr: 'username', group: 'pluginConfig', show: false }, + { attr: 'password', group: 'pluginConfig', show: false }, + { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'max_open_connections', group: 'pluginConfig' }, + { attr: 'max_idle_connections', group: 'pluginConfig' }, + { attr: 'max_connection_lifetime', group: 'pluginConfig' }, ], }, ]; +/** + * fieldsToGroups helper fn + * @param {array} arr any subset of "fields" from AVAILABLE_PLUGIN_TYPES + * @param {*} key item by which to group the fields. If item has no group it will be under "default" + * @returns array of objects where the key is default or the name of the option group, and the value is an array of attr names + */ +const fieldsToGroups = function(arr, key = 'subgroup') { + const fieldGroups = []; + const byGroup = arr.reduce(function(rv, x) { + (rv[x[key]] = rv[x[key]] || []).push(x); + return rv; + }, {}); + Object.keys(byGroup).forEach(key => { + const attrsArray = byGroup[key].map(obj => obj.attr); + const group = key === 'undefined' ? 'default' : key; + fieldGroups.push({ [group]: attrsArray }); + }); + return fieldGroups; +}; + export default Model.extend({ backend: attr('string', { readOnly: true, }), + // required name: attr('string', { label: 'Connection Name', }), @@ -33,33 +74,46 @@ export default Model.extend({ possibleValues: AVAILABLE_PLUGIN_TYPES, noDefault: true, }), + + // standard verify_connection: attr('boolean', { + label: 'Connection will be verified', defaultValue: true, }), allowed_roles: attr('array', { readOnly: true, }), - password_policy: attr('string', { + label: 'Use custom password policy', editType: 'optionalText', - subText: + subText: 'Specify the name of an existing password policy.', + defaultSubText: '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.', + defaultShown: 'Default', + docLink: 'https://www.vaultproject.io/docs/concepts/password-policies', }), - hosts: attr('string', {}), - host: attr('string', {}), - url: attr('string', {}), - port: attr('string', {}), - // connection_details - username: attr('string', {}), - password: attr('string', { - editType: 'password', - }), + // common fields connection_url: attr('string', { + subText: 'The connection string used to connect to the database.', + }), + 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.', }), + username: attr('string', { + subText: 'Optional. 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}}.', + editType: 'password', + }), + // optional + hosts: attr('string', {}), + host: attr('string', {}), + port: attr('string', {}), write_concern: attr('string', { subText: 'Optional. Must be in JSON. See our documentation for help.', editType: 'json', @@ -67,9 +121,23 @@ export default Model.extend({ defaultShown: 'Default', // defaultValue: '# For example: { "wmode": "majority", "wtimeout": 5000 }', }), - max_open_connections: attr('string', {}), - max_idle_connections: attr('string'), - max_connection_lifetime: attr('string'), + username_template: attr('string', { + editType: 'optionalText', + subText: 'Enter the custom username template to use.', + defaultSubText: + 'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.', + docLink: 'https://www.vaultproject.io/docs/concepts/username-templating', + defaultShown: 'Default', + }), + max_open_connections: attr('number', { + defaultValue: 4, + }), + max_idle_connections: attr('number', { + defaultValue: 0, + }), + max_connection_lifetime: attr('string', { + defaultValue: '0s', + }), tls: attr('string', { label: 'TLS Certificate Key', helpText: @@ -88,65 +156,44 @@ export default Model.extend({ 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']; - }), - showAttrs: computed('plugin_name', function() { - const f = [ - 'name', - 'plugin_name', - 'connection_url', - 'write_concern', - 'verify_connection', - 'allowed_roles', - ]; - return expandAttributeMeta(this, f); + const fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name) + .fields.filter(f => f.show !== false) + .map(f => f.attr); + fields.push('allowed_roles'); + return expandAttributeMeta(this, fields); + }), + + fieldAttrs: computed('plugin_name', function() { + // for both create and edit fields + let fields = ['plugin_name', 'name', 'connection_url', 'verify_connection', 'password_policy']; + if (this.plugin_name) { + fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name) + .fields.filter(f => !f.group) + .map(field => field.attr); + } + return expandAttributeMeta(this, fields); }), pluginFieldGroups: computed('plugin_name', function() { if (!this.plugin_name) { return null; } - let groups = [{ default: ['username', 'password', 'write_concern'] }]; - // TODO: Get plugin options based on plugin - groups.push({ - 'TLS options': ['tls', 'tls_ca'], - }); + let pluginFields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name).fields.filter( + f => f.group === 'pluginConfig' + ); + let groups = fieldsToGroups(pluginFields, 'subgroup'); return fieldToAttrs(this, groups); }), - fieldAttrs: computed('mainFields', function() { - // Main Field Attrs only - return expandAttributeMeta(this, this.mainFields); + statementFields: computed('plugin_name', function() { + if (!this.plugin_name) { + return expandAttributeMeta(this, ['root_rotation_statements']); + } + let fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name) + .fields.filter(f => f.group === 'statements') + .map(field => field.attr); + return expandAttributeMeta(this, fields); }), /* CAPABILITIES */ diff --git a/ui/app/models/database/role.js b/ui/app/models/database/role.js index 0ecac0fc1..06416ba98 100644 --- a/ui/app/models/database/role.js +++ b/ui/app/models/database/role.js @@ -46,11 +46,10 @@ export default Model.extend({ editType: 'ttl', defaultValue: '24h', subText: - 'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.', + 'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds. Default is 24 hours.', }), creation_statements: attr('array', { editType: 'stringArray', - defaultShown: 'Default', }), revocation_statements: attr('array', { editType: 'stringArray', @@ -60,6 +59,14 @@ export default Model.extend({ editType: 'stringArray', defaultShown: 'Default', }), + rollback_statements: attr('array', { + editType: 'stringArray', + defaultShown: 'Default', + }), + renew_statements: attr('array', { + editType: 'stringArray', + defaultShown: 'Default', + }), creation_statement: attr('string', { editType: 'json', theme: 'hashi short', @@ -100,6 +107,8 @@ export default Model.extend({ 'revocation_statements', 'revocation_statement', // only for MongoDB (styling difference) 'rotation_statements', + 'rollback_statements', + 'renew_statements', ]; return expandAttributeMeta(this, allRoleSettingFields); }), diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 93956908b..b31b44fe0 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -312,3 +312,7 @@ label { position: absolute; right: 8px; } + +fieldset.form-fieldset { + border: none; +} diff --git a/ui/app/templates/components/database-connection.hbs b/ui/app/templates/components/database-connection.hbs index b0b555cf5..638464252 100644 --- a/ui/app/templates/components/database-connection.hbs +++ b/ui/app/templates/components/database-connection.hbs @@ -40,20 +40,20 @@ Reset connection {{/if}} - {{#if (and @model.canReset @model.canDelete)}} + {{#if (or @model.canReset @model.canDelete)}}
{{/if}} - {{#if @model.canRotate }} - - Rotate root credentials - + {{#if @model.canRotateRoot }} + + Rotate root credentials + {{/if}} {{#if @model.canAddRole}} {{#each @model.fieldAttrs as |attr|}} - {{#if (eq attr.name "pluginConfig")}} - {{!-- Plugin Config Section --}} -
-

Plugin config

- {{#unless @model.pluginFieldGroups}} - - {{else}} - {{#each @model.pluginFieldGroups as |fieldGroup|}} - {{#each-in fieldGroup as |group fields|}} - {{#if (eq group "default")}} - {{#each fields as |attr|}} - {{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)}} + {{#if (not-eq attr.options.readOnly true)}} {{form-field data-test-field attr=attr model=@model}} {{/if}} {{/each}} + + {{!-- Plugin Config Section --}} +
+
+ Plugin config + {{#unless @model.pluginFieldGroups}} + + {{else}} + {{#each @model.pluginFieldGroups as |fieldGroup|}} + {{#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}} +
+ {{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}} +
+
+ + {{!-- Statements Section --}} + {{#unless (and @model.plugin_name (not @model.statementFields))}} +
+

Statements

+ {{#if (eq @model.statementFields null)}} + + {{else}} + {{#each @model.statementFields as |attr|}} + {{log attr}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} + {{/if}} +
+ {{/unless}}
@@ -141,14 +178,35 @@ {{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")}} + {{#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}} + + {{!-- Plugin Config Edit --}} +
+
+ Plugin config + {{#each @model.pluginFieldGroups as |fieldGroup|}} + {{#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 if (eq attr.name "password")}} +
@@ -178,29 +236,40 @@ {{/if}}
- {{else}} +
+ {{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}} + {{/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}} + +
+ + {{!-- Statements Edit Section --}} + {{#unless (and @model.plugin_name (not @model.statementFields))}} +
+
+ Statements + {{#each @model.statementFields as |attr|}} + {{form-field data-test-field attr=attr model=@model}} + {{/each}} +
+
+ {{/unless}}
diff --git a/ui/app/templates/components/database-role-edit.hbs b/ui/app/templates/components/database-role-edit.hbs index fd512439b..1f4b460f1 100644 --- a/ui/app/templates/components/database-role-edit.hbs +++ b/ui/app/templates/components/database-role-edit.hbs @@ -95,7 +95,7 @@ @roleType={{@model.type}} @model={{@model}} @mode={{@mode}} - @dbType={{this.databaseType}} + @dbType={{await this.databaseType}} />
diff --git a/ui/app/templates/components/database-role-setting-form.hbs b/ui/app/templates/components/database-role-setting-form.hbs index e3efb8641..0d85d8570 100644 --- a/ui/app/templates/components/database-role-setting-form.hbs +++ b/ui/app/templates/components/database-role-setting-form.hbs @@ -19,7 +19,7 @@ {{/unless}}
-{{#unless (eq this.statementFields 'NONE')}} +{{#unless (and @roleType (not this.statementFields))}}

Statements

{{#unless this.statementFields}} diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 1447b93c7..a334c8c18 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -33,6 +33,15 @@ export default Component.extend({ disabled: false, showHelpText: true, subText: '', + // This is only used internally for `optional-text` editType + showInput: false, + + init() { + this._super(...arguments); + const valuePath = this.attr.options?.fieldValue || this.attr.name; + const modelValue = this.model[valuePath]; + this.set('showInput', !!modelValue); + }, onChange() {}, @@ -84,9 +93,6 @@ export default Component.extend({ model: null, - // This is only used internally for `optional-text` editType - showInput: false, - /* * @private * @param object diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs index 362fce706..1813d8400 100644 --- a/ui/lib/core/addon/templates/components/form-field.hbs +++ b/ui/lib/core/addon/templates/components/form-field.hbs @@ -28,7 +28,7 @@ {{/if}} {{#if attr.options.subText}} -

{{attr.options.subText}}

+

{{attr.options.subText}} {{#if attr.options.docLink}}See our documentation for help.{{/if}}

{{/if}} {{/unless}} {{#if attr.options.possibleValues}} @@ -124,8 +124,8 @@ @@ -142,7 +142,11 @@ > {{labelString}}
- {{attr.options.subText}} + {{#if showInput}} + {{attr.options.subText}} {{#if attr.options.docLink}}See our documentation for help.{{/if}} + {{else}} + {{or attr.options.defaultSubText "Vault will use the engine default."}} {{#if attr.options.docLink}}See our documentation for help.{{/if}} + {{/if}}
{{#if showInput}} {{#if attr.options.subText}} -

{{attr.options.subText}}

+

{{attr.options.subText}} {{#if attr.options.docLink}}See our documentation for help.{{/if}}

{{/if}} - {{labelString}} - {{#if @attr.options.helpText}} - {{#info-tooltip}} - - {{@attr.options.helpText}} - - {{/info-tooltip}} - {{/if}} - - {{#if @attr.options.subText}} -

{{@attr.options.subText}}

+ +{{#if @attr.options.subText}} +

{{@attr.options.subText}}

+{{/if}} {{#if @attr.options.possibleValues}}
diff --git a/ui/lib/core/addon/templates/components/string-list.hbs b/ui/lib/core/addon/templates/components/string-list.hbs index c7e0287ba..536778e72 100644 --- a/ui/lib/core/addon/templates/components/string-list.hbs +++ b/ui/lib/core/addon/templates/components/string-list.hbs @@ -1,5 +1,5 @@ {{#if label}} -