diff --git a/changelog/29820.txt b/changelog/29820.txt new file mode 100644 index 0000000000..0c9edb810d --- /dev/null +++ b/changelog/29820.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui/database: Adding input field for setting skip static role password rotation for database connection config, updating static role skip field to use toggle button +``` \ 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 aeb243e195..6429c5c2db 100644 --- a/ui/app/components/database-role-edit.js +++ b/ui/app/components/database-role-edit.js @@ -46,7 +46,7 @@ export default class DatabaseRoleEdit extends Component { isValid() { const { isValid, state } = this.args.model.validate(); this.modelValidations = isValid ? null : state; - this.invalidFormAlert = 'There was an error submitting this form.'; + this.invalidFormAlert = isValid ? '' : 'There was an error submitting this form.'; return isValid; } @@ -67,7 +67,8 @@ export default class DatabaseRoleEdit extends Component { } return warnings; } - get databaseType() { + + get databaseParams() { const backend = this.args.model?.backend; const dbs = this.args.model?.database || []; if (!backend || dbs.length === 0) { @@ -75,7 +76,10 @@ export default class DatabaseRoleEdit extends Component { } return this.store .queryRecord('database/connection', { id: dbs[0], backend }) - .then((record) => record.plugin_name) + .then(({ plugin_name, skip_static_role_rotation_import }) => ({ + plugin_name, + skip_static_role_rotation_import, + })) .catch(() => null); } @@ -110,6 +114,7 @@ export default class DatabaseRoleEdit extends Component { this.resetErrors(); const { mode, model } = this.args; if (!this.isValid()) return; + if (mode === 'create') { model.id = model.name; const path = model.type === 'static' ? 'static-roles' : 'roles'; diff --git a/ui/app/components/database-role-setting-form.js b/ui/app/components/database-role-setting-form.js index 4bad5e0921..eed2091233 100644 --- a/ui/app/components/database-role-setting-form.js +++ b/ui/app/components/database-role-setting-form.js @@ -18,25 +18,41 @@ import { getStatementFields, getRoleFields } from '../utils/model-helpers/databa * @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' + * @param {object} dbParams - holds database config values, { plugin_name: string [eg 'mongodb-database-plugin'], skip_static_role_rotation_import: boolean } */ export default class DatabaseRoleSettingForm extends Component { + get dbConfig() { + return this.args.dbParams; + } + get settingFields() { + const dbValues = this.args.dbParams; if (!this.args.roleType) return null; const dbValidFields = getRoleFields(this.args.roleType); return this.args.attrs.filter((a) => { + // Sets default value for skip_import_rotation based on parent db config value + if (a.name === 'skip_import_rotation' && this.args.mode === 'create') { + a.options.defaultValue = dbValues?.skip_static_role_rotation_import; + } return dbValidFields.includes(a.name); }); } get statementFields() { const type = this.args.roleType; - const plugin = this.args.dbType; if (!type) return null; - const dbValidFields = getStatementFields(type, plugin); + const dbValidFields = getStatementFields(type, this.dbConfig ? this.dbConfig.plugin_name : null); return this.args.attrs.filter((a) => { return dbValidFields.includes(a.name); }); } + + get isOverridden() { + if (this.args.mode !== 'create' || !this.dbConfig) return null; + + const dbSkip = this.dbConfig.skip_static_role_rotation_import; + const staticVal = this.args.model.get('skip_import_rotation'); + return this.args.mode === 'create' && dbSkip !== staticVal; + } } diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js index eebcd96162..39c418a7ef 100644 --- a/ui/app/models/database/connection.js +++ b/ui/app/models/database/connection.js @@ -200,6 +200,14 @@ export default Model.extend({ }), // ENTERPRISE ONLY + skip_static_role_rotation_import: attr({ + editType: 'toggleButton', + label: 'Skip initial rotation on static roles', + helperTextDisabled: 'Vault automatically rotates static roles upon their initial creation.', + helperTextEnabled: 'Vault will not automatically rotate static role passwords upon creation.', + defaultValue: false, + }), + self_managed: attr('boolean', { subText: 'Allows onboarding static roles with a rootless connection configuration. Mutually exclusive with username and password. If true, will force verify_connection to be false.', diff --git a/ui/app/models/database/role.js b/ui/app/models/database/role.js index 7dccc41edf..f3447ccf01 100644 --- a/ui/app/models/database/role.js +++ b/ui/app/models/database/role.js @@ -8,6 +8,7 @@ import { getRoleFields } from 'vault/utils/model-helpers/database-helpers'; import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { withModelValidations } from 'vault/decorators/model-validations'; const validations = { + name: [{ type: 'presence', message: 'Role name is required.' }], database: [{ type: 'presence', message: 'Database is required.' }], type: [{ type: 'presence', message: 'Type is required.' }], username: [ @@ -69,9 +70,10 @@ export default class RoleModel extends Model { rotation_period; @attr({ label: 'Skip initial rotation', - editType: 'boolean', - defaultValue: false, - subText: 'When unchecked, Vault automatically rotates the password upon creation.', + editType: 'toggleButton', + defaultValue: false, // this defaultValue will be set in database-role-setting-form.js based on parent database value + helperTextDisabled: 'Vault will rotate password for this static role on creation.', + helperTextEnabled: "Vault will not rotate this role's password on creation.", }) skip_import_rotation; @attr('array', { diff --git a/ui/app/templates/components/database-role-edit.hbs b/ui/app/templates/components/database-role-edit.hbs index 50a9a07774..c4d0cd3d06 100644 --- a/ui/app/templates/components/database-role-edit.hbs +++ b/ui/app/templates/components/database-role-edit.hbs @@ -106,7 +106,23 @@ {{#if (eq @mode "edit")}} {{else if (not-eq attr.options.readOnly true)}} - + {{#if (eq attr.name "type")}} + {{#if @model.database}} + + {{/if}} + {{else}} + + {{/if}} {{! TODO: If database && !updateDB show warning }} {{#if (get this.warningMessages attr.name)}} @@ -116,14 +132,21 @@ {{/if}} {{/if}} {{/each}} - + {{#if @model.database}} + + {{else}} + + {{/if}}
{{#each this.settingFields as |attr|}} - {{#if (and (eq @mode "edit") (eq attr.name "username"))}} + {{#if (and (eq @mode "edit") (includes attr.name (array "skip_import_rotation" "username")))}} {{else}} + {{#if (and (eq attr.name "skip_import_rotation") this.isOverridden)}} + + Warning + This will override the connection default for this role. + + {{/if}} {{/if}} {{/each}} diff --git a/ui/app/utils/model-helpers/database-helpers.js b/ui/app/utils/model-helpers/database-helpers.js index 085fc8626b..59697afcfe 100644 --- a/ui/app/utils/model-helpers/database-helpers.js +++ b/ui/app/utils/model-helpers/database-helpers.js @@ -22,6 +22,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'tls_server_name', group: 'pluginConfig' }, { attr: 'insecure', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, ], }, { @@ -37,6 +38,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'password', group: 'pluginConfig', show: false }, { attr: 'write_concern', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'root_rotation_statements', group: 'statements' }, @@ -57,6 +59,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_open_connections', group: 'pluginConfig' }, { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'root_rotation_statements', group: 'statements' }, ], }, @@ -75,6 +78,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'root_rotation_statements', group: 'statements' }, @@ -95,6 +99,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'root_rotation_statements', group: 'statements' }, @@ -115,6 +120,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'root_rotation_statements', group: 'statements' }, @@ -135,6 +141,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'root_rotation_statements', group: 'statements' }, @@ -155,6 +162,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'root_rotation_statements', group: 'statements' }, ], }, @@ -180,6 +188,7 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'disable_escaping', group: 'pluginConfig' }, { attr: 'root_rotation_statements', group: 'statements' }, { attr: 'self_managed', group: 'pluginConfig', isEnterprise: true }, + { attr: 'skip_static_role_rotation_import', group: 'pluginConfig', isEnterprise: true }, { attr: 'private_key', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' }, { attr: 'tls_certificate', group: 'pluginConfig', subgroup: 'TLS options' }, diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index cc765a0871..7a9849bec1 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -187,17 +187,37 @@ @value={{or (get @model this.valuePath) @attr.options.defaultValue}} @onChange={{this.onChangeWithEvent}} /> + {{else if (eq @attr.options.editType "toggleButton")}} + {{! Togglable Input }} + + {{this.labelString}}
+
+ + {{#if this.toggleInputEnabled}} + {{@attr.options.helperTextEnabled}} + {{else}} + {{@attr.options.helperTextDisabled}} + {{/if}} + +
+
{{else if (eq @attr.options.editType "optionalText")}} {{! Togglable Text Input }} {{this.labelString}}
- {{#if this.showInput}} + {{#if this.showToggleTextInput}} {{@attr.options.subText}} {{#if @attr.options.docLink}} @@ -219,7 +239,7 @@ {{/if}}
- {{#if this.showInput}} + {{#if this.showToggleTextInput}} { assert.ok(true, 'rotate root called'); new Response(204); @@ -119,12 +120,16 @@ module('Acceptance | database workflow', function (hooks) { ); assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows'); this.expectedRows.forEach(({ label, value }) => { + const valueSelector = + label === 'Skip initial rotation on static roles' + ? PAGE.infoRowValueDiv(label) + : PAGE.infoRowValue(label); assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`); - assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`); + assert.dom(valueSelector).hasText(value, `Value for ${label} is correct`); }); }); test('create without rotate', async function (assert) { - assert.expect(23); + assert.expect(25); this.server.post('/:backend/rotate-root/:name', () => { assert.notOk(true, 'rotate root called when it should not have been'); new Response(204); @@ -152,12 +157,16 @@ module('Acceptance | database workflow', function (hooks) { ); assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows'); this.expectedRows.forEach(({ label, value }) => { + const valueSelector = + label === 'Skip initial rotation on static roles' + ? PAGE.infoRowValueDiv(label) + : PAGE.infoRowValue(label); assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`); - assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`); + assert.dom(valueSelector).hasText(value, `Value for ${label} is correct`); }); }); test('create failure', async function (assert) { - assert.expect(25); + assert.expect(27); this.server.post('/:backend/rotate-root/:name', (schema, req) => { const okay = req.params.name !== 'bad-connection'; assert.ok(okay, 'rotate root called but not for bad-connection'); @@ -192,8 +201,12 @@ module('Acceptance | database workflow', function (hooks) { ); assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows'); this.expectedRows.forEach(({ label, value }) => { + const valueSelector = + label === 'Skip initial rotation on static roles' + ? PAGE.infoRowValueDiv(label) + : PAGE.infoRowValue(label); assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`); - assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`); + assert.dom(valueSelector).hasText(value, `Value for ${label} is correct`); }); }); diff --git a/ui/tests/integration/components/database-role-edit-test.js b/ui/tests/integration/components/database-role-edit-test.js index 1fbbb089f2..62f46ef84a 100644 --- a/ui/tests/integration/components/database-role-edit-test.js +++ b/ui/tests/integration/components/database-role-edit-test.js @@ -94,7 +94,8 @@ module('Integration | Component | database-role-edit', function (hooks) { await render(hbs``); await fillIn('[data-test-ttl-value="Rotation period"]', '2'); - await click('[data-test-input="skip_import_rotation"]'); + await click('[data-test-toggle-input="toggle-skip_import_rotation"]'); + await click('[data-test-secret-save]'); await render(hbs``); 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 0c64346c4b..5a2dc77dcb 100644 --- a/ui/tests/integration/components/database-role-setting-form-test.js +++ b/ui/tests/integration/components/database-role-setting-form-test.js @@ -11,59 +11,45 @@ import hbs from 'htmlbars-inline-precompile'; import { setRunOptions } from 'ember-a11y-testing/test-support'; const testCases = [ - { - // default case should show all possible fields for each type - pluginType: '', - staticRoleFields: ['name', 'username', 'rotation_period', 'rotation_statements'], - dynamicRoleFields: [ - 'name', - 'default_ttl', - 'max_ttl', - 'creation_statements', - 'revocation_statements', - 'rollback_statements', - 'renew_statements', - ], - }, { pluginType: 'elasticsearch-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statement', 'default_ttl', 'max_ttl'], }, { pluginType: 'mongodb-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statement', 'revocation_statement', 'default_ttl', 'max_ttl'], statementsHidden: true, }, { pluginType: 'mssql-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, { pluginType: 'mysql-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, { pluginType: 'mysql-aurora-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, { pluginType: 'mysql-rds-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, { pluginType: 'mysql-legacy-database-plugin', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, { pluginType: 'vault-plugin-database-oracle', - staticRoleFields: ['username', 'rotation_period'], + staticRoleFields: ['username', 'rotation_period', 'skip_import_rotation'], dynamicRoleFields: ['creation_statements', 'revocation_statements', 'default_ttl', 'max_ttl'], }, ]; @@ -73,6 +59,7 @@ const ALL_ATTRS = [ { name: 'default_ttl', type: 'string', options: {} }, { name: 'max_ttl', type: 'string', options: {} }, { name: 'username', type: 'string', options: {} }, + { name: 'skip_import_rotation', type: 'boolean', options: {} }, { name: 'rotation_period', type: 'string', options: {} }, { name: 'creation_statements', type: 'string', options: {} }, { name: 'creation_statement', type: 'string', options: {} }, @@ -114,20 +101,19 @@ module('Integration | Component | database-role-setting-form', function (hooks) test('it shows appropriate fields based on roleType and db plugin', async function (assert) { this.set('roleType', 'static'); - this.set('dbType', ''); + this.set('dbParams', { plugin_name: '', skip_static_role_rotation_import: false }); await render(hbs` `); - assert.dom('[data-test-component="empty-state"]').doesNotExist('Does not show empty states'); for (const testCase of testCases) { const staticFields = getFields(testCase.staticRoleFields); const dynamicFields = getFields(testCase.dynamicRoleFields); - this.set('dbType', testCase.pluginType); + this.set('dbParams', { plugin_name: testCase.pluginType, skip_static_role_rotation_import: false }); this.set('roleType', 'static'); staticFields.show.forEach((attr) => { assert diff --git a/ui/tests/integration/components/form-field-test.js b/ui/tests/integration/components/form-field-test.js index 03fbe516fe..ded69f0ee0 100644 --- a/ui/tests/integration/components/form-field-test.js +++ b/ui/tests/integration/components/form-field-test.js @@ -106,6 +106,26 @@ module('Integration | Component | form field', function (hooks) { assert.ok(spy.calledWith('foo', 'hello'), 'onChange called with correct args'); }); + test('it renders: toggleButton', async function (assert) { + const [model, spy] = await setup.call( + this, + createAttr('foobar', 'toggleButton', { + defaultValue: false, + editType: 'toggleButton', + helperTextEnabled: 'Toggled on', + helperTextDisabled: 'Toggled off', + }) + ); + assert.ok(component.hasToggleButton, 'renders a toggle button'); + assert.dom('[data-test-toggle-input]').isNotChecked(); + assert.dom('[data-test-toggle-subtext]').hasText('Toggled off'); + + await component.fields.objectAt(0).toggleButton(); + + assert.true(model.get('foobar')); + assert.ok(spy.calledWith('foobar', true), 'onChange called with correct args'); + }); + test('it renders: editType file', async function (assert) { const subText = 'My subtext.'; await setup.call(this, createAttr('foo', 'string', { editType: 'file', subText, docLink: '/docs' })); diff --git a/ui/tests/pages/components/form-field.js b/ui/tests/pages/components/form-field.js index fb5f05fb29..94a26ac7d1 100644 --- a/ui/tests/pages/components/form-field.js +++ b/ui/tests/pages/components/form-field.js @@ -19,6 +19,7 @@ export default { hasStringList: isPresent('[data-test-component=string-list]'), hasTextFile: isPresent('[data-test-component=text-file]'), hasTTLPicker: isPresent('[data-test-toggle-input="Foo"]'), + hasToggleButton: isPresent('[data-test-toggle-input="toggle-foobar"]'), hasJSONEditor: isPresent('[data-test-component="code-mirror-modifier"]'), hasJSONClearButton: isPresent('[data-test-json-clear-button]'), hasInput: isPresent('input'), @@ -36,6 +37,7 @@ export default { fields: collection('[data-test-field]', { clickLabel: clickable('label'), toggleTtl: clickable('[data-test-toggle-input="Foo"]'), + toggleButton: clickable('[data-test-toggle-input="toggle-foobar"]'), labelValue: text('[data-test-form-field-label]'), input: fillable('input'), ttlTime: fillable('[data-test-ttl-value]'), diff --git a/ui/tests/unit/serializers/database/connection-test.js b/ui/tests/unit/serializers/database/connection-test.js index 90445ea1e0..bf48250c59 100644 --- a/ui/tests/unit/serializers/database/connection-test.js +++ b/ui/tests/unit/serializers/database/connection-test.js @@ -26,6 +26,7 @@ module('Unit | Serializer | database/connection', function (hooks) { url: 'http://localhost:9200', username: 'elastic', password: 'changeme', + skip_static_role_rotation_import: false, tls_ca: 'some-value', ca_cert: undefined, // does not send undefined values }); @@ -38,6 +39,7 @@ module('Unit | Serializer | database/connection', function (hooks) { url: 'http://localhost:9200', username: 'elastic', password: 'changeme', + skip_static_role_rotation_import: false, insecure: false, };