From 8b300cf6eb706e22a7d14a4b49e2465bc56b5d36 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 20 Jan 2026 13:38:18 -0700 Subject: [PATCH] [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 --- ui/app/forms/secrets/kmip/role.ts | 46 ++++ ui/app/utils/constants/capabilities.ts | 1 + .../addon/components/header-credentials.hbs | 16 +- .../kmip/addon/components/kmip/role-form.hbs | 82 ------ .../kmip/addon/components/kmip/role-form.js | 107 -------- .../components/operation-field-display.hbs | 25 -- .../components/operation-field-display.js | 45 ---- ui/lib/kmip/addon/components/page/role.hbs | 61 +++++ ui/lib/kmip/addon/components/page/role.ts | 54 ++++ .../addon/components/page/scope/roles.hbs | 117 +++++++++ .../kmip/addon/components/page/scope/roles.ts | 62 +++++ ui/lib/kmip/addon/components/role-form.hbs | 111 ++++++++ ui/lib/kmip/addon/components/role-form.ts | 71 ++++++ ui/lib/kmip/addon/controllers/role.js | 26 -- ui/lib/kmip/addon/controllers/scope/roles.js | 15 -- ui/lib/kmip/addon/helpers/operation-groups.ts | 32 +++ ui/lib/kmip/addon/helpers/operation-label.ts | 11 + ui/lib/kmip/addon/routes/role.js | 31 --- ui/lib/kmip/addon/routes/role.ts | 40 +++ ui/lib/kmip/addon/routes/role/edit.js | 30 --- ui/lib/kmip/addon/routes/role/edit.ts | 30 +++ ui/lib/kmip/addon/routes/scope/roles.js | 42 ---- ui/lib/kmip/addon/routes/scope/roles.ts | 69 +++++ .../kmip/addon/routes/scope/roles/create.js | 32 --- .../kmip/addon/routes/scope/roles/create.ts | 17 ++ ui/lib/kmip/addon/templates/role.hbs | 35 +-- ui/lib/kmip/addon/templates/role/edit.hbs | 12 +- ui/lib/kmip/addon/templates/scope/roles.hbs | 119 +-------- .../addon/templates/scope/roles/create.hbs | 11 +- ui/tests/acceptance/enterprise-kmip-test.js | 119 +++++---- .../components/kmip/page/role-test.js | 180 +++++++++++++ .../components/kmip/page/scope/roles-test.js | 144 +++++++++++ .../components/kmip/role-form-test.js | 238 ++++++++++++++++++ ui/tests/pages/secrets/backend/kmip/roles.js | 2 +- 34 files changed, 1386 insertions(+), 647 deletions(-) create mode 100644 ui/app/forms/secrets/kmip/role.ts delete mode 100644 ui/lib/kmip/addon/components/kmip/role-form.hbs delete mode 100644 ui/lib/kmip/addon/components/kmip/role-form.js delete mode 100644 ui/lib/kmip/addon/components/operation-field-display.hbs delete mode 100644 ui/lib/kmip/addon/components/operation-field-display.js create mode 100644 ui/lib/kmip/addon/components/page/role.hbs create mode 100644 ui/lib/kmip/addon/components/page/role.ts create mode 100644 ui/lib/kmip/addon/components/page/scope/roles.hbs create mode 100644 ui/lib/kmip/addon/components/page/scope/roles.ts create mode 100644 ui/lib/kmip/addon/components/role-form.hbs create mode 100644 ui/lib/kmip/addon/components/role-form.ts delete mode 100644 ui/lib/kmip/addon/controllers/role.js delete mode 100644 ui/lib/kmip/addon/controllers/scope/roles.js create mode 100644 ui/lib/kmip/addon/helpers/operation-groups.ts create mode 100644 ui/lib/kmip/addon/helpers/operation-label.ts delete mode 100644 ui/lib/kmip/addon/routes/role.js create mode 100644 ui/lib/kmip/addon/routes/role.ts delete mode 100644 ui/lib/kmip/addon/routes/role/edit.js create mode 100644 ui/lib/kmip/addon/routes/role/edit.ts delete mode 100644 ui/lib/kmip/addon/routes/scope/roles.js create mode 100644 ui/lib/kmip/addon/routes/scope/roles.ts delete mode 100644 ui/lib/kmip/addon/routes/scope/roles/create.js create mode 100644 ui/lib/kmip/addon/routes/scope/roles/create.ts create mode 100644 ui/tests/integration/components/kmip/page/role-test.js create mode 100644 ui/tests/integration/components/kmip/page/scope/roles-test.js create mode 100644 ui/tests/integration/components/kmip/role-form-test.js diff --git a/ui/app/forms/secrets/kmip/role.ts b/ui/app/forms/secrets/kmip/role.ts new file mode 100644 index 0000000000..00ef00e037 --- /dev/null +++ b/ui/app/forms/secrets/kmip/role.ts @@ -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 { + constructor(...args: ConstructorParameters) { + 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); + } +} diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index 282118cffa..520970fc5e 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -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'}`, }; diff --git a/ui/lib/kmip/addon/components/header-credentials.hbs b/ui/lib/kmip/addon/components/header-credentials.hbs index e8762624cf..9acdb58dbd 100644 --- a/ui/lib/kmip/addon/components/header-credentials.hbs +++ b/ui/lib/kmip/addon/components/header-credentials.hbs @@ -12,12 +12,16 @@
\ No newline at end of file diff --git a/ui/lib/kmip/addon/components/kmip/role-form.hbs b/ui/lib/kmip/addon/components/kmip/role-form.hbs deleted file mode 100644 index 8f7c96fc14..0000000000 --- a/ui/lib/kmip/addon/components/kmip/role-form.hbs +++ /dev/null @@ -1,82 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
- -
- - {{#if @model.isNew}} - {{! Show role name only in create mode }} - - {{/if}} -
- - -
- {{#unless @model.operationNone}} - -

- Allowed Operations -

-
-
- -
-
- {{#each this.operationFormGroups as |group|}} -
-

{{group.name}}

- {{#each group.fields as |attr|}} - - {{/each}} -
- {{/each}} -
-
- {{/unless}} -
-

- TLS -

- {{#each this.tlsFormFields as |attr|}} - - {{/each}} -
- {{#each @model.fields as |attr|}} - - {{/each}} -
- -
- - - - -
- \ No newline at end of file diff --git a/ui/lib/kmip/addon/components/kmip/role-form.js b/ui/lib/kmip/addon/components/kmip/role-form.js deleted file mode 100644 index c4d76d4c64..0000000000 --- a/ui/lib/kmip/addon/components/kmip/role-form.js +++ /dev/null @@ -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(); - } -} diff --git a/ui/lib/kmip/addon/components/operation-field-display.hbs b/ui/lib/kmip/addon/components/operation-field-display.hbs deleted file mode 100644 index ac47815a7c..0000000000 --- a/ui/lib/kmip/addon/components/operation-field-display.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -{{#if this.model.operationAll}} - -{{/if}} -{{#each @model.operationFormFields as |group|}} - {{#each-in group as |groupName fieldsInGroup|}} - -
- {{#each fieldsInGroup as |field|}} - - {{field.options.label}} -
- {{/each}} -
-
- {{/each-in}} -{{/each}} \ No newline at end of file diff --git a/ui/lib/kmip/addon/components/operation-field-display.js b/ui/lib/kmip/addon/components/operation-field-display.js deleted file mode 100644 index c941f8696f..0000000000 --- a/ui/lib/kmip/addon/components/operation-field-display.js +++ /dev/null @@ -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 - * - * ``` - * - * @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'); - }, - }, -}); diff --git a/ui/lib/kmip/addon/components/page/role.hbs b/ui/lib/kmip/addon/components/page/role.hbs new file mode 100644 index 0000000000..c7b210bdb4 --- /dev/null +++ b/ui/lib/kmip/addon/components/page/role.hbs @@ -0,0 +1,61 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + {{#if @capabilities.canDelete}} + +
+ {{/if}} + + {{#if @capabilities.canUpdate}} + + Edit role + + {{/if}} +
+
+ +
+
+

TLS

+ + + +
+ +
+

Allowed operations

+ {{#if @role.operation_all}} + + {{/if}} + + {{! the operation-groups helper gets all the available operation keys and formats them into groups }} + {{#each-in (operation-groups) as |groupName fields|}} + +
+ {{#each fields as |field|}} + {{#let (this.iconState field) as |icon|}} +
+ + {{! operation-label helper formats the key into a human-readable label }} + {{operation-label field}} +
+
+ {{/let}} + {{/each}} +
+
+ {{/each-in}} +
+
\ No newline at end of file diff --git a/ui/lib/kmip/addon/components/page/role.ts b/ui/lib/kmip/addon/components/page/role.ts new file mode 100644 index 0000000000..96128142c0 --- /dev/null +++ b/ui/lib/kmip/addon/components/page/role.ts @@ -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 { + @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}`); + } + } +} diff --git a/ui/lib/kmip/addon/components/page/scope/roles.hbs b/ui/lib/kmip/addon/components/page/scope/roles.hbs new file mode 100644 index 0000000000..093f32c893 --- /dev/null +++ b/ui/lib/kmip/addon/components/page/scope/roles.hbs @@ -0,0 +1,117 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:breadcrumbs> + + + + + + {{#if @roles.meta.total}} + + + + {{/if}} + + + Create role + + + + +{{#if @roles}} +
+ {{#each @roles as |role|}} +
+ + + {{role}} + + + + + + View credentials + + + View role + + {{#if + (has-capability + @capabilities + "update" + pathKey="kmipRole" + params=(hash backend=this.secretMountPath.currentPath scope=@scope name=role) + ) + }} + + Edit role + + {{/if}} + {{#if + (has-capability + @capabilities + "delete" + pathKey="kmipRole" + params=(hash backend=this.secretMountPath.currentPath scope=@scope name=role) + ) + }} + + Delete role + + {{/if}} + + + {{#if (eq this.roleToDelete role)}} + + {{/if}} + + +
+ {{/each}} + + +
+{{else}} + {{#if @filterValue}} + + {{else}} + + + + {{/if}} +{{/if}} \ No newline at end of file diff --git a/ui/lib/kmip/addon/components/page/scope/roles.ts b/ui/lib/kmip/addon/components/page/scope/roles.ts new file mode 100644 index 0000000000..29d50b7627 --- /dev/null +++ b/ui/lib/kmip/addon/components/page/scope/roles.ts @@ -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 { + @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}`); + } + } +} diff --git a/ui/lib/kmip/addon/components/role-form.hbs b/ui/lib/kmip/addon/components/role-form.hbs new file mode 100644 index 0000000000..e7c89f2330 --- /dev/null +++ b/ui/lib/kmip/addon/components/role-form.hbs @@ -0,0 +1,111 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +
+
+ + + + {{#if @form.isNew}} + {{! only show name field for new roles }} +
+ + Name + {{#if this.validationError}} + Name is required + {{/if}} + +
+ {{/if}} + +
+ + Allow this role to perform KMIP operations + +
+ + {{#unless @form.data.operation_none}} + +

+ Allowed Operations +

+
+ +
+ +
+ +
+ {{! the operation-groups helper gets all the available operation keys and formats them into groups }} + {{#each-in (operation-groups) as |groupName fields|}} +
+

+ {{groupName}} +

+ + {{#each fields as |fieldKey|}} + {{#let (@form.fieldFor fieldKey) as |field|}} +
+ {{#let (get @form.data fieldKey) as |value|}} + + {{field.options.label}} + + {{/let}} +
+ {{/let}} + {{/each}} +
+ {{/each-in}} +
+
+ {{/unless}} + +
+

+ TLS +

+ {{#each @form.tlsFields as |field|}} + + {{/each}} +
+ {{#each @form.otherFields as |field|}} + + {{/each}} +
+ + + <:error> + {{#if this.invalidFormAlert}} + + {{/if}} + + +
\ No newline at end of file diff --git a/ui/lib/kmip/addon/components/role-form.ts b/ui/lib/kmip/addon/components/role-form.ts new file mode 100644 index 0000000000..0725f256eb --- /dev/null +++ b/ui/lib/kmip/addon/components/role-form.ts @@ -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 { + @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) { + const { checked } = event.target; + const { data } = this.args.form; + data.operation_none = !checked; + data.operation_all = checked; + } + + save = task( + waitFor(async (event: HTMLElementEvent) => { + 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.'; + } + }) + ); +} diff --git a/ui/lib/kmip/addon/controllers/role.js b/ui/lib/kmip/addon/controllers/role.js deleted file mode 100644 index 1e1587b1c9..0000000000 --- a/ui/lib/kmip/addon/controllers/role.js +++ /dev/null @@ -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(); - } - } -} diff --git a/ui/lib/kmip/addon/controllers/scope/roles.js b/ui/lib/kmip/addon/controllers/scope/roles.js deleted file mode 100644 index e3c9fc7f0b..0000000000 --- a/ui/lib/kmip/addon/controllers/scope/roles.js +++ /dev/null @@ -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; - }), -}); diff --git a/ui/lib/kmip/addon/helpers/operation-groups.ts b/ui/lib/kmip/addon/helpers/operation-groups.ts new file mode 100644 index 0000000000..98d501bb0e --- /dev/null +++ b/ui/lib/kmip/addon/helpers/operation-groups.ts @@ -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, + }; +} diff --git a/ui/lib/kmip/addon/helpers/operation-label.ts b/ui/lib/kmip/addon/helpers/operation-label.ts new file mode 100644 index 0000000000..4edd54b68d --- /dev/null +++ b/ui/lib/kmip/addon/helpers/operation-label.ts @@ -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(' '); +} diff --git a/ui/lib/kmip/addon/routes/role.js b/ui/lib/kmip/addon/routes/role.js deleted file mode 100644 index eb8e5a1be4..0000000000 --- a/ui/lib/kmip/addon/routes/role.js +++ /dev/null @@ -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 }); - } -} diff --git a/ui/lib/kmip/addon/routes/role.ts b/ui/lib/kmip/addon/routes/role.ts new file mode 100644 index 0000000000..bdaa254fd4 --- /dev/null +++ b/ui/lib/kmip/addon/routes/role.ts @@ -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, + }; + } +} diff --git a/ui/lib/kmip/addon/routes/role/edit.js b/ui/lib/kmip/addon/routes/role/edit.js deleted file mode 100644 index 714bea8068..0000000000 --- a/ui/lib/kmip/addon/routes/role/edit.js +++ /dev/null @@ -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 }); - }, -}); diff --git a/ui/lib/kmip/addon/routes/role/edit.ts b/ui/lib/kmip/addon/routes/role/edit.ts new file mode 100644 index 0000000000..d622044fbe --- /dev/null +++ b/ui/lib/kmip/addon/routes/role/edit.ts @@ -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, + }; + } +} diff --git a/ui/lib/kmip/addon/routes/scope/roles.js b/ui/lib/kmip/addon/routes/scope/roles.js deleted file mode 100644 index 34ccfdcf93..0000000000 --- a/ui/lib/kmip/addon/routes/scope/roles.js +++ /dev/null @@ -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()); - }, -}); diff --git a/ui/lib/kmip/addon/routes/scope/roles.ts b/ui/lib/kmip/addon/routes/scope/roles.ts new file mode 100644 index 0000000000..b83b6a7ec1 --- /dev/null +++ b/ui/lib/kmip/addon/routes/scope/roles.ts @@ -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); + } + } +} diff --git a/ui/lib/kmip/addon/routes/scope/roles/create.js b/ui/lib/kmip/addon/routes/scope/roles/create.js deleted file mode 100644 index df702b9b8d..0000000000 --- a/ui/lib/kmip/addon/routes/scope/roles/create.js +++ /dev/null @@ -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()); - }, -}); diff --git a/ui/lib/kmip/addon/routes/scope/roles/create.ts b/ui/lib/kmip/addon/routes/scope/roles/create.ts new file mode 100644 index 0000000000..24fde1f5c8 --- /dev/null +++ b/ui/lib/kmip/addon/routes/scope/roles/create.ts @@ -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 }), + }; + } +} diff --git a/ui/lib/kmip/addon/templates/role.hbs b/ui/lib/kmip/addon/templates/role.hbs index c64d978048..5dd628821f 100644 --- a/ui/lib/kmip/addon/templates/role.hbs +++ b/ui/lib/kmip/addon/templates/role.hbs @@ -3,32 +3,9 @@ SPDX-License-Identifier: BUSL-1.1 }} - - - - {{#if this.model.updatePath.canUpdate}} - -
- {{/if}} - {{#if this.model.updatePath.canUpdate}} - - Edit role - - {{/if}} -
-
-
- -
-

- Allowed operations -

- -
-
\ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/kmip/addon/templates/role/edit.hbs b/ui/lib/kmip/addon/templates/role/edit.hbs index 25c0f50c72..98e220d09c 100644 --- a/ui/lib/kmip/addon/templates/role/edit.hbs +++ b/ui/lib/kmip/addon/templates/role/edit.hbs @@ -5,12 +5,14 @@ <:breadcrumbs> - + - \ No newline at end of file diff --git a/ui/lib/kmip/addon/templates/scope/roles.hbs b/ui/lib/kmip/addon/templates/scope/roles.hbs index cf862d4b08..01f6ed0df0 100644 --- a/ui/lib/kmip/addon/templates/scope/roles.hbs +++ b/ui/lib/kmip/addon/templates/scope/roles.hbs @@ -3,116 +3,9 @@ SPDX-License-Identifier: BUSL-1.1 }} - - <:breadcrumbs> - - - - - - {{#if this.model.meta.total}} - - - {{#if this.filterFocused}} - {{#if this.filterMatchesKey}} -

- ENTER - to go to - {{this.filter}} - roles -

- {{/if}} - {{#if this.firstPartialMatch}} -

- TAB - to complete - {{this.firstPartialMatch.id}} -

- {{/if}} - {{/if}} -
- {{/if}} - - - Create role - - -
- - {{#if list.empty}} - - - - {{else if list.item}} - - - {{list.item.id}} - - - - - View credentials - View role - {{#if list.item.updatePath.isPending}} - - - - {{else}} - {{#if list.item.updatePath.canUpdate}} - Edit role - {{/if}} - {{#if list.item.updatePath.canDelete}} - Delete role - {{/if}} - {{/if}} - - {{#if (eq this.roleToDelete list.item)}} - - {{/if}} - - - {{else}} - - - There are no roles that match - {{this.filter}}, press - ENTER - to add one. - - - {{/if}} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/kmip/addon/templates/scope/roles/create.hbs b/ui/lib/kmip/addon/templates/scope/roles/create.hbs index fb03a9abf6..df4273c511 100644 --- a/ui/lib/kmip/addon/templates/scope/roles/create.hbs +++ b/ui/lib/kmip/addon/templates/scope/roles/create.hbs @@ -5,12 +5,13 @@ <:breadcrumbs> - + - \ No newline at end of file diff --git a/ui/tests/acceptance/enterprise-kmip-test.js b/ui/tests/acceptance/enterprise-kmip-test.js index efabff9e3f..8a0637e0e8 100644 --- a/ui/tests/acceptance/enterprise-kmip-test.js +++ b/ui/tests/acceptance/enterprise-kmip-test.js @@ -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'); }); }); }); diff --git a/ui/tests/integration/components/kmip/page/role-test.js b/ui/tests/integration/components/kmip/page/role-test.js new file mode 100644 index 0000000000..a1bc009a17 --- /dev/null +++ b/ui/tests/integration/components/kmip/page/role-test.js @@ -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``, + { 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); + }); + }); +}); diff --git a/ui/tests/integration/components/kmip/page/scope/roles-test.js b/ui/tests/integration/components/kmip/page/scope/roles-test.js new file mode 100644 index 0000000000..a601ae5fc9 --- /dev/null +++ b/ui/tests/integration/components/kmip/page/scope/roles-test.js @@ -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``, + { 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('1–2 of 2', 'Renders correct pagination info'); + assert.dom(GENERAL.paginationSizeSelector).doesNotExist('Pagination size selector does not render'); + }); +}); diff --git a/ui/tests/integration/components/kmip/role-form-test.js b/ui/tests/integration/components/kmip/role-form-test.js new file mode 100644 index 0000000000..71b3add69f --- /dev/null +++ b/ui/tests/integration/components/kmip/role-form-test.js @@ -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``, + { 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'); + }); + }); +}); diff --git a/ui/tests/pages/secrets/backend/kmip/roles.js b/ui/tests/pages/secrets/backend/kmip/roles.js index 6a1762cdd0..3ae8d83c9e 100644 --- a/ui/tests/pages/secrets/backend/kmip/roles.js +++ b/ui/tests/pages/secrets/backend/kmip/roles.js @@ -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]'), });