UI: Glimmerize database/role.js and add model validations (#29754)

* glimmerize db role model

* adding validations

* updates to validators

* formatting fix

* changelog

* add validations to top fields

* updates

* added test for form validation

* updates from pr review

* remove added period

* remove extra line
This commit is contained in:
Dan Rivera 2025-03-03 18:04:20 -05:00 committed by GitHub
parent 0cec5066e6
commit 6b9467c568
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 221 additions and 139 deletions

3
changelog/29754.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui/database: Glimmerizing and adding validations to role create
```

View file

@ -2,19 +2,39 @@
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { waitFor } from '@ember/test-waiters';
import { task } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
/**
* @module DatabaseRoleEdit component is used to configure a database role.
* See secret-edit-layout which uses options for backend to determine which component to render.
* @example
* <DatabaseRoleEdit
@model={{this.model.database.role}}
@tab="edit"
@model="edit"
@initialKey=this.initialKey
/>
*
* @param {object} model - The database role model.
* @param {string} tab - The tab to render.
* @param {string} mode - The mode to render. Either 'create' or 'edit'.
* @param {string} [initialKey] - The initial key to set for the database role.
*/
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
export default class DatabaseRoleEdit extends Component {
@service router;
@service flashMessages;
@service store;
@tracked modelValidations;
@tracked invalidFormAlert;
@tracked errorMessage = '';
constructor() {
super(...arguments);
@ -23,19 +43,30 @@ export default class DatabaseRoleEdit extends Component {
}
}
@tracked loading = false;
isValid() {
const { isValid, state } = this.args.model.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = 'There was an error submitting this form.';
return isValid;
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = this.invalidFormAlert = '';
this.modelValidations = null;
}
get warningMessages() {
const warnings = {};
const { canCreateDynamic, canCreateStatic, type } = this.args.model;
if (
(this.args.model.type === 'dynamic' && this.args.model.canCreateDynamic === false) ||
(this.args.model.type === 'static' && this.args.model.canCreateStatic === false)
(type === 'dynamic' && canCreateDynamic === false) ||
(type === 'static' && canCreateStatic === false)
) {
warnings.type = `You don't have permissions to create this type of role.`;
}
return warnings;
}
get databaseType() {
const backend = this.args.model?.backend;
const dbs = this.args.model?.database || [];
@ -73,36 +104,29 @@ export default class DatabaseRoleEdit extends Component {
});
}
@action
handleCreateEditRole(evt) {
evt.preventDefault();
this.loading = true;
const mode = this.args.mode;
const roleSecret = this.args.model;
const secretId = roleSecret.name;
if (mode === 'create') {
roleSecret.set('id', secretId);
const path = roleSecret.type === 'static' ? 'static-roles' : 'roles';
roleSecret.set('path', path);
}
return roleSecret
.save()
.then(() => {
try {
this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`);
} catch (e) {
console.debug(e); // eslint-disable-line
}
})
.catch((e) => {
const errorMessage = e.errors?.join('. ') || e.message;
handleCreateEditRole = task(
waitFor(async (evt) => {
evt.preventDefault();
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';
model.path = path;
}
try {
await model.save();
this.router.transitionTo(SHOW_ROUTE, `role/${model.name}`);
} catch (e) {
this.errorMessage = errorMessage(e);
this.flashMessages.danger(
errorMessage || 'Could not save the role. Please check Vault logs for more information.'
this.errorMessage || 'Could not save the role. Please check Vault logs for more information.'
);
this.loading = false;
});
}
}
})
);
@action
rotateRoleCred(id) {
const backend = this.args.model?.backend;

View file

@ -2,21 +2,31 @@
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
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 'vault/utils/model-helpers/database-helpers';
export default Model.extend({
idPrefix: 'role/',
backend: attr('string', { readOnly: true }),
name: attr('string', {
label: 'Role name',
}),
database: attr('array', {
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
database: [{ type: 'presence', message: 'Database is required.' }],
type: [{ type: 'presence', message: 'Type is required.' }],
username: [
{
validator(model) {
const { type, username } = model;
if (!type || type === 'dynamic') return true;
if (username) return true;
},
message: 'Username is required.',
},
],
};
@withModelValidations(validations)
export default class RoleModel extends Model {
idPrefix = 'role/';
@attr('string', { readOnly: true }) backend;
@attr('string', { label: 'Role name' }) name;
@attr('array', {
label: 'Connection name',
editType: 'searchSelect',
fallbackComponent: 'string-list',
@ -24,81 +34,90 @@ export default Model.extend({
selectLimit: 1,
onlyAllowExisting: true,
subText: 'The database connection for which credentials will be generated.',
}),
type: attr('string', {
})
database;
@attr('string', {
label: 'Type of role',
noDefault: true,
possibleValues: ['static', 'dynamic'],
}),
default_ttl: attr({
})
type;
@attr({
editType: 'ttl',
defaultValue: '1h',
label: 'Generated credentialss Time-to-Live (TTL)',
helperTextDisabled: 'Vault will use a TTL of 1 hour.',
defaultShown: 'Engine default',
}),
max_ttl: attr({
})
default_ttl;
@attr({
editType: 'ttl',
defaultValue: '24h',
label: 'Generated credentialss maximum Time-to-Live (Max TTL)',
helperTextDisabled: 'Vault will use a TTL of 24 hours.',
defaultShown: 'Engine default',
}),
username: attr('string', {
subText: 'The database username that this Vault role corresponds to.',
}),
rotation_period: attr({
})
max_ttl;
@attr('string', { subText: 'The database username that this Vault role corresponds to.' }) username;
@attr({
editType: 'ttl',
defaultValue: '24h',
helperTextDisabled:
'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds. Default is 24 hours.',
helperTextEnabled: 'Vault will rotate password after',
}),
skip_import_rotation: attr({
helperTextEnabled: 'Vault will rotate password after.',
})
rotation_period;
@attr({
label: 'Skip initial rotation',
editType: 'boolean',
defaultValue: false,
subText: 'When unchecked, Vault automatically rotates the password upon creation',
}),
creation_statements: attr('array', {
subText: 'When unchecked, Vault automatically rotates the password upon creation.',
})
skip_import_rotation;
@attr('array', {
editType: 'stringArray',
}),
revocation_statements: attr('array', {
})
creation_statements;
@attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
rotation_statements: attr('array', {
})
revocation_statements;
@attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
rollback_statements: attr('array', {
})
rotation_statements;
@attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
renew_statements: attr('array', {
})
rollback_statements;
@attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
creation_statement: attr('string', {
})
renew_statements;
@attr('string', {
editType: 'json',
allowReset: true,
theme: 'hashi short',
defaultShown: 'Default',
}),
revocation_statement: attr('string', {
})
creation_statement;
@attr('string', {
editType: 'json',
allowReset: true,
theme: 'hashi short',
defaultShown: 'Default',
}),
})
revocation_statement;
/* FIELD ATTRIBUTES */
get fieldAttrs() {
// Main fields on edit/create form
const fields = ['name', 'database', 'type'];
return expandAttributeMeta(this, fields);
},
}
get showFields() {
let fields = ['name', 'database', 'type'];
fields = fields.concat(getRoleFields(this.type)).concat(['creation_statements']);
@ -107,9 +126,8 @@ export default Model.extend({
fields = fields.concat(['revocation_statements']);
}
return expandAttributeMeta(this, fields);
},
roleSettingAttrs: computed(function () {
}
get roleSettingAttrs() {
// logic for which get displayed is on DatabaseRoleSettingForm
const allRoleSettingFields = [
'default_ttl',
@ -126,25 +144,40 @@ export default Model.extend({
'renew_statements',
];
return expandAttributeMeta(this, allRoleSettingFields);
}),
}
/* CAPABILITIES */
// only used for secretPath
path: attr('string', { readOnly: true }),
@attr('string', { readOnly: true }) path;
@lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id') secretPath;
@lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend') dynamicPath;
@lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend') staticPath;
@lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id') credentialPath;
@lazyCapabilities(apiPath`${'backend'}/static-creds/${'id'}`, 'backend', 'id') staticCredentialPath;
@lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database') databasePath;
@lazyCapabilities(apiPath`${'backend'}/rotate-role/${'id'}`, 'backend', 'id') rotateRolePath;
secretPath: lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id'),
canEditRole: alias('secretPath.canUpdate'),
canDelete: alias('secretPath.canDelete'),
dynamicPath: lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend'),
canCreateDynamic: alias('dynamicPath.canCreate'),
staticPath: lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend'),
canCreateStatic: alias('staticPath.canCreate'),
credentialPath: lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id'),
staticCredentialPath: lazyCapabilities(apiPath`${'backend'}/static-creds/${'id'}`, 'backend', 'id'),
canGenerateCredentials: alias('credentialPath.canRead'),
canGetCredentials: alias('staticCredentialPath.canRead'),
databasePath: lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database'),
canUpdateDb: alias('databasePath.canUpdate'),
rotateRolePath: lazyCapabilities(apiPath`${'backend'}/rotate-role/${'id'}`, 'backend', 'id'),
canRotateRoleCredentials: alias('rotateRolePath.canUpdate'),
});
get canEditRole() {
return this.secretPath.get('canUpdate');
}
get canDelete() {
return this.secretPath.get('canDelete');
}
get canCreateDynamic() {
return this.dynamicPath.get('canCreate');
}
get canCreateStatic() {
return this.staticPath.get('canCreate');
}
get canGenerateCredentials() {
return this.credentialPath.get('canRead');
}
get canGetCredentials() {
return this.staticCredentialPath.get('canRead');
}
get canUpdateDb() {
return this.databasePath.get('canUpdate');
}
get canRotateRoleCredentials() {
return this.rotateRolePath.get('canUpdate');
}
}

View file

@ -2,10 +2,9 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<PageHeader as |p|>
<p.top>
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{this.mode}} @root={{@root}} @showCurrent={{true}} />
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{@mode}} @root={{@root}} @showCurrent={{true}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-secret-header="true">
@ -19,7 +18,6 @@
</h1>
</p.levelLeft>
</PageHeader>
{{#if (eq @mode "show")}}
<Toolbar>
<ToolbarActions>
@ -28,7 +26,7 @@
@buttonText="Delete role"
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{action "delete"}}
@onConfirmAction={{this.delete}}
@confirmTitle="Delete role?"
@confirmMessage="This role will be permanently deleted. You will need to recreate it to use it again."
data-test-database-role-delete
@ -102,12 +100,13 @@
{{else}}
{{! Edit or Create }}
<div class="box is-sideless is-fullwidth is-marginless">
<form {{on "submit" this.handleCreateEditRole}}>
<form {{on "submit" (perform this.handleCreateEditRole)}}>
<MessageError @errorMessage={{this.errorMessage}} />
{{#each @model.fieldAttrs as |attr|}}
{{#if (eq @mode "edit")}}
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
{{else if (not-eq attr.options.readOnly true)}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{! TODO: If database && !updateDB show warning }}
{{#if (get this.warningMessages attr.name)}}
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-negative-s has-bottom-margin-s" as |A|>
@ -117,44 +116,50 @@
{{/if}}
{{/if}}
{{/each}}
<DatabaseRoleSettingForm
@attrs={{@model.roleSettingAttrs}}
@roleType={{@model.type}}
@model={{@model}}
@mode={{@mode}}
@dbType={{await this.databaseType}}
@modelValidations={{this.modelValidations}}
/>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<Hds::ButtonSet>
<Hds::Button
@text={{if (eq @mode "create") "Create role" "Save"}}
@icon={{if this.loading "loading"}}
type="submit"
disabled={{this.loading}}
data-test-secret-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{@model.backend}}
@query={{hash tab="role"}}
data-test-database-role-cancel
/>
</Hds::ButtonSet>
{{#if (not (is-empty-value this.warningMessages))}}
<Hds::Alert @type="compact" @color="warning" class="has-left-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You don't have permissions required to
{{if (eq @mode "create") "create" "update"}}
this role. See form for details.
</A.Description>
</Hds::Alert>
{{/if}}
</div>
<div class="field is-fullwidth box is-bottomless">
<Hds::ButtonSet>
<Hds::Button
@text={{if (eq @mode "create") "Create role" "Save"}}
@icon={{if this.handleCreateEditRole.isRunning "loading"}}
type="submit"
disabled={{this.handleCreateEditRole.isRunning}}
data-test-secret-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{@model.backend}}
@query={{hash tab="role"}}
data-test-database-role-cancel
/>
</Hds::ButtonSet>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
{{#if (not (is-empty-value this.warningMessages))}}
<Hds::Alert @type="compact" @color="warning" class="has-left-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You don't have permissions required to
{{if (eq @mode "create") "create" "update"}}
this role. See form for details.
</A.Description>
</Hds::Alert>
{{/if}}
</div>
</form>
</div>

View file

@ -11,7 +11,7 @@
{{#if (and (eq @mode "edit") (eq attr.name "username"))}}
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
{{else}}
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} @modelValidations={{@modelValidations}} />
{{/if}}
{{/each}}

View file

@ -35,8 +35,25 @@ module('Integration | Component | database-role-edit', function (hooks) {
name: 'my-dynamic-role',
id: 'my-dynamic-role',
});
this.store.pushPayload('database-role', {
modelName: 'database/role',
database: ['my-mongodb-database'],
id: 'test-role',
type: 'static',
name: 'test-role',
});
this.modelStatic = this.store.peekRecord('database/role', 'my-static-role');
this.modelDynamic = this.store.peekRecord('database/role', 'my-dynamic-role');
this.modelEmpty = this.store.peekRecord('database/role', 'test-role');
});
test('it should display form errors when trying to create a role without required fields', async function (assert) {
this.server.post('/sys/capabilities-self', capabilitiesStub('database/static-creds/my-role', ['create']));
await render(hbs`<DatabaseRoleEdit @model={{this.modelEmpty}} @mode="create"/>`);
await click('[data-test-secret-save]');
assert.dom('[data-test-inline-error-message]').exists('Inline form errors exist');
});
test('it should let user edit a static role when given update capability', async function (assert) {