[UI] Ember Data Migration - KMIP Roles (#11801) (#11854)

* updates kmip scope roles route to ts

* updates kmip scope roles route to use api service and adds page component

* converts kmip role route to ts

* fixes a11y error in kmip header-credentials component

* updates kmip role route to use api service and adds page component

* removes kmip operation-field-display component that was moved into role page component

* converts kmip role create route to ts

* moves kmip role form component to component directory root

* converts kmip role form component to ts

* adds operation-groups helper and refactors kmip role page to use it

* adds operation-label helper and updates kmip role page to use it

* converts kmip edit role route to ts

* updates kmip role test to use operation-groups helper

* adds kmip role form

* updates kmip role edit and create routes to use api service and form class

* updates kmip role form component to work with form class

* updates kmip acceptance tests

* fixes flash message issue on kmip role form submit success

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2026-01-20 13:38:18 -07:00 committed by GitHub
parent 812498cfc6
commit 8b300cf6eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1386 additions and 647 deletions

View file

@ -0,0 +1,46 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import OpenApiForm from 'vault/forms/open-api';
import type { KmipWriteRoleRequest } from '@hashicorp/vault-client-typescript';
import type Form from 'vault/forms/form';
import type FormField from 'vault/utils/forms/field';
export default class KmipRoleForm extends OpenApiForm<KmipWriteRoleRequest> {
constructor(...args: ConstructorParameters<typeof Form>) {
super('KmipWriteRoleRequest', ...args);
}
get tlsFields() {
return this.formFields.filter((field) => field.name.startsWith('tls_'));
}
// there are currently no other fields but adding this for future-proofing
get otherFields() {
return this.formFields.filter(
(field) => !field.name.startsWith('tls_') && !field.name.startsWith('operation_')
);
}
// helper used in form template to look up field by name
fieldFor = (key: keyof KmipWriteRoleRequest) => {
return this.formFields.find((f) => f.name === key) as FormField;
};
toJSON() {
let data = this.data;
const { tls_client_key_bits, tls_client_key_type, tls_client_ttl } = data;
const tls = { tls_client_key_bits, tls_client_key_type, tls_client_ttl };
if (data.operation_all) {
data = { ...tls, operation_all: true };
} else if (data.operation_none) {
data = { ...tls, operation_none: true };
} else {
// ensure operation_all and operation_none are not present in payload
const { operation_all, operation_none, ...rest } = data;
data = rest;
}
return super.toJSON(data);
}
}

View file

@ -59,4 +59,5 @@ export const PATH_MAP = {
kubernetesRole: apiPath`${'backend'}/role/${'name'}`,
kubernetesCreds: apiPath`${'backend'}/creds/${'name'}`,
kmipScope: apiPath`${'backend'}/scopes/${'name'}`,
kmipRole: apiPath`${'backend'}/scopes/${'scope'}/roles/${'name'}`,
};

View file

@ -12,12 +12,16 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs">
<ul>
<LinkTo @route="credentials.index" @models={{array @scope @role}} data-test-kmip-link-credentials="true">
Credentials
</LinkTo>
<LinkTo @route="role" @models={{array @scope @role}} data-test-kmip-link-role-details="true">
Details
</LinkTo>
<li>
<LinkTo @route="credentials.index" @models={{array @scope @role}} data-test-kmip-link-credentials="true">
Credentials
</LinkTo>
</li>
<li>
<LinkTo @route="role" @models={{array @scope @role}} data-test-kmip-link-role-details="true">
Details
</LinkTo>
</li>
</ul>
</nav>
</div>

View file

@ -1,82 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<form {{on "submit" (perform this.save)}}>
<MessageError @model={{@model}} data-test-edit-form-error />
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" />
{{#if @model.isNew}}
{{! Show role name only in create mode }}
<FormField data-test-field @attr={{hash name="role" type="string" options=(hash label="Name")}} @model={{@model}} />
{{/if}}
<div class="control is-flex box is-shadowless is-fullwidth is-marginless">
<input
data-test-input="operationNone"
id="operationNone"
type="checkbox"
class="toggle is-success is-small"
checked={{not @model.operationNone}}
{{on "change" this.toggleOperationSpecial}}
/>
<label for="operationNone" class="has-text-weight-bold is-size-8">
Allow this role to perform KMIP operations
</label>
</div>
{{#unless @model.operationNone}}
<Toolbar>
<h3 class="title is-6 has-left-padding-s" data-test-kmip-section="Allowed Operations">
Allowed Operations
</h3>
</Toolbar>
<div class="box">
<FormField
@attr={{hash name="operationAll" type="boolean" options=(hash label="Allow this role to perform all operations")}}
@model={{@model}}
/>
<hr />
<div class="kmip-role-operations">
{{#each this.operationFormGroups as |group|}}
<div class="kmip-role-allowed-operations">
<h4 class="title is-7" data-test-kmip-operations={{group.name}}>{{group.name}}</h4>
{{#each group.fields as |attr|}}
<FormField
data-test-field
@disabled={{or @model.operationNone @model.operationAll}}
@attr={{attr}}
@model={{this.placeholderOrModel @model attr.name}}
@showHelpText={{false}}
/>
{{/each}}
</div>
{{/each}}
</div>
</div>
{{/unless}}
<div class="box is-fullwidth is-shadowless">
<h3 class="title is-3" data-test-kmip-section="TLS">
TLS
</h3>
{{#each this.tlsFormFields as |attr|}}
<FormField data-test-field @attr={{attr}} @model={{@model}} />
{{/each}}
</div>
{{#each @model.fields as |attr|}}
<FormField data-test-field @attr={{attr}} @model={{@model}} />
{{/each}}
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<Hds::ButtonSet>
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-submit
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" @onCancel}} data-test-cancel />
</Hds::ButtonSet>
</div>
</form>

View file

@ -1,107 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import AdapterError from '@ember-data/adapter/error';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
import { removeManyFromArray } from 'vault/helpers/remove-from-array';
import { operationFieldsWithoutSpecial, tlsFields } from 'vault/utils/model-helpers/kmip-role-fields';
export default class KmipRoleFormComponent extends Component {
@service flashMessages;
@service store;
// Actual attribute fields
get tlsFormFields() {
return tlsFields().map((attr) => this.args.model.allByKey[attr]);
}
get operationFormGroups() {
const objects = [
'operationCreate',
'operationActivate',
'operationGet',
'operationLocate',
'operationRekey',
'operationRevoke',
'operationDestroy',
];
const attributes = ['operationAddAttribute', 'operationGetAttributes'];
const server = ['operationDiscoverVersions'];
const others = removeManyFromArray(operationFieldsWithoutSpecial(this.args.model.editableFields), [
...objects,
...attributes,
...server,
]);
const groups = [
{ name: 'Managed Cryptographic Objects', fields: objects },
{ name: 'Object Attributes', fields: attributes },
{ name: 'Server', fields: server },
];
if (others.length) {
groups.push({
name: 'Other',
fields: others,
});
}
// expand field names to attributes
return groups.map((group) => ({
...group,
fields: group.fields.map((attr) => this.args.model.allByKey[attr]),
}));
}
placeholderOrModel = (model, attrName) => {
return model.operationAll ? { [attrName]: true } : model;
};
preSave() {
const opFieldsWithoutSpecial = operationFieldsWithoutSpecial(this.args.model.editableFields);
// if we have operationAll or operationNone, we want to clear
// out the others so that display shows the right data
if (this.args.model.operationAll || this.args.model.operationNone) {
opFieldsWithoutSpecial.forEach((field) => (this.args.model[field] = null));
}
// set operationNone if user unchecks 'operationAll' instead of toggling the 'operationNone' input
// doing here instead of on the 'operationNone' input because a user might deselect all, then reselect some options
// and immediately setting operationNone will hide all of the checkboxes in the UI
this.args.model.operationNone =
opFieldsWithoutSpecial.every((attr) => this.args.model[attr] !== true) && !this.args.model.operationAll;
return this.args.model;
}
@action toggleOperationSpecial(evt) {
const { checked } = evt.target;
this.args.model.operationNone = !checked;
this.args.model.operationAll = checked;
}
save = task(async (evt) => {
evt.preventDefault();
const model = this.preSave();
try {
await model.save();
this.flashMessages.success(`Saved role ${model.role}`);
} catch (err) {
// err will display via model state
// AdapterErrors are handled by the error-message component
if (err instanceof AdapterError === false) {
throw err;
}
return;
}
this.args.onSave();
});
willDestroy() {
// components are torn down after store is unloaded and will cause an error if attempt to unload record
const noTeardown = this.store && !this.store.isDestroying;
if (noTeardown && this.args?.model?.isDirty) {
this.args.model.rollbackAttributes();
}
super.willDestroy();
}
}

View file

@ -1,25 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.model.operationAll}}
<AlertInline @type="info" @message="This role allows all KMIP operations" class="is-marginless" />
{{/if}}
{{#each @model.operationFormFields as |group|}}
{{#each-in group as |groupName fieldsInGroup|}}
<InfoTableRow @alwaysRender={{true}} @label={{groupName}} @value={{true}}>
<div>
{{#each fieldsInGroup as |field|}}
<Icon
aria-label={{compute (action "operationEnabled") this.model field}}
class={{compute (action "iconClass") this.model field}}
@name={{compute (action "iconGlyph") this.model field}}
/>
{{field.options.label}}
<br />
{{/each}}
</div>
</InfoTableRow>
{{/each-in}}
{{/each}}

View file

@ -1,45 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
/**
* @module OperationFieldDisplay
* OperationFieldDisplay components are used on KMIP role show pages to display the allowed operations on that model
*
* @example
* ```js
* <OperationFieldDisplay @model={{model}} />
* ```
*
* @param model {DS.Model} - model is the KMIP role model that needs to display its allowed operations
*
*/
import Component from '@ember/component';
export default Component.extend({
tagName: '',
model: null,
trueOrFalseString(model, field, trueString, falseString) {
if (model.operationAll) {
return trueString;
}
if (model.operationNone) {
return falseString;
}
return model[field.name] ? trueString : falseString;
},
actions: {
iconClass(model, field) {
return this.trueOrFalseString(model, field, 'hds-foreground-success', 'hds-foreground-faint');
},
iconGlyph(model, field) {
return this.trueOrFalseString(model, field, 'check-circle', 'x-square');
},
operationEnabled(model, field) {
return this.trueOrFalseString(model, field, 'Enabled', 'Disabled');
},
},
});

View file

@ -0,0 +1,61 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<HeaderCredentials @role={{@roleName}} @scope={{@scopeName}} />
<Toolbar>
<ToolbarActions>
{{#if @capabilities.canDelete}}
<ConfirmAction
@buttonText="Delete role"
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{this.deleteRole}}
@confirmMessage="Are you sure you want to delete {{@roleName}}?"
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @capabilities.canUpdate}}
<ToolbarLink @route="role.edit" @models={{array @scopeName @roleName}} data-test-kmip-link-edit-role>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-shadowless">
<div class="box is-fullwidth is-shadowless is-sideless is-marginless">
<h2 class="title is-5">TLS</h2>
<InfoTableRow @label="TLS client key bits" @value={{@role.tls_client_key_bits}} />
<InfoTableRow @label="TLS client key type" @value={{@role.tls_client_key_type}} />
<InfoTableRow @label="TLS client TTL" @value={{@role.tls_client_ttl}} @formatTtl={{true}} />
</div>
<div class="box is-fullwidth is-shadowless is-sideless is-marginless">
<h2 class="title is-5">Allowed operations</h2>
{{#if @role.operation_all}}
<AlertInline @type="info" @message="This role allows all KMIP operations" class="is-marginless" />
{{/if}}
{{! the operation-groups helper gets all the available operation keys and formats them into groups }}
{{#each-in (operation-groups) as |groupName fields|}}
<InfoTableRow @alwaysRender={{true}} @label={{groupName}} @value={{true}}>
<div>
{{#each fields as |field|}}
{{#let (this.iconState field) as |icon|}}
<div data-test-operation-field={{field}}>
<Icon aria-label={{icon.label}} class={{icon.class}} @name={{icon.name}} />
{{! operation-label helper formats the key into a human-readable label }}
{{operation-label field}}
<br />
</div>
{{/let}}
{{/each}}
</div>
</InfoTableRow>
{{/each-in}}
</div>
</div>

View file

@ -0,0 +1,54 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import type RouterService from '@ember/routing/router-service';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type ApiService from 'vault/services/api';
import type { Capabilities } from 'vault/app-types';
import type FlashMessageService from 'vault/services/flash-messages';
import type { KmipWriteRoleRequest } from '@hashicorp/vault-client-typescript';
interface Args {
role: KmipWriteRoleRequest;
roleName: string;
scopeName: string;
capabilities: Capabilities;
}
export default class KmipScopesPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly api: ApiService;
@service declare readonly flashMessages: FlashMessageService;
iconState = (field: keyof KmipWriteRoleRequest) => {
const { operation_all, operation_none } = this.args.role;
const isEnabled = operation_all || (!operation_none && this.args.role[field]);
return {
class: isEnabled ? 'hds-foreground-success' : 'hds-foreground-faint',
name: isEnabled ? 'check-circle' : 'x-square',
label: isEnabled ? 'Enabled' : 'Disabled',
};
};
@action
async deleteRole() {
const { roleName, scopeName } = this.args;
const { currentPath } = this.secretMountPath;
try {
await this.api.secrets.kmipDeleteRole(roleName, scopeName, currentPath);
this.flashMessages.success(`Successfully deleted role ${roleName}`);
this.router.transitionTo('vault.cluster.secrets.backend.kmip.scope.roles', scopeName);
} catch (error) {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Error deleting role ${roleName}: ${message}`);
}
}
}

View file

@ -0,0 +1,117 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title={{@scope}}>
<:breadcrumbs>
<KmipBreadcrumb @showPath={{true}} @currentRoute={{@scope}} />
</:breadcrumbs>
</Page::Header>
<Toolbar>
{{#if @roles.meta.total}}
<ToolbarFilters>
<FilterInput
aria-label="Filter roles by name"
placeholder="Filter roles by name"
@value={{@filterValue}}
@wait={{200}}
@onInput={{this.onFilterChange}}
/>
</ToolbarFilters>
{{/if}}
<ToolbarActions>
<ToolbarLink @route="scope.roles.create" @type="add" data-test-role-create>
Create role
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#if @roles}}
<div class="has-bottom-margin-s">
{{#each @roles as |role|}}
<div data-test-list-item={{role}}>
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "credentials" @scope role}} as |Item|>
<Item.content>
<Icon @name="user" class="has-text-grey-light" />{{role}}
</Item.content>
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="More options"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
<dd.Interactive @route="credentials" @models={{array @scope role}} data-test-popup-menu="View credentials">
View credentials
</dd.Interactive>
<dd.Interactive @route="role" @models={{array @scope role}} data-test-popup-menu="View role">
View role
</dd.Interactive>
{{#if
(has-capability
@capabilities
"update"
pathKey="kmipRole"
params=(hash backend=this.secretMountPath.currentPath scope=@scope name=role)
)
}}
<dd.Interactive @route="role.edit" @models={{array @scope role}} data-test-popup-menu="Edit role">
Edit role
</dd.Interactive>
{{/if}}
{{#if
(has-capability
@capabilities
"delete"
pathKey="kmipRole"
params=(hash backend=this.secretMountPath.currentPath scope=@scope name=role)
)
}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.roleToDelete) role)}}
data-test-confirm-action-trigger
>
Delete role
</dd.Interactive>
{{/if}}
</Hds::Dropdown>
{{#if (eq this.roleToDelete role)}}
<ConfirmModal
@color="critical"
@confirmMessage="Are you sure you want to delete {{this.roleToDelete}}?"
@onConfirm={{this.deleteRole}}
@onClose={{fn (mut this.roleToDelete) null}}
/>
{{/if}}
</Item.menu>
</ListItem>
</div>
{{/each}}
<Hds::Pagination::Numbered
@currentPage={{@roles.meta.currentPage}}
@currentPageSize={{@roles.meta.pageSize}}
@route="scope.roles"
@showSizeSelector={{false}}
@totalItems={{@roles.meta.filteredTotal}}
@queryFunction={{this.paginationQueryParams}}
data-test-pagination
/>
</div>
{{else}}
{{#if @filterValue}}
<EmptyState @title="There are no roles matching &quot;{{@filterValue}}&quot;" />
{{else}}
<EmptyState
@title="No roles in this scope yet"
@message="Roles let you generate credentials with a specified set of KMIP operations permissions that clients are allowed to perform."
>
<Hds::Link::Standalone @icon="plus" @text="Create a role" @route="scope.roles.create" />
</EmptyState>
{{/if}}
{{/if}}

View file

@ -0,0 +1,62 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { getOwner } from '@ember/owner';
import type RouterService from '@ember/routing/router-service';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type ApiService from 'vault/services/api';
import type { CapabilitiesMap, EngineOwner } from 'vault/app-types';
import FlashMessageService from 'vault/services/flash-messages';
interface Args {
scope: string;
roles: string[];
capabilities: CapabilitiesMap;
filterValue: string | undefined;
}
export default class KmipScopeRolesPageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly api: ApiService;
@service declare readonly flashMessages: FlashMessageService;
@tracked roleToDelete: string | null = null;
get mountPoint() {
return (getOwner(this) as EngineOwner).mountPoint;
}
get paginationQueryParams() {
return (page: number) => ({ page });
}
@action
onFilterChange(pageFilter: string) {
this.router.transitionTo({ queryParams: { pageFilter } });
}
@action
async deleteRole() {
try {
await this.api.secrets.kmipDeleteRole(
this.roleToDelete as string,
this.args.scope,
this.secretMountPath.currentPath
);
this.flashMessages.success(`Successfully deleted role ${this.roleToDelete}`);
this.roleToDelete = null;
this.router.refresh();
} catch (error) {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Error deleting role ${this.roleToDelete}: ${message}`);
}
}
}

View file

@ -0,0 +1,111 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @errorMessage={{this.errorMessage}} />
<NamespaceReminder @mode="save" />
{{#if @form.isNew}}
{{! only show name field for new roles }}
<div class="field" data-test-field="name">
<Hds::Form::TextInput::Field
name="name"
@id="kmip-role-name"
@value={{this.name}}
@isInvalid={{this.validationError}}
disabled={{this.save.isRunning}}
autocomplete="off"
spellcheck="false"
data-test-input="name"
{{on "input" (pipe (pick "target.value") (fn (mut this.name)))}}
as |F|
>
<F.Label data-test-form-field-label>Name</F.Label>
{{#if this.validationError}}
<F.Error data-test-validation-error="name">Name is required</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
</div>
{{/if}}
<div class="control is-flex box is-shadowless is-fullwidth is-marginless">
<Hds::Form::Toggle::Field
data-test-input="operation_none"
checked={{not @form.data.operation_none}}
{{on "change" this.toggleOperationNone}}
as |F|
>
<F.Label>Allow this role to perform KMIP operations</F.Label>
</Hds::Form::Toggle::Field>
</div>
{{#unless @form.data.operation_none}}
<Toolbar>
<h3 class="title is-6 has-left-padding-s" data-test-kmip-section="Allowed Operations">
Allowed Operations
</h3>
</Toolbar>
<div class="box">
<FormField
@attr={{hash name="operation_all" type="boolean" options=(hash label="Allow this role to perform all operations")}}
@model={{@form}}
/>
<hr />
<div class="kmip-role-operations">
{{! the operation-groups helper gets all the available operation keys and formats them into groups }}
{{#each-in (operation-groups) as |groupName fields|}}
<div class="kmip-role-allowed-operations">
<h4 class="title is-7" data-test-kmip-operations={{groupName}}>
{{groupName}}
</h4>
{{#each fields as |fieldKey|}}
{{#let (@form.fieldFor fieldKey) as |field|}}
<div class="field" data-test-field={{field.name}}>
{{#let (get @form.data fieldKey) as |value|}}
<Hds::Form::Checkbox::Field
name={{field.name}}
checked={{or @form.data.operation_all value}}
disabled={{@form.data.operation_all}}
{{on "change" (fn (mut value) (not value))}}
data-test-input={{field.name}}
as |F|
>
<F.Label data-test-form-field-label>{{field.options.label}}</F.Label>
</Hds::Form::Checkbox::Field>
{{/let}}
</div>
{{/let}}
{{/each}}
</div>
{{/each-in}}
</div>
</div>
{{/unless}}
<div class="box is-fullwidth is-shadowless">
<h3 class="title is-3" data-test-kmip-section="TLS">
TLS
</h3>
{{#each @form.tlsFields as |field|}}
<FormField @attr={{field}} @model={{@form}} />
{{/each}}
</div>
{{#each @form.otherFields as |field|}}
<FormField @attr={{field}} @model={{@form}} />
{{/each}}
</div>
<FormSaveButtons @isSaving={{this.save.isRunning}} @onCancel={{@onCancel}}>
<:error>
{{#if this.invalidFormAlert}}
<AlertInline @type="danger" class="has-top-padding-s" @message={{this.invalidFormAlert}} />
{{/if}}
</:error>
</FormSaveButtons>
</form>

View file

@ -0,0 +1,71 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import type KmipRoleForm from 'vault/forms/secrets/kmip/role';
import type FlashMessageService from 'vault/services/flash-messages';
import type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type { HTMLElementEvent } from 'vault/forms';
interface Args {
roleName: string;
scopeName: string;
form: KmipRoleForm;
onSave: CallableFunction;
onCancel: CallableFunction;
}
export default class KmipRoleFormComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@tracked declare name: string;
@tracked validationError = false;
@tracked invalidFormAlert: string | null = null;
@tracked errorMessage: string | null = null;
@action
toggleOperationNone(event: HTMLElementEvent<HTMLInputElement>) {
const { checked } = event.target;
const { data } = this.args.form;
data.operation_none = !checked;
data.operation_all = checked;
}
save = task(
waitFor(async (event: HTMLElementEvent<HTMLFormElement>) => {
event.preventDefault();
const { form, roleName, scopeName } = this.args;
if (!form.isNew || this.name) {
this.validationError = false;
try {
const { data } = form.toJSON();
const name = form.isNew ? this.name : roleName;
await this.api.secrets.kmipWriteRole(name, scopeName, this.secretMountPath.currentPath, data);
this.flashMessages.success(`Successfully saved role ${name}`);
this.args.onSave();
} catch (error) {
const { message } = await this.api.parseError(error);
this.errorMessage = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
} else {
this.validationError = true;
this.invalidFormAlert = 'There is an error with this form.';
}
})
);
}

View file

@ -1,26 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class RoleController extends Controller {
@service flashMessages;
@service('app-router') router;
@action
async deleteRole() {
const { id } = this.model;
try {
await this.model.destroyRecord();
this.flashMessages.success(`Successfully deleted role ${id}`);
this.router.transitionTo('vault.cluster.secrets.backend.kmip.scope.roles', this.scope);
} catch (e) {
this.flashMessages.danger(`There was an error deleting the role ${id}: ${e.errors.join(' ')}`);
this.model.rollbackAttributes();
}
}
}

View file

@ -1,15 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ListController from 'core/mixins/list-controller';
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { getOwner } from '@ember/owner';
export default Controller.extend(ListController, {
mountPoint: computed(function () {
return getOwner(this).mountPoint;
}),
});

View file

@ -0,0 +1,32 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { KmipWriteRoleRequest, KmipWriteRoleRequestToJSONTyped } from '@hashicorp/vault-client-typescript';
export default function operationGroups() {
// the client helper returns an object with all available properties from the schema
// we could pass in a role to set the values but in this case we are only interested in the keys
const role = KmipWriteRoleRequestToJSONTyped({} as KmipWriteRoleRequest);
const objects = [
'operation_create',
'operation_activate',
'operation_get',
'operation_locate',
'operation_rekey',
'operation_revoke',
'operation_destroy',
];
const attributes = ['operation_add_attribute', 'operation_get_attributes'];
const server = ['operation_discover_versions'];
const notOther = [...objects, ...attributes, ...server, 'operation_all', 'operation_none'];
const other = Object.keys(role).filter((key) => key.startsWith('operation_') && !notOther.includes(key));
return {
'Managed Cryptographic Objects': objects,
'Object Attributes': attributes,
Server: server,
Other: other,
};
}

View file

@ -0,0 +1,11 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { capitalize } from '@ember/string';
import type { KmipWriteRoleRequest } from '@hashicorp/vault-client-typescript';
export default function label(field: keyof KmipWriteRoleRequest) {
return field.replace('operation_', '').split('_').map(capitalize).join(' ');
}

View file

@ -1,31 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class KmipRoleRoute extends Route {
@service store;
@service secretMountPath;
@service pathHelp;
beforeModel() {
return this.pathHelp.hydrateModel('kmip/role', this.secretMountPath.currentPath);
}
model(params) {
return this.store.queryRecord('kmip/role', {
backend: this.secretMountPath.currentPath,
scope: params.scope_name,
id: params.role_name,
});
}
setupController(controller) {
super.setupController(...arguments);
const { scope_name: scope, role_name: role } = this.paramsFor('role');
controller.setProperties({ role, scope });
}
}

View file

@ -0,0 +1,40 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type CapabilitiesService from 'vault/services/capabilities';
export default class KmipRoleRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly capabilities: CapabilitiesService;
async model(params: { scope_name: string; role_name: string }) {
const { currentPath } = this.secretMountPath;
const { scope_name, role_name } = params;
const { data: role } = await this.api.secrets.kmipReadRole(
params.role_name,
params.scope_name,
currentPath
);
const capabilities = await this.capabilities.for('kmipRole', {
backend: currentPath,
scope: scope_name,
name: role_name,
});
return {
role,
roleName: role_name,
scopeName: scope_name,
capabilities,
};
}
}

View file

@ -1,30 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default Route.extend({
store: service(),
secretMountPath: service(),
pathHelp: service(),
beforeModel() {
return this.pathHelp.hydrateModel('kmip/role', this.secretMountPath.currentPath);
},
model() {
const params = this.paramsFor(this.routeName);
return this.store.queryRecord('kmip/role', {
backend: this.secretMountPath.currentPath,
scope: params.scope_name,
id: params.role_name,
});
},
setupController(controller) {
this._super(...arguments);
const { scope_name: scope, role_name: role } = this.paramsFor(this.routeName);
controller.setProperties({ role, scope });
},
});

View file

@ -0,0 +1,30 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import KmipRoleForm from 'vault/forms/secrets/kmip/role';
import type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type { KmipWriteRoleRequest } from '@hashicorp/vault-client-typescript';
export default class KmipRoleEditRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
async model(params: { scope_name: string; role_name: string }) {
const { currentPath } = this.secretMountPath;
const { scope_name: scopeName, role_name: roleName } = params;
const { data: role } = await this.api.secrets.kmipReadRole(roleName, scopeName, currentPath);
return {
form: new KmipRoleForm(role as KmipWriteRoleRequest),
roleName,
scopeName,
};
}
}

View file

@ -1,42 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import ListRoute from 'core/mixins/list-route';
import { service } from '@ember/service';
export default Route.extend(ListRoute, {
pagination: service(),
secretMountPath: service(),
pathHelp: service(),
scope() {
return this.paramsFor('scope').scope_name;
},
beforeModel() {
return this.pathHelp.hydrateModel('kmip/role', this.secretMountPath.currentPath);
},
model(params) {
return this.pagination
.lazyPaginatedQuery('kmip/role', {
backend: this.secretMountPath.currentPath,
scope: this.scope(),
responsePath: 'data.keys',
page: params.page,
pageFilter: params.pageFilter,
})
.catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
},
setupController(controller) {
this._super(...arguments);
controller.set('scope', this.scope());
},
});

View file

@ -0,0 +1,69 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { paginate } from 'core/utils/paginate-list';
import { KmipListRolesListEnum } from '@hashicorp/vault-client-typescript';
import type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Controller from '@ember/controller';
import type CapabilitiesService from 'vault/services/capabilities';
interface KmipScopeRolesController extends Controller {
pageFilter: string | undefined;
page: number | undefined;
}
export default class KmipScopeRolesRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly capabilities: CapabilitiesService;
queryParams = {
page: {
refreshModel: true,
},
pageFilter: {
refreshModel: true,
},
};
async model(params: { page: number; pageFilter: string }) {
const { page, pageFilter } = params;
const { currentPath } = this.secretMountPath;
const { scope_name: scope } = this.paramsFor('scope');
try {
const { keys } = await this.api.secrets.kmipListRoles(
scope as string,
currentPath,
KmipListRolesListEnum.TRUE
);
const roles = keys ? paginate(keys, { page: Number(page) || 1, filter: pageFilter }) : [];
// fetch capabilities for filtered scopes
const paths = roles.map((role) =>
this.capabilities.pathFor('kmipRole', { backend: currentPath, scope, name: role })
);
const capabilities = paths ? await this.capabilities.fetch(paths) : {};
return { roles, capabilities, scope };
} catch (error) {
const { status } = await this.api.parseError(error);
if (status === 404) {
return { roles: [], capabilities: {}, scope };
}
throw error;
}
}
resetController(controller: KmipScopeRolesController, isExiting: boolean) {
if (isExiting) {
controller.set('pageFilter', undefined);
controller.set('page', undefined);
}
}
}

View file

@ -1,32 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default Route.extend({
store: service(),
secretMountPath: service(),
pathHelp: service(),
scope() {
return this.paramsFor('scope').scope_name;
},
beforeModel() {
this.store.unloadAll('kmip/role');
return this.pathHelp.hydrateModel('kmip/role', this.secretMountPath.currentPath);
},
model() {
const model = this.store.createRecord('kmip/role', {
backend: this.secretMountPath.currentPath,
scope: this.scope(),
operationAll: true,
});
return model;
},
setupController(controller) {
this._super(...arguments);
controller.set('scope', this.scope());
},
});

View file

@ -0,0 +1,17 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import KmipRoleForm from 'vault/forms/secrets/kmip/role';
export default class KmipRoleCreateRoute extends Route {
model() {
const { scope_name } = this.paramsFor('scope');
return {
scopeName: scope_name,
form: new KmipRoleForm({ operation_all: true }, { isNew: true }),
};
}
}

View file

@ -3,32 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
}}
<HeaderCredentials @role={{this.role}} @scope={{this.scope}} />
<Toolbar>
<ToolbarActions>
{{#if this.model.updatePath.canUpdate}}
<ConfirmAction
@buttonText="Delete role"
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{this.deleteRole}}
@confirmMessage="Are you sure you want to delete {{this.model.id}}?"
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if this.model.updatePath.canUpdate}}
<ToolbarLink @route="role.edit" @models={{array this.scope this.role}} data-test-kmip-link-edit-role>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-shadowless">
<FieldGroupShow @model={{this.model}} @showAllFields={{false}} />
<div class="box is-fullwidth is-shadowless">
<h2 class="title is-5">
Allowed operations
</h2>
<OperationFieldDisplay @model={{this.model}} />
</div>
</div>
<Page::Role
@role={{this.model.role}}
@roleName={{this.model.roleName}}
@scopeName={{this.model.scopeName}}
@capabilities={{this.model.capabilities}}
/>

View file

@ -5,12 +5,14 @@
<Page::Header @title="Edit Role">
<:breadcrumbs>
<KmipBreadcrumb @scope={{this.scope}} @role={{this.role}} @currentRoute="Edit Role" />
<KmipBreadcrumb @scope={{this.model.scopeName}} @role={{this.model.roleName}} @currentRoute="Edit Role" />
</:breadcrumbs>
</Page::Header>
<Kmip::RoleForm
@model={{this.model}}
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.role" this.scope this.role}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kmip.role" this.scope this.role}}
<RoleForm
@form={{this.model.form}}
@roleName={{this.model.roleName}}
@scopeName={{this.model.scopeName}}
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.role" this.model.scopeName this.model.roleName}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kmip.role" this.model.scopeName this.model.roleName}}
/>

View file

@ -3,116 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title={{this.scope}}>
<:breadcrumbs>
<KmipBreadcrumb @showPath={{true}} @currentRoute={{this.scope}} />
</:breadcrumbs>
</Page::Header>
<Toolbar>
{{#if this.model.meta.total}}
<ToolbarFilters>
<NavigateInput
@filterFocusDidChange={{action "setFilterFocus"}}
@filterDidChange={{action "setFilter"}}
@filter={{this.filter}}
@filterMatchesKey={{this.filterMatchesKey}}
@firstPartialMatch={{this.firstPartialMatch}}
@placeholder="Filter roles by name"
@extraNavParams={{this.scope}}
@urls={{hash
create="vault.cluster.secrets.backend.kmip.scope.roles.create"
list="vault.cluster.secrets.backend.kmip.scope.roles"
show="vault.cluster.secrets.backend.kmip.credentials"
}}
/>
{{#if this.filterFocused}}
{{#if this.filterMatchesKey}}
<p class="input-hint">
<kbd>ENTER</kbd>
to go to
<code>{{this.filter}}</code>
roles
</p>
{{/if}}
{{#if this.firstPartialMatch}}
<p class="input-hint">
<kbd>TAB</kbd>
to complete
<code>{{this.firstPartialMatch.id}}</code>
</p>
{{/if}}
{{/if}}
</ToolbarFilters>
{{/if}}
<ToolbarActions>
<ToolbarLink @route="scope.roles.create" @type="add" data-test-role-create>
Create role
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<ListView @items={{this.model}} @itemNoun="role" @paginationRouteName="scope.roles" as |list|>
{{#if list.empty}}
<list.empty
@title="No roles in this scope yet"
@message="Roles let you generate credentials with a specified set of KMIP operations permissions that clients are allowed to perform."
>
<Hds::Link::Standalone @icon="plus" @text="Create a role" @route="scope.roles.create" />
</list.empty>
{{else if list.item}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "credentials" this.scope list.item.id}} as |Item|>
<Item.content>
<Icon @name="user" class="has-text-grey-light" />{{list.item.id}}
</Item.content>
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon @icon="more-horizontal" @text="More options" @hasChevron={{false}} data-test-popup-menu-trigger />
<dd.Interactive @route="credentials" @models={{array this.scope list.item.id}}>View credentials</dd.Interactive>
<dd.Interactive @route="role" @models={{array this.scope list.item.id}}>View role</dd.Interactive>
{{#if list.item.updatePath.isPending}}
<dd.Generic>
<LoadingDropdownOption />
</dd.Generic>
{{else}}
{{#if list.item.updatePath.canUpdate}}
<dd.Interactive @route="role.edit" @models={{array this.scope list.item.id}}>Edit role</dd.Interactive>
{{/if}}
{{#if list.item.updatePath.canDelete}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.roleToDelete) list.item)}}
data-test-confirm-action-trigger
>Delete role</dd.Interactive>
{{/if}}
{{/if}}
</Hds::Dropdown>
{{#if (eq this.roleToDelete list.item)}}
<ConfirmModal
@color="critical"
@confirmMessage="Are you sure you want to delete {{this.roleToDelete.id}}?"
@onConfirm={{action
(perform
Item.callMethod
"destroyRecord"
this.roleToDelete
(concat "Successfully deleted role " this.roleToDelete.id)
(concat "There was an error deleting the role " this.roleToDelete.id)
(action "refresh")
)
}}
@onClose={{fn (mut this.roleToDelete) null}}
/>
{{/if}}
</Item.menu>
</ListItem>
{{else}}
<ListItem as |Item|>
<Item.content>
There are no roles that match
{{this.filter}}, press
<kbd>ENTER</kbd>
to add one.
</Item.content>
</ListItem>
{{/if}}
</ListView>
<Page::Scope::Roles
@roles={{this.model.roles}}
@scope={{this.model.scope}}
@capabilities={{this.model.capabilities}}
@filterValue={{this.pageFilter}}
/>

View file

@ -5,12 +5,13 @@
<Page::Header @title="Create a Role">
<:breadcrumbs>
<KmipBreadcrumb @scope={{this.scope}} @currentRoute="Create Role" />
<KmipBreadcrumb @scope={{this.model.scopeName}} @currentRoute="Create Role" />
</:breadcrumbs>
</Page::Header>
<Kmip::RoleForm
@model={{this.model}}
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.scope}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.scope}}
<RoleForm
@form={{this.model.form}}
@scopeName={{this.model.scopeName}}
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.model.scopeName}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.model.scopeName}}
/>

View file

@ -11,6 +11,7 @@ import {
visit,
waitUntil,
find,
findAll,
click,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
@ -232,8 +233,8 @@ module('Acceptance | Enterprise | KMIP secrets', function (hooks) {
'links to the role create form'
);
// check that the role form looks right
assert.dom(GENERAL.inputByAttr('operationNone')).isChecked('allows role to perform roles by default');
assert.dom(GENERAL.inputByAttr('operationAll')).isChecked('operationAll is checked by default');
assert.dom(GENERAL.inputByAttr('operation_none')).isChecked('allows role to perform roles by default');
assert.dom(GENERAL.inputByAttr('operation_all')).isChecked('operation_all is checked by default');
assert.dom('[data-test-kmip-section]').exists({ count: 2 });
assert.dom('[data-test-kmip-operations]').exists({ count: 4 });
@ -388,75 +389,87 @@ module('Acceptance | Enterprise | KMIP secrets', function (hooks) {
this.store = this.owner.lookup('service:store');
this.scope = 'my-scope';
this.name = 'my-role';
await login();
await runCmd(mountEngineCmd('kmip', this.backend), false);
await runCmd([`write ${this.backend}/scope/${this.scope} -force`]);
await rolesPage.visit({ backend: this.backend, scope: this.scope });
this.setModel = async () => {
this.saveRole = async () => {
await click(GENERAL.submitButton);
await visit(`/vault/secrets-engines/${this.backend}/kmip/scopes/${this.scope}/roles/${this.name}`);
this.model = this.store.peekRecord('kmip/role', this.name);
};
this.iconSelector = (operation) => `[data-test-operation-field="${operation}"] svg`;
});
// "operationNone" is the attr name for the 'Allow this role to perform KMIP operations' toggle
// operationNone = false => the toggle is ON and KMIP operations are allowed
// operationNone = true => the toggle is OFF and KMIP operations are not allowed
test('it submits when operationNone is toggled on', async function (assert) {
assert.expect(3);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('role'), this.name);
assert.dom(GENERAL.inputByAttr('operationAll')).isChecked('operationAll is checked by default');
await this.setModel();
assert.true(this.model.operationAll, 'operationAll is true');
assert.strictEqual(this.model.operationNone, undefined, 'operationNone is unset');
});
test('it submits when operationNone is toggled off', async function (assert) {
assert.expect(4);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('role'), this.name);
await click(GENERAL.inputByAttr('operationNone'));
assert
.dom(GENERAL.inputByAttr('operationNone'))
.isNotChecked('Allow this role to perform KMIP operations is toggled off');
assert
.dom(GENERAL.inputByAttr('operationAll'))
.doesNotExist('clicking the toggle hides KMIP operation checkboxes');
await this.setModel();
assert.strictEqual(this.model.operationAll, undefined, 'operationAll is unset');
assert.true(this.model.operationNone, 'operationNone is true');
});
test('it submits when operationAll is unchecked', async function (assert) {
// "operation_none" is the field name for the 'Allow this role to perform KMIP operations' toggle
// operation_none = false => the toggle is ON and KMIP operations are allowed
// operation_none = true => the toggle is OFF and KMIP operations are not allowed
test('it submits when operation_none is toggled on', async function (assert) {
assert.expect(2);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('role'), this.name);
await click(GENERAL.inputByAttr('operationAll'));
await this.setModel();
await fillIn(GENERAL.inputByAttr('name'), this.name);
assert.dom(GENERAL.inputByAttr('operation_all')).isChecked('operation_all is checked by default');
await this.saveRole();
assert
.dom(GENERAL.inlineError)
.hasText('This role allows all KMIP operations', 'operation_all was saved');
});
assert.strictEqual(this.model.operationAll, undefined, 'operationAll is unset');
assert.true(this.model.operationNone, 'operationNone is true');
test('it submits when operation_none is toggled off', async function (assert) {
assert.expect(3);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('name'), this.name);
await click(GENERAL.inputByAttr('operation_none'));
assert
.dom(GENERAL.inputByAttr('operation_none'))
.isNotChecked('Allow this role to perform KMIP operations is toggled off');
assert
.dom(GENERAL.inputByAttr('operation_all'))
.doesNotExist('clicking the toggle hides KMIP operation checkboxes');
await this.saveRole();
const operations = findAll('[data-test-operation-field]');
const notAllowed = findAll('[data-test-operation-field] svg[data-test-icon="x-square"]');
assert.strictEqual(notAllowed.length, operations.length, 'no operations are allowed');
});
test('it submits when operation_all is unchecked', async function (assert) {
assert.expect(2);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('name'), this.name);
await click(GENERAL.inputByAttr('operation_all'));
await click(GENERAL.inputByAttr('operation_create'));
await this.saveRole();
assert.dom(GENERAL.inlineError).doesNotExist('operation_all was not saved');
assert
.dom(this.iconSelector('operation_create'))
.hasAttribute('data-test-icon', 'check-circle', 'operation_create was saved');
});
test('it submits individually selected operations', async function (assert) {
assert.expect(6);
assert.expect(4);
await click('[data-test-role-create]');
await fillIn(GENERAL.inputByAttr('role'), this.name);
await click(GENERAL.inputByAttr('operationAll'));
await click(GENERAL.inputByAttr('operationGet'));
await click(GENERAL.inputByAttr('operationGetAttributes'));
assert.dom(GENERAL.inputByAttr('operationAll')).isNotChecked();
assert.dom(GENERAL.inputByAttr('operationCreate')).isNotChecked(); // unchecking operationAll deselects the other checkboxes
await this.setModel();
assert.strictEqual(this.model.operationAll, undefined, 'operationAll is unset');
assert.strictEqual(this.model.operationNone, undefined, 'operationNone is unset');
assert.true(this.model.operationGet, 'operationGet is true');
assert.true(this.model.operationGetAttributes, 'operationGetAttributes is true');
await fillIn(GENERAL.inputByAttr('name'), this.name);
await click(GENERAL.inputByAttr('operation_all'));
await click(GENERAL.inputByAttr('operation_get'));
await click(GENERAL.inputByAttr('operation_get_attributes'));
assert.dom(GENERAL.inputByAttr('operation_all')).isNotChecked();
assert.dom(GENERAL.inputByAttr('operation_create')).isNotChecked(); // unchecking operation_all deselects the other checkboxes
await this.saveRole();
assert
.dom(this.iconSelector('operation_get'))
.hasAttribute('data-test-icon', 'check-circle', 'operation_get was saved');
assert
.dom(this.iconSelector('operation_get_attributes'))
.hasAttribute('data-test-icon', 'check-circle', 'operation_get_attributes was saved');
});
});
});

View file

@ -0,0 +1,180 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { click, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { capitalize } from '@ember/string';
import operationGroups from 'kmip/helpers/operation-groups';
module('Integration | Component | kmip | Page::Role', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kmip');
hooks.beforeEach(function () {
this.backend = 'kmip-test';
this.owner.lookup('service:secret-mount-path').update(this.backend);
this.roleName = 'role-1';
this.scopeName = 'scope-1';
this.capabilities = { canDelete: true, canUpdate: true };
this.getRole = (allOrNone) => {
const tlsOptions = {
tls_client_key_bits: 521,
tls_client_key_type: 'ec',
tls_client_ttl: 86400,
};
let operations = {
operation_activate: true,
operation_add_attribute: true,
operation_decrypt: true,
operation_discover_versions: true,
operation_import: true,
operation_locate: true,
operation_register: true,
operation_revoke: true,
};
if (allOrNone === 'all') {
operations = { operation_all: true };
} else if (allOrNone === 'none') {
operations = { operation_none: true };
}
return { ...tlsOptions, ...operations };
};
this.role = this.getRole();
// get all keys that are rendered in the operation groups
this.operationKeys = Object.values(operationGroups()).flat();
this.apiStub = sinon.stub(this.owner.lookup('service:api').secrets, 'kmipDeleteRole').resolves();
this.flashStub = sinon.stub(this.owner.lookup('service:flashMessages'), 'success');
this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.operationState = (assert, operation, isEnabled) => {
const iconClass = isEnabled ? 'hds-foreground-success' : 'hds-foreground-faint';
const iconName = isEnabled ? 'check-circle' : 'x-square';
const label = operation.replace('operation_', '').split('_').map(capitalize).join(' ');
const state = isEnabled ? 'enabled' : 'disabled';
const selector = `[data-test-operation-field="${operation}"]`;
assert
.dom(`${selector} svg`)
.hasClass(iconClass, `${operation} has correct icon class for ${state} state`);
assert
.dom(`${selector} svg`)
.hasAttribute('data-test-icon', iconName, `${operation} has correct icon for ${state} state`);
assert.dom(selector).containsText(label, `${operation} has correct label`);
};
this.renderComponent = () =>
render(
hbs`<Page::Role @roleName={{this.roleName}} @scopeName={{this.scopeName}} @role={{this.role}} @capabilities={{this.capabilities}} />`,
{ owner: this.engine }
);
});
test('it should render/hide toolbar actions based on capabilities', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.confirmTrigger).hasText('Delete role', 'Delete role action renders in toolbar');
assert.dom('[data-test-kmip-link-edit-role]').hasText('Edit role', 'Edit role action renders in toolbar');
this.capabilities = { canDelete: false, canUpdate: false };
await this.renderComponent();
assert.dom(GENERAL.confirmTrigger).doesNotExist('Delete role action is hidden without capability');
assert
.dom('[data-test-kmip-link-edit-role]')
.doesNotExist('Edit role action is hidden without capability');
});
test('it should delete role', async function (assert) {
await this.renderComponent();
await click(GENERAL.confirmTrigger);
await click(GENERAL.confirmButton);
assert.true(
this.apiStub.calledWith(this.roleName, this.scopeName, this.backend),
'API called to delete role'
);
assert.true(
this.flashStub.calledWith(`Successfully deleted role ${this.roleName}`),
'Success flash message shown'
);
assert.true(
this.routerStub.calledWith('vault.cluster.secrets.backend.kmip.scope.roles', this.scopeName),
'Transitions to roles list on delete success'
);
});
test('it should render tls fields', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('TLS client key bits')).hasText('521', 'TLS client key bits renders');
assert.dom(GENERAL.infoRowValue('TLS client key type')).hasText('ec', 'TLS client key type renders');
assert.dom(GENERAL.infoRowValue('TLS client TTL')).hasText('1 day', 'TLS client TTL renders');
});
test('it should render operation groups', async function (assert) {
await this.renderComponent();
assert
.dom(GENERAL.infoRowLabel('Managed Cryptographic Objects'))
.exists('Cypto operations group renders');
assert.dom(GENERAL.infoRowLabel('Object Attributes')).exists('Attributes operations group renders');
assert.dom(GENERAL.infoRowLabel('Server')).exists('Server operations group renders');
assert.dom(GENERAL.infoRowLabel('Other')).exists('Other operations group renders');
});
test('it should mark all operations as enabled when operations_all was selected', async function (assert) {
assert.expect(this.operationKeys.length * 3 + 1);
this.role = this.getRole('all');
await this.renderComponent();
assert
.dom(GENERAL.inlineError)
.hasText('This role allows all KMIP operations', 'All operations enabled message renders');
this.operationKeys.forEach((operation) => {
this.operationState(assert, operation, true);
});
});
test('it should mark operations as disabled when operations_none was selected', async function (assert) {
assert.expect(this.operationKeys.length * 3 + 1);
this.role = this.getRole('none');
await this.renderComponent();
assert
.dom(GENERAL.inlineError)
.doesNotExist('All operations enabled message does not render when operations are disabled');
this.operationKeys.forEach((operation) => {
this.operationState(assert, operation, false);
});
});
test('it should correctly mark operations as enabled or disabled based on selections', async function (assert) {
assert.expect(this.operationKeys.length * 3 + 1);
await this.renderComponent();
assert
.dom(GENERAL.inlineError)
.doesNotExist('All operations enabled message does not render when some operations are disabled');
this.operationKeys.forEach((operation) => {
this.operationState(assert, operation, this.role[operation] === true);
});
});
});

View file

@ -0,0 +1,144 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, fillIn, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | kmip | Page::Scope::Roles', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kmip');
setupMirage(hooks);
hooks.beforeEach(function () {
this.backend = 'kmip-test';
this.owner.lookup('service:secret-mount-path').update(this.backend);
const { secrets } = this.owner.lookup('service:api');
this.apiStub = sinon.stub(secrets, 'kmipDeleteRole').resolves();
const flash = this.owner.lookup('service:flashMessages');
this.flashSuccessStub = sinon.stub(flash, 'success');
this.flashDangerStub = sinon.stub(flash, 'danger');
const router = this.owner.lookup('service:router');
this.transitionStub = sinon.stub(router, 'transitionTo');
this.refreshStub = sinon.stub(router, 'refresh');
this.scope = 'scope-1';
this.roles = ['role-1', 'role-2'];
this.roles.meta = {
currentPage: 1,
pageSize: 10,
filteredTotal: this.roles.length,
total: this.roles.length,
};
this.filterValue = '';
const { pathFor } = this.owner.lookup('service:capabilities');
this.capabilities = this.roles.reduce((capabilities, name) => {
const path = pathFor('kmipRole', { backend: this.backend, scope: this.scope, name });
const hasPermission = name === 'role-1'; // only role-1 has permissions to test conditional rendering
capabilities[path] = { canDelete: hasPermission, canUpdate: hasPermission };
return capabilities;
}, {});
this.renderComponent = () =>
render(
hbs`<Page::Scope::Roles @roles={{this.roles}} @scope={{this.scope}} @capabilities={{this.capabilities}} @filterValue={{this.filterValue}} />`,
{ owner: this.engine }
);
});
test('it should render filter and create action in toolbar', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.filterInput).exists('Renders filter input in toolbar');
assert.dom('[data-test-role-create]').exists('Renders create role action in toolbar');
});
test('it should populate filter with arg value', async function (assert) {
this.filterValue = 'role-1';
await this.renderComponent();
assert.dom(GENERAL.filterInput).hasValue('role-1', 'Renders filter input with correct value');
});
test('it should filter list items', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.filterInput, 'role-1');
assert.true(
this.transitionStub.calledWith({ queryParams: { pageFilter: 'role-1' } }),
'Transitions with correct query param on page filter change'
);
});
test('it should render list items', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.listItem()).exists({ count: this.roles.length }, 'Renders correct number of roles');
assert.dom(GENERAL.listItem('role-1')).containsText('role-1', 'Renders role name in list item');
await click(`${GENERAL.listItem('role-1')} ${GENERAL.menuTrigger}`);
assert.dom(GENERAL.menuItem('View credentials')).exists('Renders View credentials action in more menu');
assert.dom(GENERAL.menuItem('View role')).exists('Renders View role action in more menu');
assert.dom(GENERAL.menuItem('Edit role')).exists('Renders Edit role action in more menu');
assert
.dom(`${GENERAL.listItem('role-1')} ${GENERAL.confirmTrigger}`)
.exists('Renders Delete action in more menu');
await click(`${GENERAL.listItem('role-2')} ${GENERAL.menuTrigger}`);
assert
.dom(GENERAL.menuItem('Edit role'))
.doesNotExist('Edit role action does not render with no update capability');
assert
.dom(`${GENERAL.listItem('role-2')} ${GENERAL.confirmTrigger}`)
.doesNotExist('Delete action does not render with no delete capability');
});
test('it should delete role', async function (assert) {
await this.renderComponent();
await click(`${GENERAL.listItem('role-1')} ${GENERAL.menuTrigger}`);
await click(`${GENERAL.listItem('role-1')} ${GENERAL.confirmTrigger}`);
await click(GENERAL.confirmButton);
assert.true(this.apiStub.calledWith('role-1', this.scope, this.backend), 'Calls API to delete role');
assert.true(
this.flashSuccessStub.calledWith('Successfully deleted role role-1'),
'Shows success flash message'
);
assert.true(this.refreshStub.called, 'Refreshes the route on delete success');
});
test('it should handle delete error', async function (assert) {
const error = 'An error occurred deleting the role';
this.apiStub.rejects(getErrorResponse({ errors: [error] }, 500));
await this.renderComponent();
await click(`${GENERAL.listItem('role-1')} ${GENERAL.menuTrigger}`);
await click(`${GENERAL.listItem('role-1')} ${GENERAL.confirmTrigger}`);
await click(GENERAL.confirmButton);
assert.true(
this.flashDangerStub.calledWith(`Error deleting role role-1: ${error}`),
'Shows flash message on delete error'
);
});
test('it should render pagination controls', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.pagination).exists('Renders pagination controls');
assert.dom(GENERAL.paginationInfo).hasText('12 of 2', 'Renders correct pagination info');
assert.dom(GENERAL.paginationSizeSelector).doesNotExist('Pagination size selector does not render');
});
});

View file

@ -0,0 +1,238 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { click, fillIn, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import KmipRoleForm from 'vault/forms/secrets/kmip/role';
import operationGroups from 'kmip/helpers/operation-groups';
module('Integration | Component | kmip | RoleForm', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kmip');
hooks.beforeEach(function () {
this.backend = 'kmip-test';
this.owner.lookup('service:secret-mount-path').update(this.backend);
this.roleName = '';
this.scopeName = 'scope-1';
this.tlsOptions = {
tls_client_key_bits: 521,
tls_client_key_type: 'ec',
tls_client_ttl: '86400',
};
this.setForm = (isNew = true, allOrNone) => {
let operations = {
operation_activate: true,
operation_add_attribute: true,
operation_decrypt: true,
operation_discover_versions: true,
operation_import: true,
operation_locate: true,
operation_register: true,
operation_revoke: true,
};
if (allOrNone === 'all') {
operations = { operation_all: true };
} else if (allOrNone === 'none') {
operations = { operation_none: true };
}
const role = isNew ? { operation_all: true } : { ...this.tlsOptions, ...operations };
this.form = new KmipRoleForm(role, { isNew });
};
this.onSave = sinon.spy();
this.onCancel = sinon.spy();
// get all keys that are rendered in the operation groups
this.operationKeys = Object.values(operationGroups()).flat();
this.apiStub = sinon.stub(this.owner.lookup('service:api').secrets, 'kmipWriteRole').resolves();
this.flashStub = sinon.stub(this.owner.lookup('service:flashMessages'), 'success');
this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.renderComponent = (isNew, allOrNone) => {
this.setForm(isNew, allOrNone);
// set the roleName for existing forms
this.roleName = isNew ? '' : 'role-1';
return render(
hbs`<RoleForm @roleName={{this.roleName}} @scopeName={{this.scopeName}} @form={{this.form}} @onSave={{this.onSave}} @onCancel={{this.onCancel}} />`,
{ owner: this.engine }
);
};
});
test('it should render name field and default to allow all for new roles', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('name')).exists('Name field is rendered for new role');
assert.dom(GENERAL.inputByAttr('operation_none')).isChecked('Operations are allowed by default');
assert.dom(GENERAL.inputByAttr('operation_all')).isChecked('Allow all operations is checked by default');
});
test('it should hide operations when none is toggled', async function (assert) {
await this.renderComponent();
assert
.dom('[data-test-kmip-section="Allowed Operations"]')
.exists('Allowed Operations section is rendered');
await click(GENERAL.inputByAttr('operation_none'));
assert
.dom('[data-test-kmip-section="Allowed Operations"]')
.doesNotExist('Allowed Operations section is hidden');
});
test('it should check all operations when allow all is selected', async function (assert) {
await this.renderComponent();
this.operationKeys.forEach((key) => {
assert.dom(GENERAL.inputByAttr(key)).isChecked(`${key} is checked when allow all is selected`);
});
});
test('it should trigger onCancel callback', async function (assert) {
await this.renderComponent();
await click(GENERAL.cancelButton);
assert.true(this.onCancel.calledOnce, 'onCancel callback is triggered on cancel');
});
module('Editing existing role', function () {
test('it should hide name field when editing', async function (assert) {
await this.renderComponent(false);
assert.dom(GENERAL.inputByAttr('name')).doesNotExist('Name field is hidden when editing');
});
test('it should populate fields when editing: operation_none', async function (assert) {
await this.renderComponent(false, 'none');
assert
.dom(GENERAL.inputByAttr('operation_none'))
.isNotChecked('Renders correct toggle state for operation_none');
assert
.dom('[data-test-kmip-section="Allowed Operations"]')
.doesNotExist('Allowed Operations section is hidden');
});
test('it should populate fields when editing: operation_all', async function (assert) {
await this.renderComponent(false, 'all');
assert.dom(GENERAL.inputByAttr('operation_none')).isChecked('Allow operations toggle is checked');
assert.dom(GENERAL.inputByAttr('operation_all')).isChecked('Allow all operations is checked');
this.operationKeys.forEach((key) => {
assert.dom(GENERAL.inputByAttr(key)).isChecked(`${key} is checked when allow all is selected`);
});
});
test('it should populate fields when editing: selected operations', async function (assert) {
await this.renderComponent(false);
this.operationKeys.forEach((key) => {
const domMethod = this.form.data[key] === true ? 'isChecked' : 'isNotChecked';
assert.dom(GENERAL.inputByAttr(key))[domMethod](`${key} ${domMethod} correctly`);
});
});
test('it should populate tls fields', async function (assert) {
// this is a bit wonky but when the value is set by the component it's a string
// but in order to populate it , it needs to be a number
this.tlsOptions.tls_client_ttl = 86400;
await this.renderComponent(false);
assert
.dom(GENERAL.inputByAttr('tls_client_key_bits'))
.hasValue('521', 'TLS field is populated correctly');
assert
.dom(GENERAL.inputByAttr('tls_client_key_type'))
.hasValue('ec', 'TLS field is populated correctly');
assert
.dom(GENERAL.ttl.input('TLS Client TTL'))
.hasValue('1', 'TLS TTL value field is populated correctly');
assert.dom(GENERAL.selectByAttr('ttl-unit')).hasValue('d', 'TLS TTL unit field is populated correctly');
});
test('it should save edited role', async function (assert) {
await this.renderComponent(false);
await click(GENERAL.inputByAttr('operation_none'));
await click(GENERAL.submitButton);
const payload = { ...this.tlsOptions, operation_none: true };
assert.true(this.apiStub.calledWith(this.roleName, this.scopeName, this.backend, payload));
assert.true(this.flashStub.calledWith(`Successfully saved role ${this.roleName}`));
assert.true(this.onSave.calledOnce, 'onSave callback is triggered on save');
});
});
module('Create new role', function () {
test('it should show validation error when name is not provided', async function (assert) {
await this.renderComponent();
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('name'))
.hasText('Name is required', 'Shows validation error for name field');
assert.dom(GENERAL.inlineError).hasText('There is an error with this form.');
});
test('it should create new role without operations', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'new-role');
await click(GENERAL.inputByAttr('operation_none'));
await click(GENERAL.submitButton);
const payload = { ...this.tlsOptions, operation_none: true };
assert.true(
this.apiStub.calledWith('new-role', this.scopeName, this.backend, payload),
'Role created with no operations'
);
assert.true(this.flashStub.calledWith('Successfully saved role new-role'));
assert.true(this.onSave.calledOnce, 'onSave callback is triggered on save');
});
test('it should create new role with all operations', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'new-role');
await click(GENERAL.submitButton);
const payload = { ...this.tlsOptions, operation_all: true };
assert.true(
this.apiStub.calledWith('new-role', this.scopeName, this.backend, payload),
'Role created with all operations'
);
assert.true(this.flashStub.calledWith('Successfully saved role new-role'));
assert.true(this.onSave.calledOnce, 'onSave callback is triggered on save');
});
test('it should create new role with selected operations', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'new-role');
await click(GENERAL.inputByAttr('operation_all'));
await click(GENERAL.inputByAttr('operation_decrypt'));
await click(GENERAL.inputByAttr('operation_create'));
await click(GENERAL.inputByAttr('operation_get_attributes'));
await click(GENERAL.submitButton);
const payload = {
...this.tlsOptions,
operation_decrypt: true,
operation_create: true,
operation_get_attributes: true,
};
assert.true(
this.apiStub.calledWith('new-role', this.scopeName, this.backend, payload),
'Role created with all operations'
);
assert.true(this.flashStub.calledWith('Successfully saved role new-role'));
assert.true(this.onSave.calledOnce, 'onSave callback is triggered on save');
});
});
});

View file

@ -11,7 +11,7 @@ export default create({
visit: visitable('/vault/secrets-engines/:backend/kmip/scopes/:scope/roles'),
visitDetail: visitable('/vault/secrets-engines/:backend/kmip/scopes/:scope/roles/:role'),
create: clickable('[data-test-role-create]'),
roleName: fillable('[data-test-input="role"]'),
roleName: fillable('[data-test-input="name"]'),
detailEditLink: clickable('[data-test-kmip-link-edit-role]'),
cancelLink: clickable('[data-test-edit-form-cancel]'),
});