From 13e4d0f2300b33217aa8581a5289116be322afde Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:21:18 -0600 Subject: [PATCH] UI: Update PostgreSQL database params (#29200) * add testing before model changes * add enterprise self_managed attr, update tests * add postgres params * add changelog * update test * cleanup filter function * fix nits --- changelog/29200.txt | 3 + ui/app/models/database/connection.js | 88 ++++- .../utils/model-helpers/database-helpers.js | 9 + .../helpers/secret-engine/database-helpers.ts | 347 ++++++++++++++++++ .../unit/models/database-connection-test.js | 95 +++++ 5 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 changelog/29200.txt create mode 100644 ui/tests/helpers/secret-engine/database-helpers.ts create mode 100644 ui/tests/unit/models/database-connection-test.js diff --git a/changelog/29200.txt b/changelog/29200.txt new file mode 100644 index 0000000000..03177bea03 --- /dev/null +++ b/changelog/29200.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Adds params to postgresql database to improve editing a connection in the web browser. +``` diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js index e19585f2b8..1ca2038cde 100644 --- a/ui/app/models/database/connection.js +++ b/ui/app/models/database/connection.js @@ -9,6 +9,7 @@ import { alias, or } from '@ember/object/computed'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { AVAILABLE_PLUGIN_TYPES } from '../../utils/model-helpers/database-helpers'; +import { service } from '@ember/service'; /** * fieldsToGroups helper fn @@ -31,6 +32,8 @@ const fieldsToGroups = function (arr, key = 'subgroup') { }; export default Model.extend({ + version: service(), + backend: attr('string', { readOnly: true, }), @@ -77,6 +80,11 @@ export default Model.extend({ subText: 'The password to use when connecting with the above username.', editType: 'password', }), + disable_escaping: attr('boolean', { + defaultValue: false, + subText: 'Turns off the escaping of special characters inside of the username and password fields.', + docLink: 'https://developer.hashicorp.com/vault/docs/secrets/databases#disable-character-escaping', + }), // optional ca_cert: attr('string', { @@ -125,12 +133,56 @@ export default Model.extend({ label: 'Disable SSL verification', defaultValue: false, }), + password_authentication: attr('string', { + defaultValue: 'password', + editType: 'radio', + subText: 'The default is "password."', + possibleValues: [ + { + value: 'password', + helpText: + 'Passwords will be sent to PostgreSQL in plaintext format and may appear in PostgreSQL logs as-is.', + }, + { + value: 'scram-sha-256', + helpText: + 'When set to "scram-sha-256", passwords will be hashed by Vault and stored as-is by PostgreSQL. Using "scram-sha-256" requires a minimum version of PostgreSQL 10.', + }, + ], + docLink: + 'https://developer.hashicorp.com/vault/api-docs/secret/databases/postgresql#password_authentication', + }), + auth_type: attr('string', { + subText: 'If set to "gcp_iam", will enable IAM authentication to a Google CloudSQL instance.', + docLink: 'https://developer.hashicorp.com/vault/api-docs/secret/databases/postgresql#auth_type', + }), + service_account_json: attr('string', { + label: 'Service account JSON', + subText: + 'JSON encoded credentials for a GCP Service Account to use for IAM authentication. Requires "auth_type" to be "gcp_iam".', + editType: 'file', + }), + use_private_ip: attr('boolean', { + label: 'Use private IP', + subText: + 'Enables the option to connect to CloudSQL Instances with Private IP. Requires auth_type to be "gcp_iam".', + defaultValue: false, + }), + private_key: attr('string', { + helpText: 'The secret key used for the x509 client certificate. Must be PEM encoded.', + editType: 'file', + }), tls: attr('string', { label: 'TLS Certificate Key', helpText: 'x509 certificate for connecting to the database. This must be a PEM encoded version of the private key and the certificate combined.', editType: 'file', }), + tls_certificate: attr('string', { + label: 'TLS Certificate Key', + helpText: 'The x509 certificate for connecting to the database. Must be PEM encoded.', + editType: 'file', + }), tls_ca: attr('string', { label: 'TLS CA', helpText: @@ -147,25 +199,28 @@ export default Model.extend({ defaultShown: 'Default', }), + // ENTERPRISE ONLY + 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.', + defaultValue: false, + }), + 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) - .map((f) => f.attr); + const fields = this._filterFields((f) => f.show !== false).map((f) => f.attr); fields.push('allowed_roles'); return expandAttributeMeta(this, fields); }), + // for both create and edit 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); + fields = this._filterFields((f) => !f.group).map((f) => f.attr); } return expandAttributeMeta(this, fields); }), @@ -174,9 +229,7 @@ export default Model.extend({ if (!this.plugin_name) { return null; } - const pluginFields = AVAILABLE_PLUGIN_TYPES.find((a) => a.value === this.plugin_name).fields.filter( - (f) => f.group === 'pluginConfig' - ); + const pluginFields = this._filterFields((f) => f.group === 'pluginConfig'); const groups = fieldsToGroups(pluginFields, 'subgroup'); return fieldToAttrs(this, groups); }), @@ -185,12 +238,21 @@ export default Model.extend({ if (!this.plugin_name) { return expandAttributeMeta(this, ['root_rotation_statements']); } - const fields = AVAILABLE_PLUGIN_TYPES.find((a) => a.value === this.plugin_name) - .fields.filter((f) => f.group === 'statements') - .map((field) => field.attr); + const fields = this._filterFields((f) => f.group === 'statements').map((f) => f.attr); return expandAttributeMeta(this, fields); }), + // after checking for enterprise, filter callback fires and returns + _filterFields(filterCallback) { + const plugin = AVAILABLE_PLUGIN_TYPES.find((a) => a.value === this.plugin_name); + return plugin.fields.filter((field) => { + // return if attribute is enterprise only and we're on community + if (field?.isEnterprise && !this.version.isEnterprise) return false; + // filter by group, or if there isn't a group + return filterCallback(field); + }); + }, + /* CAPABILITIES */ editConnectionPath: lazyCapabilities(apiPath`${'backend'}/config/${'id'}`, 'backend', 'id'), canEdit: alias('editConnectionPath.canUpdate'), diff --git a/ui/app/utils/model-helpers/database-helpers.js b/ui/app/utils/model-helpers/database-helpers.js index 38e7f565f6..d669e36178 100644 --- a/ui/app/utils/model-helpers/database-helpers.js +++ b/ui/app/utils/model-helpers/database-helpers.js @@ -169,11 +169,20 @@ export const AVAILABLE_PLUGIN_TYPES = [ { attr: 'connection_url', group: 'pluginConfig' }, { attr: 'username', group: 'pluginConfig', show: false }, { attr: 'password', group: 'pluginConfig', show: false }, + { attr: 'password_authentication', group: 'pluginConfig' }, { attr: 'max_open_connections', group: 'pluginConfig' }, { attr: 'max_idle_connections', group: 'pluginConfig' }, { attr: 'max_connection_lifetime', group: 'pluginConfig' }, + { attr: 'auth_type', group: 'pluginConfig' }, + { attr: 'service_account_json', group: 'pluginConfig' }, + { attr: 'use_private_ip', group: 'pluginConfig' }, { attr: 'username_template', group: 'pluginConfig' }, + { attr: 'disable_escaping', group: 'pluginConfig' }, { attr: 'root_rotation_statements', group: 'statements' }, + { attr: 'self_managed', 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/tests/helpers/secret-engine/database-helpers.ts b/ui/tests/helpers/secret-engine/database-helpers.ts new file mode 100644 index 0000000000..fa868d8153 --- /dev/null +++ b/ui/tests/helpers/secret-engine/database-helpers.ts @@ -0,0 +1,347 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// If we refactor the database/connection model we can move this const to a util and use it in a form component. +// The ideal (future) scenario is the API provides this and we don't have to manually write the schema :) +export const EXPECTED_FIELDS = { + 'elasticsearch-database-plugin': { + default: [ + 'url', + 'username', + 'password', + 'ca_cert', + 'ca_path', + 'client_cert', + 'client_key', + 'tls_server_name', + 'insecure', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'url', + 'ca_cert', + 'ca_path', + 'client_cert', + 'client_key', + 'tls_server_name', + 'insecure', + 'username_template', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + pluginFieldGroups: undefined, + statementFields: [], + }, + 'mongodb-database-plugin': { + default: ['username', 'password', 'write_concern', 'username_template'], + showAttrs: [ + 'plugin_name', + 'name', + 'connection_url', + 'password_policy', + 'write_concern', + 'username_template', + 'tls', + 'tls_ca', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'connection_url', 'verify_connection', 'password_policy'], + pluginFieldGroups: ['username', 'password', 'write_concern', 'username_template'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + 'TLS options': ['tls', 'tls_ca'], + }, + 'mssql-database-plugin': { + default: [ + 'username', + 'password', + 'username_template', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'connection_url', + 'password_policy', + 'username_template', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'connection_url', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + }, + 'mysql-aurora-database-plugin': { + default: [ + 'connection_url', + 'username', + 'password', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + 'tls', + 'tls_ca', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + 'TLS options': ['tls', 'tls_ca'], + }, + 'mysql-legacy-database-plugin': { + default: [ + 'connection_url', + 'username', + 'password', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + 'tls', + 'tls_ca', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + 'TLS options': ['tls', 'tls_ca'], + }, + 'mysql-database-plugin': { + default: [ + 'connection_url', + 'username', + 'password', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + 'tls', + 'tls_ca', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + 'TLS options': ['tls', 'tls_ca'], + }, + 'mysql-rds-database-plugin': { + default: [ + 'connection_url', + 'username', + 'password', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + 'tls', + 'tls_ca', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + 'TLS options': ['tls', 'tls_ca'], + }, + 'vault-plugin-database-oracle': { + default: [ + 'connection_url', + 'username', + 'password', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'username_template', + 'root_rotation_statements', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + }, + 'postgresql-database-plugin': { + default: [ + 'connection_url', + 'username', + 'password', + 'password_authentication', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'auth_type', + 'service_account_json', + 'use_private_ip', + 'username_template', + 'disable_escaping', + ], + showAttrs: [ + 'plugin_name', + 'name', + 'password_policy', + 'connection_url', + 'password_authentication', + 'max_open_connections', + 'max_idle_connections', + 'max_connection_lifetime', + 'auth_type', + 'service_account_json', + 'use_private_ip', + 'username_template', + 'disable_escaping', + 'root_rotation_statements', + 'private_key', + 'tls_ca', + 'tls_certificate', + 'allowed_roles', + ], + fieldAttrs: ['plugin_name', 'name', 'verify_connection', 'password_policy'], + statementFields: [ + { + name: 'root_rotation_statements', + options: { + defaultShown: 'Default', + editType: 'stringArray', + subText: + "The database statements to be executed to rotate the root user's credentials. If nothing is entered, Vault will use a reasonable default.", + }, + type: undefined, + }, + ], + enterpriseOnly: 'self_managed', + }, +}; diff --git a/ui/tests/unit/models/database-connection-test.js b/ui/tests/unit/models/database-connection-test.js new file mode 100644 index 0000000000..dfa08cf2cf --- /dev/null +++ b/ui/tests/unit/models/database-connection-test.js @@ -0,0 +1,95 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { EXPECTED_FIELDS } from 'vault/tests/helpers/secret-engine/database-helpers'; + +import { AVAILABLE_PLUGIN_TYPES } from 'vault/utils/model-helpers/database-helpers'; + +module('Unit | Model | database/connection', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(async function () { + this.store = this.owner.lookup('service:store'); + this.version = this.owner.lookup('service:version'); + // setting version here so tests can be run locally on CE or ENT + this.version.type = 'community'; + + this.createModel = (plugin) => + this.store.createRecord('database/connection', { + plugin_name: plugin, + }); + }); + + for (const plugin of AVAILABLE_PLUGIN_TYPES.map((p) => p.value)) { + module(`it computes fields for plugin_type: ${plugin}`, function (hooks) { + hooks.beforeEach(function () { + this.model = this.createModel(plugin); + this.getActual = (group) => this.model[group]; + this.getExpected = (group) => EXPECTED_FIELDS[plugin][group]; + + const pluginFields = AVAILABLE_PLUGIN_TYPES.find((o) => o.value === plugin).fields; + this.pluginHasTlsOptions = pluginFields.some((f) => f.subgroup === 'TLS options'); + this.pluginHasEnterpriseAttrs = pluginFields.some((f) => f.isEnterprise); + }); + + test('it computes showAttrs', function (assert) { + const actual = this.getActual('showAttrs').map((a) => a.name); + const expected = this.getExpected('showAttrs'); + assert.propEqual(actual, expected, 'actual computed attrs match expected'); + }); + + test('it computes fieldAttrs', function (assert) { + const actual = this.getActual('fieldAttrs').map((a) => a.name); + const expected = this.getExpected('fieldAttrs'); + assert.propEqual(actual, expected, 'actual computed attrs match expected'); + }); + + test('it computes default group', function (assert) { + // pluginFieldGroups is an array of group objects + const [actualDefault] = this.getActual('pluginFieldGroups'); + + assert.propEqual( + actualDefault.default.map((a) => a.name), + this.getExpected('default'), + 'it has expected default group attributes' + ); + }); + + test('it computes statementFields', function (assert) { + const actual = this.getActual('statementFields'); + const expected = this.getExpected('statementFields'); + assert.propEqual(actual, expected, 'actual computed attrs match expected'); + }); + + if (this.pluginHasTlsOptions) { + test('it computes TLS options group', function (assert) { + // pluginFieldGroups is an array of group objects + const [, actualTlsOptions] = this.getActual('pluginFieldGroups'); + + assert.propEqual( + actualTlsOptions['TLS options'].map((a) => a.name), + this.getExpected('TLS options'), + 'it has expected TLS options' + ); + }); + } + + if (this.pluginHasEnterpriseAttrs) { + test('it includes enterprise fields', function (assert) { + this.version.type = 'enterprise'; + const [actualDefault] = this.getActual('pluginFieldGroups'); + const expected = this.getExpected('default').push(this.getExpected('enterpriseOnly')); + assert.propEqual( + actualDefault.default.map((a) => a.name), + expected, + 'it includes enterprise attributes' + ); + }); + } + }); + } +});