mirror of
https://github.com/hashicorp/vault.git
synced 2026-06-09 08:55:13 -04:00
Backport [UI] Ember Data Migration - Transform Role and Transformation views | VAULT-45708 | VAULT-45709 into ce/main (#15234)
* no-op commit * migrates transform role and transformation views * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fixed failing tests and updated query selectors --------- Co-authored-by: Mohit Ojha <mohit.ojha@hashicorp.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
parent
c0d95b0acf
commit
1779d0b264
11 changed files with 1033 additions and 407 deletions
|
|
@ -5,36 +5,30 @@
|
|||
|
||||
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
|
||||
<:breadcrumbs>
|
||||
<KeyValueHeader
|
||||
@baseKey={{hash display=this.model.id id=this.model.idForNav}}
|
||||
@path="vault.cluster.secrets.backend.list"
|
||||
@mode={{this.mode}}
|
||||
@root={{this.breadcrumbs}}
|
||||
@showCurrent={{true}}
|
||||
/>
|
||||
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
|
||||
</:breadcrumbs>
|
||||
</Page::Header>
|
||||
|
||||
{{#if (eq this.mode "show")}}
|
||||
{{#if (eq @mode "show")}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if this.model.updatePath.canDelete}}
|
||||
<ConfirmAction
|
||||
@buttonText="Delete role"
|
||||
{{#if @capabilities.canDelete}}
|
||||
<Hds::Button
|
||||
@text="Delete role"
|
||||
@color="secondary"
|
||||
class="toolbar-button"
|
||||
@buttonColor="secondary"
|
||||
@onConfirmAction={{action "delete"}}
|
||||
@confirmMessage="Deleting this role means that you’ll need to recreate it and reassign any existing transformations to use it again."
|
||||
data-test-transformation-role-delete
|
||||
data-test-delete
|
||||
{{on "click" this.onDelete}}
|
||||
/>
|
||||
<div class="toolbar-separator"></div>
|
||||
{{/if}}
|
||||
{{#if this.model.updatePath.canUpdate}}
|
||||
{{#if @capabilities.canUpdate}}
|
||||
<ToolbarSecretLink
|
||||
@secret={{concat this.model.idPrefix this.model.id}}
|
||||
@secret={{concat "role/" @form.data.name}}
|
||||
@backend={{@form.data.backend}}
|
||||
@mode="edit"
|
||||
data-test-edit-link={{true}}
|
||||
@replace={{true}}
|
||||
data-test-edit-link
|
||||
>
|
||||
Edit role
|
||||
</ToolbarSecretLink>
|
||||
|
|
@ -43,28 +37,52 @@
|
|||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if (or (eq this.mode "edit") (eq this.mode "create"))}}
|
||||
<form onsubmit={{action "createOrUpdate" this.mode}}>
|
||||
{{#if (or (eq @mode "edit") (eq @mode "create"))}}
|
||||
<form {{on "submit" this.createOrUpdate}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @model={{this.model}} />
|
||||
<NamespaceReminder @mode={{this.mode}} @noun="Transform role" />
|
||||
{{#each this.model.attrs as |attr|}}
|
||||
{{#if (and (eq this.mode "edit") attr.options.readOnly)}}
|
||||
<ReadonlyFormField @attr={{attr}} @value={{get this.model attr.name}} />
|
||||
<NamespaceReminder @mode={{@mode}} @noun="transform role" />
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
{{#each @form.formFields as |field|}}
|
||||
{{#if (eq field.name "transformations")}}
|
||||
<div class="form-section">
|
||||
<SearchSelect
|
||||
@id="transformations"
|
||||
@label={{field.options.label}}
|
||||
@labelClass="title is-4"
|
||||
@subText={{field.options.subText}}
|
||||
@options={{this.transformations}}
|
||||
@inputValue={{@form.data.transformations}}
|
||||
@onChange={{fn (mut @form.transformations)}}
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="string-list"
|
||||
data-test-field
|
||||
/>
|
||||
{{#if (and this.modelValidations.transformations (not this.modelValidations.transformations.isValid))}}
|
||||
<Hds::Form::Error data-test-validation-error="transformations">
|
||||
{{this.modelValidations.transformations.errors}}
|
||||
</Hds::Form::Error>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
<FormField data-test-field @attr={{attr}} @model={{this.model}} />
|
||||
<FormField
|
||||
data-test-field
|
||||
@attr={{field}}
|
||||
@model={{@form}}
|
||||
@mode={{@mode}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="field is-grouped-split box is-fullwidth is-bottomless">
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button @text={{if (eq this.mode "create") "Create role" "Save"}} type="submit" data-test-submit />
|
||||
{{#if (eq this.mode "create")}}
|
||||
<Hds::Button @text={{if (eq @mode "create") "Create role" "Save"}} type="submit" data-test-submit />
|
||||
{{#if (eq @mode "create")}}
|
||||
<Hds::Button
|
||||
@text="Cancel"
|
||||
@color="secondary"
|
||||
@route="vault.cluster.secrets.backend.list-root"
|
||||
@model={{this.model.backend}}
|
||||
@model={{@form.data.backend}}
|
||||
@query={{hash tab="role"}}
|
||||
/>
|
||||
{{else}}
|
||||
|
|
@ -72,8 +90,7 @@
|
|||
@text="Cancel"
|
||||
@color="secondary"
|
||||
@route="vault.cluster.secrets.backend.show"
|
||||
@models={{array this.model.backend (concat "role/" this.model.id)}}
|
||||
@query={{hash tab="role"}}
|
||||
@models={{array @form.data.backend (concat "role/" @form.data.name)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</Hds::ButtonSet>
|
||||
|
|
@ -81,25 +98,13 @@
|
|||
</form>
|
||||
{{else}}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each this.model.attrs as |attr|}}
|
||||
{{#if (eq attr.type "object")}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{stringify (get this.model attr.name)}}
|
||||
/>
|
||||
{{else if (eq attr.type "array")}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get this.model attr.name}}
|
||||
@type={{attr.type}}
|
||||
@isLink={{eq attr.name "transformations"}}
|
||||
/>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get this.model attr.name}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#each @form.formFields as |field|}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
|
||||
@value={{get @form.data field.name}}
|
||||
@type={{field.type}}
|
||||
@isLink={{eq field.name "transformations"}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
@ -3,152 +3,224 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import TransformBase, { addToList, removeFromList } from './transform-edit-base';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { computed } from '@ember/object';
|
||||
import { SecretsApiTransformListTransformationsListEnum } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
export default TransformBase.extend({
|
||||
flashMessages: service(),
|
||||
store: service(),
|
||||
initialTransformations: null,
|
||||
/**
|
||||
* @module TransformRoleEdit
|
||||
* `TransformRoleEdit` is a component that allows you to create/edit or view a transform role.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />
|
||||
* ```
|
||||
* @param {object} form - RoleForm instance with data and formFields.
|
||||
* @param {object} capabilities - Object with canDelete, canUpdate, canRead capabilities.
|
||||
* @param {string} mode - Is either show, create or edit.
|
||||
*/
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.set('initialTransformations', this.model.transformations);
|
||||
},
|
||||
export default class TransformRoleEditComponent extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service api;
|
||||
|
||||
breadcrumbs: computed('root', 'title', function () {
|
||||
@tracked errorMessage = '';
|
||||
@tracked modelValidations;
|
||||
@tracked transformations = [];
|
||||
|
||||
// Non-tracked: used only to diff added/removed transformations after save
|
||||
initialTransformations = [];
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.initialTransformations = [...(this.args.form.data.transformations ?? [])];
|
||||
this.fetchTransformations();
|
||||
}
|
||||
|
||||
async fetchTransformations() {
|
||||
try {
|
||||
const resp = await this.api.secrets.transformListTransformations(
|
||||
this.args.form.data.backend,
|
||||
SecretsApiTransformListTransformationsListEnum.TRUE
|
||||
);
|
||||
this.transformations = (resp.keys ?? []).map((key) => ({ id: key }));
|
||||
} catch {
|
||||
// swallow errors, SearchSelect will fall back to string-list
|
||||
}
|
||||
}
|
||||
|
||||
get breadcrumbs() {
|
||||
const backend = this.args.form?.data?.backend;
|
||||
const name = this.args.form?.data?.name;
|
||||
return [
|
||||
{ label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' },
|
||||
{ text: 'Secrets engines', path: 'vault.cluster.secrets.backends' },
|
||||
this.root,
|
||||
{ label: this.title, text: this.title },
|
||||
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
|
||||
{ label: 'Secrets engines', route: 'vault.cluster.secrets.backends' },
|
||||
{
|
||||
label: backend,
|
||||
route: 'vault.cluster.secrets.backend.list-root',
|
||||
model: backend,
|
||||
query: { tab: 'role' },
|
||||
},
|
||||
{ label: this.title },
|
||||
{ label: this.args.mode === 'create' ? 'role' : name },
|
||||
];
|
||||
}),
|
||||
}
|
||||
|
||||
title: computed('mode', function () {
|
||||
if (this.mode === 'create') {
|
||||
get title() {
|
||||
if (this.args.mode === 'create') {
|
||||
return 'Create role';
|
||||
} else if (this.mode === 'edit') {
|
||||
} else if (this.args.mode === 'edit') {
|
||||
return 'Edit role';
|
||||
} else {
|
||||
return 'Role';
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
subtitle: computed('mode', 'model.id', function () {
|
||||
if (this.mode === 'create' || this.mode === 'edit') return;
|
||||
get subtitle() {
|
||||
if (this.args.mode === 'show') {
|
||||
return this.args.form?.data?.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.model.id;
|
||||
}),
|
||||
// Reads a transformation, updates its allowed_roles (add/remove this role), then saves.
|
||||
async syncTransformationForRole(transformationName, roleName, backend, syncAction) {
|
||||
let currentAllowedRoles;
|
||||
try {
|
||||
const resp = await this.api.secrets.transformReadTransformation(transformationName, backend);
|
||||
const data = resp?.data || resp || {};
|
||||
currentAllowedRoles = data.allowed_roles || [];
|
||||
} catch {
|
||||
// If the transformation can't be read, skip it
|
||||
return { transformationName, syncAction, errorStatus: null, skipped: true };
|
||||
}
|
||||
|
||||
handleUpdateTransformations(updateTransformations, roleId, type = 'update') {
|
||||
if (!updateTransformations) return;
|
||||
const backend = this.model.backend;
|
||||
const promises = updateTransformations.map((transform) => {
|
||||
return this.store
|
||||
.queryRecord('transform', {
|
||||
backend,
|
||||
id: transform.id,
|
||||
})
|
||||
.then(function (transformation) {
|
||||
let roles = transformation.allowed_roles;
|
||||
if (transform.action === 'ADD') {
|
||||
roles = addToList(roles, roleId);
|
||||
} else if (transform.action === 'REMOVE') {
|
||||
roles = removeFromList(roles, roleId);
|
||||
}
|
||||
let updatedRoles;
|
||||
if (syncAction === 'ADD') {
|
||||
updatedRoles = currentAllowedRoles.includes(roleName)
|
||||
? currentAllowedRoles
|
||||
: [...currentAllowedRoles, roleName];
|
||||
} else {
|
||||
updatedRoles = currentAllowedRoles.filter((r) => r !== roleName);
|
||||
}
|
||||
|
||||
transformation.setProperties({
|
||||
backend,
|
||||
allowed_roles: roles,
|
||||
});
|
||||
|
||||
return transformation.save().catch((e) => {
|
||||
return { errorStatus: e.httpStatus, ...transform };
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises).then((res) => {
|
||||
const hasError = res.find((r) => !!r.errorStatus);
|
||||
if (hasError) {
|
||||
const errorAdding = res.find((r) => r.errorStatus === 403 && r.action === 'ADD');
|
||||
const errorRemoving = res.find((r) => r.errorStatus === 403 && r.action === 'REMOVE');
|
||||
|
||||
let message =
|
||||
'The edits to this role were successful, but allowed_roles for its transformations was not edited due to a lack of permissions.';
|
||||
if (type === 'create') {
|
||||
message =
|
||||
'Transformations have been attached to this role, but the role was not added to those transformations’ allowed_roles due to a lack of permissions.';
|
||||
} else if (errorAdding && errorRemoving) {
|
||||
message =
|
||||
'This role was edited to both add and remove transformations; however, this role was not added or removed from those transformations’ allowed_roles due to a lack of permissions.';
|
||||
} else if (errorAdding) {
|
||||
message =
|
||||
'This role was edited to include new transformations, but this role was not added to those transformations’ allowed_roles due to a lack of permissions.';
|
||||
} else if (errorRemoving) {
|
||||
message =
|
||||
'This role was edited to remove transformations, but this role was not removed from those transformations’ allowed_roles due to a lack of permissions.';
|
||||
}
|
||||
this.flashMessages.info(message, {
|
||||
sticky: true,
|
||||
priority: 300,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
createOrUpdate(type, event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.applyChanges('save', () => {
|
||||
const roleId = this.model.id;
|
||||
const newModelTransformations = this.model.transformations;
|
||||
|
||||
if (!this.initialTransformations) {
|
||||
this.handleUpdateTransformations(
|
||||
newModelTransformations.map((t) => ({
|
||||
id: t,
|
||||
action: 'ADD',
|
||||
})),
|
||||
roleId,
|
||||
type
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTransformations = [...newModelTransformations, ...this.initialTransformations]
|
||||
.map((t) => {
|
||||
if (this.initialTransformations.indexOf(t) < 0) {
|
||||
return {
|
||||
id: t,
|
||||
action: 'ADD',
|
||||
};
|
||||
}
|
||||
if (newModelTransformations.indexOf(t) < 0) {
|
||||
return {
|
||||
id: t,
|
||||
action: 'REMOVE',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((t) => !!t);
|
||||
this.handleUpdateTransformations(updateTransformations, roleId);
|
||||
try {
|
||||
await this.api.secrets.transformWriteTransformation(transformationName, backend, {
|
||||
allowed_roles: updatedRoles,
|
||||
});
|
||||
},
|
||||
return { transformationName, syncAction, errorStatus: null };
|
||||
} catch (writeErr) {
|
||||
const { status } = await this.api.parseError(writeErr);
|
||||
return { transformationName, syncAction, errorStatus: status };
|
||||
}
|
||||
}
|
||||
|
||||
delete() {
|
||||
const roleId = this.model?.id;
|
||||
const roleTransformations = this.model?.transformations || [];
|
||||
const updateTransformations = roleTransformations.map((t) => ({
|
||||
id: t,
|
||||
action: 'REMOVE',
|
||||
}));
|
||||
this.handleUpdateTransformations(updateTransformations, roleId);
|
||||
this.applyDelete();
|
||||
},
|
||||
},
|
||||
});
|
||||
// Diffs current vs initial transformations, syncs allowed_roles on each
|
||||
// affected transformation, then shows a single contextual flash if any failed.
|
||||
async handleTransformationSync(roleName, backend, type = 'update') {
|
||||
const currentTransformations = this.args.form.data.transformations ?? [];
|
||||
const initialTransformations = this.initialTransformations;
|
||||
|
||||
let syncOps;
|
||||
if (type === 'create') {
|
||||
syncOps = currentTransformations.map((t) => ({ id: t, syncAction: 'ADD' }));
|
||||
} else {
|
||||
const added = currentTransformations.filter((t) => !initialTransformations.includes(t));
|
||||
const removed = initialTransformations.filter((t) => !currentTransformations.includes(t));
|
||||
syncOps = [
|
||||
...added.map((t) => ({ id: t, syncAction: 'ADD' })),
|
||||
...removed.map((t) => ({ id: t, syncAction: 'REMOVE' })),
|
||||
];
|
||||
}
|
||||
|
||||
if (syncOps.length === 0) return;
|
||||
|
||||
const results = await Promise.all(
|
||||
syncOps.map(({ id, syncAction }) => this.syncTransformationForRole(id, roleName, backend, syncAction))
|
||||
);
|
||||
|
||||
const errors = results.filter((r) => r.errorStatus === 403);
|
||||
if (errors.length === 0) return;
|
||||
|
||||
const errorAdding = errors.some((r) => r.syncAction === 'ADD');
|
||||
const errorRemoving = errors.some((r) => r.syncAction === 'REMOVE');
|
||||
|
||||
let message;
|
||||
if (type === 'create') {
|
||||
message =
|
||||
'Transformations have been attached to this role, but the role was not added to those transformations\u2019 allowed_roles due to a lack of permissions.';
|
||||
} else if (errorAdding && errorRemoving) {
|
||||
message =
|
||||
'This role was edited to both add and remove transformations; however, this role was not added or removed from those transformations\u2019 allowed_roles due to a lack of permissions.';
|
||||
} else if (errorAdding) {
|
||||
message =
|
||||
'This role was edited to include new transformations, but this role was not added to those transformations\u2019 allowed_roles due to a lack of permissions.';
|
||||
} else {
|
||||
message =
|
||||
'This role was edited to remove transformations, but this role was not removed from those transformations\u2019 allowed_roles due to a lack of permissions.';
|
||||
}
|
||||
|
||||
this.flashMessages.info(message, { sticky: true, priority: 300 });
|
||||
}
|
||||
|
||||
// Removes this role from all of its transformations' allowed_roles on delete.
|
||||
async cleanupTransformationsOnDelete(roleName, backend) {
|
||||
const transformations = this.args.form.data.transformations ?? [];
|
||||
if (transformations.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
transformations.map((t) => this.syncTransformationForRole(t, roleName, backend, 'REMOVE'))
|
||||
);
|
||||
}
|
||||
|
||||
transition(route = 'show') {
|
||||
this.errorMessage = '';
|
||||
this.modelValidations = null;
|
||||
const { backend, name } = this.args.form.data;
|
||||
if (route === 'list') {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend, {
|
||||
queryParams: { tab: 'role' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.show', `role/${name}`);
|
||||
}
|
||||
|
||||
@action async createOrUpdate(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.errorMessage = invalidFormMessage;
|
||||
if (!isValid) return;
|
||||
|
||||
const { name, transformations, backend } = data;
|
||||
|
||||
const isCreate = this.args.mode === 'create';
|
||||
try {
|
||||
await this.api.secrets.transformWriteRole(name, backend, { transformations });
|
||||
this.flashMessages.success('Role saved.');
|
||||
await this.handleTransformationSync(name, backend, isCreate ? 'create' : 'update');
|
||||
this.transition();
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.errorMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
@action async onDelete() {
|
||||
const { name, backend } = this.args.form.data;
|
||||
try {
|
||||
await this.api.secrets.transformDeleteRole(name, backend);
|
||||
this.flashMessages.success('Role deleted.');
|
||||
await this.cleanupTransformationsOnDelete(name, backend);
|
||||
this.transition('list');
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
{{!
|
||||
Copyright IBM Corp. 2016, 2025
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
Copyright IBM Corp. 2016, 2026
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
|
||||
<:breadcrumbs>
|
||||
<KeyValueHeader
|
||||
@baseKey={{this.model}}
|
||||
@path="vault.cluster.secrets.backend.list"
|
||||
@mode={{this.mode}}
|
||||
@root={{this.breadcrumbs}}
|
||||
@showCurrent={{true}}
|
||||
/>
|
||||
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
|
||||
</:breadcrumbs>
|
||||
</Page::Header>
|
||||
|
||||
{{#if (eq this.mode "show")}}
|
||||
{{#if (eq @mode "show")}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if this.model.updatePath.canDelete}}
|
||||
{{#if (gt this.model.allowed_roles.length 0)}}
|
||||
{{#if @capabilities.canDelete}}
|
||||
{{#if (gt @form.data.allowed_roles.length 0)}}
|
||||
<Hds::TooltipButton @text="This transformation is in use by a role and can't be deleted.">
|
||||
<Hds::Button
|
||||
@text="Delete transformation"
|
||||
|
|
@ -34,22 +28,29 @@ SPDX-License-Identifier: BUSL-1.1
|
|||
@text="Delete transformation"
|
||||
@color="secondary"
|
||||
class="toolbar-button"
|
||||
{{on "click" (action (mut this.isDeleteModalActive) true)}}
|
||||
data-test-delete
|
||||
{{on "click" (fn (mut this.isDeleteModalActive) true)}}
|
||||
/>
|
||||
{{/if}}
|
||||
<div class="toolbar-separator"></div>
|
||||
{{/if}}
|
||||
{{#if this.model.updatePath.canUpdate}}
|
||||
{{#if (gt this.model.allowed_roles.length 0)}}
|
||||
{{#if @capabilities.canUpdate}}
|
||||
{{#if (gt @form.data.allowed_roles.length 0)}}
|
||||
<Hds::Button
|
||||
@text="Edit transformation"
|
||||
@color="secondary"
|
||||
class="toolbar-button"
|
||||
{{on "click" (fn (mut this.isEditModalActive) true)}}
|
||||
data-test-edit-link
|
||||
{{on "click" (fn (mut this.isEditModalActive) true)}}
|
||||
/>
|
||||
{{else}}
|
||||
<ToolbarSecretLink @secret={{this.model.id}} @mode="edit" data-test-edit-link={{true}} @replace={{true}}>
|
||||
<ToolbarSecretLink
|
||||
@secret={{@form.data.name}}
|
||||
@backend={{@form.data.backend}}
|
||||
@mode="edit"
|
||||
@replace={{true}}
|
||||
data-test-edit-link
|
||||
>
|
||||
Edit transformation
|
||||
</ToolbarSecretLink>
|
||||
{{/if}}
|
||||
|
|
@ -58,29 +59,162 @@ SPDX-License-Identifier: BUSL-1.1
|
|||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.mode "edit")}}
|
||||
<TransformEditForm @mode={{this.mode}} @model={{this.model}} />
|
||||
{{else if (eq this.mode "create")}}
|
||||
<TransformCreateForm @mode={{this.mode}} @model={{this.model}} />
|
||||
{{#if (or (eq @mode "edit") (eq @mode "create"))}}
|
||||
<form {{on "submit" this.createOrUpdate}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<NamespaceReminder @mode={{@mode}} @noun="transformation" />
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
{{#each this.visibleFormFields as |field|}}
|
||||
{{#if (eq field.name "template")}}
|
||||
<div class="form-section">
|
||||
<SearchSelect
|
||||
@id="template"
|
||||
@label={{field.options.label}}
|
||||
@labelClass="title is-4"
|
||||
@subText={{field.options.subText}}
|
||||
@options={{this.templates}}
|
||||
@inputValue={{@form.data.template}}
|
||||
@onChange={{fn (mut @form.template)}}
|
||||
@selectLimit={{1}}
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="string-list"
|
||||
data-test-field
|
||||
/>
|
||||
{{#if (and this.modelValidations.template (not this.modelValidations.template.isValid))}}
|
||||
<Hds::Form::Error data-test-validation-error="template">
|
||||
{{this.modelValidations.template.errors}}
|
||||
</Hds::Form::Error>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else if (eq field.name "allowed_roles")}}
|
||||
<div class="form-section">
|
||||
<SearchSelect
|
||||
@id="allowed_roles"
|
||||
@label={{field.options.label}}
|
||||
@labelClass="title is-4"
|
||||
@subText={{field.options.subText}}
|
||||
@options={{this.roles}}
|
||||
@inputValue={{@form.data.allowed_roles}}
|
||||
@onChange={{fn (mut @form.allowed_roles)}}
|
||||
@fallbackComponent="string-list"
|
||||
@wildcardLabel="role"
|
||||
data-test-field
|
||||
/>
|
||||
</div>
|
||||
{{else}}
|
||||
<FormField
|
||||
data-test-field
|
||||
@attr={{field}}
|
||||
@model={{@form}}
|
||||
@mode={{@mode}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="field is-grouped-split box is-fullwidth is-bottomless">
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button @text={{if (eq @mode "create") "Create transformation" "Save"}} type="submit" data-test-submit />
|
||||
{{#if (eq @mode "create")}}
|
||||
<Hds::Button
|
||||
@text="Cancel"
|
||||
@color="secondary"
|
||||
@route="vault.cluster.secrets.backend.list-root"
|
||||
@model={{@form.data.backend}}
|
||||
@query={{hash tab="transformations"}}
|
||||
/>
|
||||
{{else}}
|
||||
<Hds::Button
|
||||
@text="Cancel"
|
||||
@color="secondary"
|
||||
@route="vault.cluster.secrets.backend.show"
|
||||
@models={{array @form.data.backend @form.data.name}}
|
||||
/>
|
||||
{{/if}}
|
||||
</Hds::ButtonSet>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<TransformShowTransformation @model={{this.model}} @transformRoles={{this.transformRoles}} />
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each this.visibleFormFields as |field|}}
|
||||
{{#if (eq field.name "allowed_roles")}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
|
||||
@value={{get @form.data field.name}}
|
||||
@type="array"
|
||||
@isLink={{true}}
|
||||
@queryParam="role"
|
||||
@wildcardLabel="role"
|
||||
/>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
|
||||
@value={{get @form.data field.name}}
|
||||
@type={{field.type}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="has-top-margin-xl has-bottom-margin-s">
|
||||
<label class="title has-border-bottom-light page-header">CLI Commands</label>
|
||||
<div class="has-bottom-margin-s">
|
||||
<h2 class="title is-6">Encode</h2>
|
||||
<div class="has-bottom-margin-s">
|
||||
<span class="helper-text has-text-grey">
|
||||
To test the encoding capability of your transformation, use the following command. It will output an encoded_value.
|
||||
</span>
|
||||
</div>
|
||||
<div class="copy-text level">
|
||||
{{#let (concat "vault write " @form.data.backend "/encode/" this.cliCommand) as |copyEncodeCommand|}}
|
||||
<Hds::CodeBlock
|
||||
@language="bash"
|
||||
@hasLineNumbers={{false}}
|
||||
@hasLineWrapping={{true}}
|
||||
@hasCopyButton={{true}}
|
||||
@value={{copyEncodeCommand}}
|
||||
/>
|
||||
{{/let}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="title is-6">Decode</h2>
|
||||
<div class="has-bottom-margin-s">
|
||||
<span class="helper-text has-text-grey">
|
||||
To test decoding capability of your transformation, use the encoded_value in the following command. It should
|
||||
return your original input.
|
||||
</span>
|
||||
</div>
|
||||
<div class="copy-text level">
|
||||
{{#let (concat "vault write " @form.data.backend "/decode/" this.cliCommand) as |copyDecodeCommand|}}
|
||||
<Hds::CodeBlock
|
||||
@language="bash"
|
||||
@hasLineNumbers={{false}}
|
||||
@hasLineWrapping={{true}}
|
||||
@hasCopyButton={{true}}
|
||||
@value={{copyDecodeCommand}}
|
||||
/>
|
||||
{{/let}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<ConfirmationModal
|
||||
@title="Delete transformation"
|
||||
@onClose={{action (mut this.isDeleteModalActive) false}}
|
||||
@onClose={{fn (mut this.isDeleteModalActive) false}}
|
||||
@isActive={{this.isDeleteModalActive}}
|
||||
@confirmText={{this.model.name}}
|
||||
@confirmText={{@form.data.name}}
|
||||
@toConfirmMsg="deleting the transformation."
|
||||
@onConfirm={{action "delete"}}
|
||||
@onConfirm={{this.onDelete}}
|
||||
>
|
||||
<p class="has-bottom-margin-m">
|
||||
Deleting the
|
||||
<strong>{{this.model.name}}</strong>
|
||||
<strong>{{@form.data.name}}</strong>
|
||||
transformation means that the underlying keys are lost and the data encoded by the transformation are unrecoverable and
|
||||
cannot be decoded.
|
||||
</p>
|
||||
<MessageError @model={{this.model}} @errorMessage={{this.error}} />
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
</ConfirmationModal>
|
||||
|
||||
{{#if this.isEditModalActive}}
|
||||
|
|
@ -96,12 +230,7 @@ SPDX-License-Identifier: BUSL-1.1
|
|||
</M.Body>
|
||||
<M.Footer as |F|>
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button
|
||||
@text="Confirm"
|
||||
@route="vault.cluster.secrets.backend.edit"
|
||||
@model={{this.model.id}}
|
||||
data-test-edit-confirm-button
|
||||
/>
|
||||
<Hds::Button @text="Confirm" data-test-edit-confirm-button {{on "click" this.confirmEdit}} />
|
||||
<Hds::Button @color="secondary" @text="Cancel" {{on "click" F.close}} />
|
||||
</Hds::ButtonSet>
|
||||
</M.Footer>
|
||||
|
|
|
|||
|
|
@ -3,156 +3,301 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import TransformBase, { addToList, removeFromList } from './transform-edit-base';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { computed } from '@ember/object';
|
||||
import {
|
||||
SecretsApiTransformListRolesListEnum,
|
||||
SecretsApiTransformListTemplatesListEnum,
|
||||
} from '@hashicorp/vault-client-typescript';
|
||||
|
||||
export default TransformBase.extend({
|
||||
flashMessages: service(),
|
||||
store: service(),
|
||||
initialRoles: null,
|
||||
/**
|
||||
* @module TransformationEdit
|
||||
* `TransformationEdit` is a component that allows you to create/edit or view a transformation.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />
|
||||
* ```
|
||||
* @param {object} form - TransformationForm instance with data and formFields.
|
||||
* @param {object} capabilities - Object with canDelete, canUpdate, canRead capabilities.
|
||||
* @param {string} mode - Is either show, create or edit.
|
||||
*/
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
if (!this.model) return;
|
||||
this.set('initialRoles', this.model.allowed_roles);
|
||||
},
|
||||
export default class TransformationEditComponent extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service api;
|
||||
|
||||
breadcrumbs: computed('root', 'title', function () {
|
||||
@tracked errorMessage = '';
|
||||
@tracked modelValidations;
|
||||
@tracked roles = [];
|
||||
@tracked templates = [];
|
||||
@tracked isDeleteModalActive = false;
|
||||
@tracked isEditModalActive = false;
|
||||
|
||||
// Non-tracked: used only to diff added/removed roles after save
|
||||
initialAllowedRoles = [];
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.initialAllowedRoles = [...(this.args.form.data.allowed_roles ?? [])];
|
||||
this.fetchRoles();
|
||||
this.fetchTemplates();
|
||||
}
|
||||
|
||||
get visibleFormFields() {
|
||||
const type = this.args.form.data.type;
|
||||
return this.args.form.formFields.filter((field) => {
|
||||
switch (field.name) {
|
||||
case 'tweak_source':
|
||||
return type === 'fpe';
|
||||
case 'masking_character':
|
||||
return type === 'masking';
|
||||
case 'template':
|
||||
return type !== 'tokenization';
|
||||
case 'mapping_mode':
|
||||
case 'convergent':
|
||||
case 'max_ttl':
|
||||
case 'stores':
|
||||
return type === 'tokenization';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetchRoles() {
|
||||
try {
|
||||
const resp = await this.api.secrets.transformListRoles(
|
||||
this.args.form.data.backend,
|
||||
SecretsApiTransformListRolesListEnum.TRUE
|
||||
);
|
||||
this.roles = (resp.keys ?? []).map((key) => ({ id: key }));
|
||||
} catch {
|
||||
// swallow errors, SearchSelect will fall back to string-list
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTemplates() {
|
||||
try {
|
||||
const resp = await this.api.secrets.transformListTemplates(
|
||||
this.args.form.data.backend,
|
||||
SecretsApiTransformListTemplatesListEnum.TRUE
|
||||
);
|
||||
this.templates = (resp.keys ?? []).map((key) => ({ id: key }));
|
||||
} catch {
|
||||
// swallow errors, SearchSelect will fall back to string-list
|
||||
}
|
||||
}
|
||||
|
||||
get breadcrumbs() {
|
||||
const backend = this.args.form?.data?.backend;
|
||||
const name = this.args.form?.data?.name;
|
||||
return [
|
||||
{ label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' },
|
||||
{ text: 'Secrets engines', path: 'vault.cluster.secrets.backends' },
|
||||
this.root,
|
||||
{ label: this.title, text: this.title },
|
||||
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
|
||||
{ label: 'Secrets engines', route: 'vault.cluster.secrets.backends' },
|
||||
{
|
||||
label: backend,
|
||||
route: 'vault.cluster.secrets.backend.list-root',
|
||||
model: backend,
|
||||
},
|
||||
{ label: this.title },
|
||||
{ label: this.args.mode === 'create' ? 'transformation' : name },
|
||||
];
|
||||
}),
|
||||
}
|
||||
|
||||
title: computed('mode', function () {
|
||||
if (this.mode === 'create') {
|
||||
return 'Create Transformation';
|
||||
} else if (this.mode === 'edit') {
|
||||
return 'Edit Transformation';
|
||||
get title() {
|
||||
if (this.args.mode === 'create') {
|
||||
return 'Create transformation';
|
||||
} else if (this.args.mode === 'edit') {
|
||||
return 'Edit transformation';
|
||||
} else {
|
||||
return 'Transformation';
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
subtitle: computed('mode', 'model.id', function () {
|
||||
if (this.mode === 'create' || this.mode === 'edit') return;
|
||||
|
||||
return this.model.id;
|
||||
}),
|
||||
|
||||
async updateOrCreateRole(role, transformationId, backend) {
|
||||
const roleRecord = await this.store
|
||||
.queryRecord('transform/role', {
|
||||
backend,
|
||||
id: role.id,
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.httpStatus !== 403 && role.action === 'ADD') {
|
||||
// If role doesn't yet exist, create it with this transformation attached
|
||||
var newRole = this.store.createRecord('transform/role', {
|
||||
id: role.id,
|
||||
name: role.id,
|
||||
transformations: [transformationId],
|
||||
backend,
|
||||
});
|
||||
return newRole.save().catch((e) => {
|
||||
return {
|
||||
errorStatus: e.httpStatus,
|
||||
...role,
|
||||
action: 'CREATE',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
errorStatus: e.httpStatus,
|
||||
};
|
||||
});
|
||||
// if an error occurs while querying the role, exit function and return the error
|
||||
if (roleRecord.errorStatus) return roleRecord;
|
||||
// otherwise update the role with the transformation and save
|
||||
let transformations = roleRecord.transformations;
|
||||
if (role.action === 'ADD') {
|
||||
transformations = addToList(transformations, transformationId);
|
||||
} else if (role.action === 'REMOVE') {
|
||||
transformations = removeFromList(transformations, transformationId);
|
||||
get subtitle() {
|
||||
if (this.args.mode === 'show') {
|
||||
return this.args.form?.data?.name;
|
||||
}
|
||||
roleRecord.setProperties({
|
||||
backend,
|
||||
transformations,
|
||||
});
|
||||
return roleRecord.save().catch((e) => {
|
||||
return {
|
||||
errorStatus: e.httpStatus,
|
||||
...role,
|
||||
};
|
||||
});
|
||||
},
|
||||
return '';
|
||||
}
|
||||
|
||||
handleUpdateRoles(updateRoles, transformationId) {
|
||||
if (!updateRoles) return;
|
||||
const { backend } = this.model;
|
||||
updateRoles.forEach(async (record) => {
|
||||
// For each role that needs to be updated, update the role with the transformation.
|
||||
const updateOrCreateResponse = await this.updateOrCreateRole(record, transformationId, backend);
|
||||
// If an error was returned, check error type and show a message.
|
||||
const errorStatus = updateOrCreateResponse?.errorStatus;
|
||||
let message;
|
||||
if (errorStatus == 403) {
|
||||
message = `The edits to this transformation were successful, but transformations for the role ${record.id} were not edited due to a lack of permissions.`;
|
||||
} else if (errorStatus) {
|
||||
message = `You've edited the allowed_roles for this transformation. However, there was a problem updating the role: ${record.id}.`;
|
||||
get cliCommand() {
|
||||
const { type, allowed_roles, tweak_source, name } = this.args.form?.data ?? {};
|
||||
if (!name) return '';
|
||||
|
||||
const rolesArr = allowed_roles ?? [];
|
||||
const wildCardRole = rolesArr.find((role) => role.includes('*'));
|
||||
let role = '<choose a role>';
|
||||
if (rolesArr.length === 1 && !wildCardRole) {
|
||||
role = rolesArr[0];
|
||||
}
|
||||
|
||||
let tweak = '';
|
||||
if (type === 'fpe' && tweak_source === 'supplied') {
|
||||
tweak = 'tweak=<enter your tweak>';
|
||||
}
|
||||
|
||||
return `${role} value=<enter your value here> ${tweak} transformation=${name}`;
|
||||
}
|
||||
|
||||
isWildcard(roleName) {
|
||||
return typeof roleName === 'string' && roleName.includes('*');
|
||||
}
|
||||
|
||||
async syncRoleForTransformation(roleName, transformationName, backend, syncAction) {
|
||||
if (this.isWildcard(roleName)) return;
|
||||
|
||||
let currentTransformations;
|
||||
try {
|
||||
const resp = await this.api.secrets.transformReadRole(roleName, backend);
|
||||
const data = resp?.data || resp || {};
|
||||
currentTransformations = data.transformations || [];
|
||||
} catch (readErr) {
|
||||
const { status } = await this.api.parseError(readErr);
|
||||
if (status === 403) {
|
||||
this.flashMessages.info(
|
||||
`The transformation was saved, but the role "${roleName}" could not be updated due to a lack of permissions.`,
|
||||
{ sticky: true, priority: 300 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.flashMessages.info(message, {
|
||||
sticky: true,
|
||||
priority: 300,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
isWildcard(role) {
|
||||
if (typeof role === 'string') {
|
||||
return role.indexOf('*') >= 0;
|
||||
// Role not found (404) or other non-403 error
|
||||
if (syncAction === 'ADD') {
|
||||
// Auto-create the role with this transformation
|
||||
try {
|
||||
await this.api.secrets.transformWriteRole(roleName, backend, {
|
||||
transformations: [transformationName],
|
||||
});
|
||||
} catch (createErr) {
|
||||
const { message } = await this.api.parseError(createErr);
|
||||
this.flashMessages.info(
|
||||
`The transformation was saved, but the role "${roleName}" could not be created: ${message}`,
|
||||
{ sticky: true, priority: 300 }
|
||||
);
|
||||
}
|
||||
}
|
||||
// For REMOVE: role doesn't exist, nothing to do
|
||||
return;
|
||||
}
|
||||
if (role && role.id) {
|
||||
return role.id.indexOf('*') >= 0;
|
||||
|
||||
let updatedTransformations;
|
||||
if (syncAction === 'ADD') {
|
||||
updatedTransformations = currentTransformations.includes(transformationName)
|
||||
? currentTransformations
|
||||
: [...currentTransformations, transformationName];
|
||||
} else {
|
||||
updatedTransformations = currentTransformations.filter((t) => t !== transformationName);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
actions: {
|
||||
createOrUpdate(type, event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.applyChanges('save', () => {
|
||||
const transformationId = this.model.id || this.model.name;
|
||||
const newModelRoles = this.model.allowed_roles || [];
|
||||
const initialRoles = this.initialRoles || [];
|
||||
|
||||
const updateRoles = [...newModelRoles, ...initialRoles]
|
||||
.filter((r) => !this.isWildcard(r)) // CBS TODO: expand wildcards into included roles instead
|
||||
.map((role) => {
|
||||
if (initialRoles.indexOf(role) < 0) {
|
||||
return {
|
||||
id: role,
|
||||
action: 'ADD',
|
||||
};
|
||||
}
|
||||
if (newModelRoles.indexOf(role) < 0) {
|
||||
return {
|
||||
id: role,
|
||||
action: 'REMOVE',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((r) => !!r);
|
||||
this.handleUpdateRoles(updateRoles, transformationId);
|
||||
try {
|
||||
await this.api.secrets.transformWriteRole(roleName, backend, {
|
||||
transformations: updatedTransformations,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (writeErr) {
|
||||
const { status, message } = await this.api.parseError(writeErr);
|
||||
const detail = status === 403 ? `due to a lack of permissions` : message;
|
||||
this.flashMessages.info(
|
||||
`The transformation was saved, but the role "${roleName}" could not be updated: ${detail}`,
|
||||
{ sticky: true, priority: 300 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Diffs current vs initial allowed_roles and syncs each changed role in parallel.
|
||||
async handleRoleSync(transformationName, backend) {
|
||||
const currentRoles = this.args.form.data.allowed_roles ?? [];
|
||||
const initialRoles = this.initialAllowedRoles;
|
||||
|
||||
const addedRoles = currentRoles.filter((r) => !this.isWildcard(r) && !initialRoles.includes(r));
|
||||
const removedRoles = initialRoles.filter((r) => !this.isWildcard(r) && !currentRoles.includes(r));
|
||||
|
||||
await Promise.all([
|
||||
...addedRoles.map((r) => this.syncRoleForTransformation(r, transformationName, backend, 'ADD')),
|
||||
...removedRoles.map((r) => this.syncRoleForTransformation(r, transformationName, backend, 'REMOVE')),
|
||||
]);
|
||||
}
|
||||
|
||||
transition(route = 'show') {
|
||||
this.errorMessage = '';
|
||||
this.modelValidations = null;
|
||||
const { name } = this.args.form.data;
|
||||
if (route === 'list') {
|
||||
const { backend } = this.args.form.data;
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend, {
|
||||
queryParams: { tab: 'transformations' },
|
||||
});
|
||||
} else if (route === 'edit') {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.edit', name);
|
||||
} else {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.show', name);
|
||||
}
|
||||
}
|
||||
|
||||
@action async createOrUpdate(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.errorMessage = invalidFormMessage;
|
||||
if (!isValid) return;
|
||||
|
||||
const {
|
||||
name,
|
||||
backend,
|
||||
type,
|
||||
tweak_source,
|
||||
masking_character,
|
||||
template,
|
||||
allowed_roles,
|
||||
deletion_allowed,
|
||||
mapping_mode,
|
||||
convergent,
|
||||
max_ttl,
|
||||
stores,
|
||||
} = data;
|
||||
|
||||
const templateValue = Array.isArray(template) ? template[0] : template;
|
||||
|
||||
try {
|
||||
await this.api.secrets.transformWriteTransformation(name, backend, {
|
||||
type,
|
||||
tweak_source,
|
||||
masking_character,
|
||||
template: templateValue,
|
||||
allowed_roles,
|
||||
deletion_allowed,
|
||||
mapping_mode,
|
||||
convergent,
|
||||
max_ttl,
|
||||
stores,
|
||||
});
|
||||
this.flashMessages.success('Transformation saved.');
|
||||
await this.handleRoleSync(name, backend);
|
||||
this.transition();
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.errorMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
@action async onDelete() {
|
||||
const { name, backend } = this.args.form.data;
|
||||
try {
|
||||
await this.api.secrets.transformDeleteTransformation(name, backend);
|
||||
this.flashMessages.success('Transformation deleted.');
|
||||
this.transition('list');
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
|
||||
@action confirmEdit() {
|
||||
this.isEditModalActive = false;
|
||||
this.transition('edit');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
ui/app/forms/transform/role.ts
Normal file
35
ui/app/forms/transform/role.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Form from 'vault/forms/form';
|
||||
import FormField from 'vault/utils/forms/field';
|
||||
import type { Validations } from 'vault/app-types';
|
||||
|
||||
type RoleData = {
|
||||
name?: string;
|
||||
transformations?: string[];
|
||||
backend?: string;
|
||||
};
|
||||
|
||||
export default class RoleForm extends Form<RoleData> {
|
||||
idPrefix = 'role/';
|
||||
|
||||
formFields = [
|
||||
new FormField('name', 'string', {
|
||||
editDisabled: true,
|
||||
subText: 'The name for your role. This cannot be edited later.',
|
||||
}),
|
||||
new FormField('transformations', 'array', {
|
||||
isSectionHeader: true,
|
||||
label: 'Transformations',
|
||||
subText: 'Select which transformations this role will have access to. It must already exist.',
|
||||
}),
|
||||
];
|
||||
|
||||
validations: Validations = {
|
||||
name: [{ type: 'presence', message: 'Name is required.' }],
|
||||
transformations: [{ type: 'presence', message: 'At least one transformation is required.' }],
|
||||
};
|
||||
}
|
||||
108
ui/app/forms/transform/transformation.ts
Normal file
108
ui/app/forms/transform/transformation.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Form from 'vault/forms/form';
|
||||
import FormField from 'vault/utils/forms/field';
|
||||
import type { Validations } from 'vault/app-types';
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'fpe', displayName: 'Format Preserving Encryption (FPE)' },
|
||||
{ value: 'masking', displayName: 'Masking' },
|
||||
{ value: 'tokenization', displayName: 'Tokenization' },
|
||||
];
|
||||
|
||||
const TWEAK_SOURCE = [
|
||||
{ value: 'supplied', displayName: 'supplied' },
|
||||
{ value: 'generated', displayName: 'generated' },
|
||||
{ value: 'internal', displayName: 'internal' },
|
||||
];
|
||||
|
||||
type TransformationData = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
tweak_source?: string;
|
||||
masking_character?: string;
|
||||
template?: string[];
|
||||
allowed_roles?: string[];
|
||||
deletion_allowed?: boolean;
|
||||
convergent?: boolean;
|
||||
stores?: string[];
|
||||
mapping_mode?: string;
|
||||
max_ttl?: string;
|
||||
backend?: string;
|
||||
};
|
||||
|
||||
export default class TransformationForm extends Form<TransformationData> {
|
||||
formFields = [
|
||||
new FormField('name', 'string', {
|
||||
editDisabled: true,
|
||||
subText: 'The name for your transformation. This cannot be edited later.',
|
||||
}),
|
||||
new FormField('type', 'string', {
|
||||
editDisabled: true,
|
||||
possibleValues: TYPES,
|
||||
defaultValue: 'fpe',
|
||||
label: 'Type',
|
||||
subText:
|
||||
'Vault provides two types of transformations: Format Preserving Encryption (FPE) is reversible, while Masking is not. This cannot be edited later.',
|
||||
}),
|
||||
new FormField('deletion_allowed', 'boolean', {
|
||||
label: 'Allow deletion',
|
||||
subText:
|
||||
'If checked, this transform can be deleted otherwise deletion is blocked. Note that deleting the transform deletes the underlying key which makes decoding of tokenized values impossible without restoring from a backup.',
|
||||
}),
|
||||
new FormField('tweak_source', 'string', {
|
||||
possibleValues: TWEAK_SOURCE,
|
||||
defaultValue: 'supplied',
|
||||
label: 'Tweak source',
|
||||
subText:
|
||||
'A tweak value is used when performing FPE transformations. This can be supplied, generated, or internal.',
|
||||
}),
|
||||
new FormField('masking_character', 'string', {
|
||||
defaultValue: '*',
|
||||
label: 'Masking character',
|
||||
subText: 'Specify which character you\u2019d like to mask your data.',
|
||||
}),
|
||||
new FormField('template', 'array', {
|
||||
isSectionHeader: true,
|
||||
label: 'Template',
|
||||
subText:
|
||||
'Templates allow Vault to determine what and how to capture the value to be transformed. Type to use an existing template or create a new one.',
|
||||
}),
|
||||
new FormField('allowed_roles', 'array', {
|
||||
isSectionHeader: true,
|
||||
label: 'Allowed roles',
|
||||
subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).',
|
||||
}),
|
||||
new FormField('mapping_mode', 'string', {
|
||||
defaultValue: 'default',
|
||||
label: 'Mapping mode',
|
||||
subText:
|
||||
'Specifies the mapping mode for stored tokenization values. "default" is strongly recommended for highest security, "exportable" allows for all plaintexts to be decoded via the export-decoded endpoint in an emergency.',
|
||||
}),
|
||||
new FormField('convergent', 'boolean', {
|
||||
label: 'Use convergent tokenization',
|
||||
subText:
|
||||
'If checked, tokenization of the same plaintext more than once results in the same token. Defaults to false as unique tokens are more desirable from a security standpoint.',
|
||||
}),
|
||||
new FormField('max_ttl', 'string', {
|
||||
editType: 'ttl',
|
||||
defaultValue: '0',
|
||||
label: 'Maximum TTL (time-to-live) of a token',
|
||||
subText: 'If \u201c0\u201d or unspecified, tokens may have no expiration.',
|
||||
}),
|
||||
new FormField('stores', 'array', {
|
||||
editType: 'stringArray',
|
||||
label: 'Stores',
|
||||
subText:
|
||||
'The list of tokenization stores to use for tokenization state. Vault\u2019s internal storage is used by default.',
|
||||
}),
|
||||
];
|
||||
|
||||
validations: Validations = {
|
||||
name: [{ type: 'presence', message: 'Name is required.' }],
|
||||
template: [{ type: 'presence', message: 'Template is required.' }],
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import TotpKeyForm from 'vault/forms/totp/key';
|
|||
import SshRoleForm from 'vault/forms/ssh/role';
|
||||
import AlphabetForm from 'vault/forms/transform/alphabet';
|
||||
import TemplateForm from 'vault/forms/transform/template';
|
||||
import RoleForm from 'vault/forms/transform/role';
|
||||
import TransformationForm from 'vault/forms/transform/transformation';
|
||||
import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
const secretModel = (store, backend, key) => {
|
||||
|
|
@ -21,13 +23,6 @@ const secretModel = (store, backend, key) => {
|
|||
return model;
|
||||
};
|
||||
|
||||
const transformModel = (queryParams) => {
|
||||
const modelType = 'transform';
|
||||
if (!queryParams || !queryParams.itemType) return modelType;
|
||||
|
||||
return `${modelType}/${queryParams.itemType}`;
|
||||
};
|
||||
|
||||
export default EditBase.extend({
|
||||
store: service(),
|
||||
|
||||
|
|
@ -82,9 +77,11 @@ export default EditBase.extend({
|
|||
if (modelType === 'transform/template') {
|
||||
return new TemplateForm({ backend }, { isNew: true });
|
||||
}
|
||||
// TODO: Remove once all transform sub-types (template, role, transformation) are migrated to Form classes.
|
||||
if (modelType === 'transform/role') {
|
||||
return new RoleForm({ backend }, { isNew: true });
|
||||
}
|
||||
if (modelType === 'transform') {
|
||||
modelType = transformModel(transition.to.queryParams);
|
||||
return new TransformationForm({ backend, type: 'fpe' }, { isNew: true });
|
||||
}
|
||||
if (modelType === 'database/connection' && transition.to?.queryParams?.itemType === 'role') {
|
||||
modelType = 'database/role';
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import TotpKeyForm from 'vault/forms/totp/key';
|
|||
import SshRoleForm from 'vault/forms/ssh/role';
|
||||
import AlphabetForm from 'vault/forms/transform/alphabet';
|
||||
import TemplateForm from 'vault/forms/transform/template';
|
||||
import RoleForm from 'vault/forms/transform/role';
|
||||
import TransformationForm from 'vault/forms/transform/transformation';
|
||||
import Form from 'vault/forms/form';
|
||||
import {
|
||||
SecretsApiKeyManagementListKmsProvidersForKeyListEnum,
|
||||
|
|
@ -426,6 +428,62 @@ export default Route.extend({
|
|||
};
|
||||
},
|
||||
|
||||
async fetchTransformRole(backend, name) {
|
||||
const resp = await this.api.secrets.transformReadRole(name, backend);
|
||||
const data = resp.data || {};
|
||||
return new RoleForm({ name, backend, transformations: data.transformations || [] }, { isNew: false });
|
||||
},
|
||||
|
||||
async fetchTransformRoleCapabilities(backend, name) {
|
||||
const rolePath = this.capabilitiesService.pathFor('transformRole', { backend, name });
|
||||
const rolesPath = this.capabilitiesService.pathFor('transformRoles', { backend });
|
||||
|
||||
const capabilities = await this.capabilitiesService.fetch([rolePath, rolesPath]);
|
||||
|
||||
return {
|
||||
canDelete: capabilities[rolePath]?.canDelete,
|
||||
canUpdate: capabilities[rolePath]?.canUpdate,
|
||||
canRead: capabilities[rolePath]?.canRead,
|
||||
canList: capabilities[rolesPath]?.canList,
|
||||
};
|
||||
},
|
||||
|
||||
async fetchTransformTransformation(backend, name) {
|
||||
const resp = await this.api.secrets.transformReadTransformation(name, backend);
|
||||
const data = resp.data || resp || {};
|
||||
return new TransformationForm(
|
||||
{
|
||||
name,
|
||||
backend,
|
||||
type: data.type || 'fpe',
|
||||
tweak_source: data.tweak_source,
|
||||
masking_character: data.masking_character,
|
||||
template: data.template ? [data.template] : [],
|
||||
allowed_roles: data.allowed_roles || [],
|
||||
deletion_allowed: data.deletion_allowed,
|
||||
mapping_mode: data.mapping_mode,
|
||||
convergent: data.convergent,
|
||||
max_ttl: data.max_ttl,
|
||||
stores: data.stores || [],
|
||||
},
|
||||
{ isNew: false }
|
||||
);
|
||||
},
|
||||
|
||||
async fetchTransformTransformationCapabilities(backend, name) {
|
||||
const transformationPath = this.capabilitiesService.pathFor('transformTransformation', { backend, name });
|
||||
const transformationsPath = this.capabilitiesService.pathFor('transformTransformations', { backend });
|
||||
|
||||
const capabilities = await this.capabilitiesService.fetch([transformationPath, transformationsPath]);
|
||||
|
||||
return {
|
||||
canDelete: capabilities[transformationPath]?.canDelete,
|
||||
canUpdate: capabilities[transformationPath]?.canUpdate,
|
||||
canRead: capabilities[transformationPath]?.canRead,
|
||||
canList: capabilities[transformationsPath]?.canList,
|
||||
};
|
||||
},
|
||||
|
||||
async handleSecretModelError(capabilitiesPromise, secretId, modelType, error) {
|
||||
// capabilities is a promise proxy, not a real object
|
||||
// to work around this we explicitly assign it to a const and await it
|
||||
|
|
@ -462,7 +520,6 @@ export default Route.extend({
|
|||
secret = secret.replace('role/', '');
|
||||
}
|
||||
let secretModel;
|
||||
let transformRoles;
|
||||
let capabilities;
|
||||
|
||||
if (modelType === 'totp-key') {
|
||||
|
|
@ -483,6 +540,12 @@ export default Route.extend({
|
|||
} else if (modelType === 'transform/template') {
|
||||
secretModel = await this.fetchTransformTemplate(backend, secret);
|
||||
capabilities = await this.fetchTransformTemplateCapabilities(backend, secret);
|
||||
} else if (modelType === 'transform/role') {
|
||||
secretModel = await this.fetchTransformRole(backend, secret);
|
||||
capabilities = await this.fetchTransformRoleCapabilities(backend, secret);
|
||||
} else if (modelType === 'transform') {
|
||||
secretModel = await this.fetchTransformTransformation(backend, secret);
|
||||
capabilities = await this.fetchTransformTransformationCapabilities(backend, secret);
|
||||
} else {
|
||||
capabilities = await this.capabilities(secret, modelType);
|
||||
try {
|
||||
|
|
@ -498,15 +561,9 @@ export default Route.extend({
|
|||
}
|
||||
}
|
||||
|
||||
// fetch roles for transform type to display in detail view
|
||||
if (modelType === 'transform') {
|
||||
transformRoles = await this.fetchTransformRoles(backend);
|
||||
}
|
||||
|
||||
return {
|
||||
secret: secretModel,
|
||||
capabilities,
|
||||
transformRoles,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -537,7 +594,6 @@ export default Route.extend({
|
|||
backend,
|
||||
preferAdvancedEdit,
|
||||
backendType,
|
||||
transformRoles: model.transformRoles,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -92,11 +92,11 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
);
|
||||
assert.ok(GENERAL.emptyStateTitle, 'renders empty state');
|
||||
assert
|
||||
.dom('.active[data-test-secret-list-tab="Transformations"]')
|
||||
.dom(`.active${GENERAL.secretTab('Transformations')}`)
|
||||
.exists('Has Transformations tab which is active');
|
||||
assert.dom('[data-test-secret-list-tab="Roles"]').exists('Has Roles tab');
|
||||
assert.dom('[data-test-secret-list-tab="Templates"]').exists('Has Templates tab');
|
||||
assert.dom('[data-test-secret-list-tab="Alphabets"]').exists('Has Alphabets tab');
|
||||
assert.dom(GENERAL.secretTab('Roles')).exists('Has Roles tab');
|
||||
assert.dom(GENERAL.secretTab('Templates')).exists('Has Templates tab');
|
||||
assert.dom(GENERAL.secretTab('Alphabets')).exists('Has Alphabets tab');
|
||||
});
|
||||
|
||||
test('it can create a transformation and add itself to the role attached', async function (assert) {
|
||||
|
|
@ -142,7 +142,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
`/vault/secrets-engines/${backend}/show/${transformationName}`,
|
||||
'redirects to show transformation page after submit'
|
||||
);
|
||||
await click(`[data-test-secret-breadcrumb="${backend}"] a`);
|
||||
await click(GENERAL.breadcrumbLink(backend));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets-engines/${backend}/list`,
|
||||
|
|
@ -157,13 +157,13 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
await mountBackend('transform', backend);
|
||||
// create transformation without role
|
||||
await newTransformation(backend, 'a-transformation', true);
|
||||
await click(`[data-test-secret-breadcrumb="${backend}"] a`);
|
||||
await click(GENERAL.breadcrumbLink(backend));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets-engines/${backend}/list`,
|
||||
'Links back to list view from breadcrumb'
|
||||
);
|
||||
await click('[data-test-secret-list-tab="Roles"]');
|
||||
await click(GENERAL.secretTab('Roles'));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets-engines/${backend}/list?tab=role`,
|
||||
|
|
@ -186,7 +186,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
`/vault/secrets-engines/${backend}/show/role/${roleName}`,
|
||||
'redirects to show role page after submit'
|
||||
);
|
||||
await click(`[data-test-secret-breadcrumb="${backend}"] a`);
|
||||
await click(GENERAL.breadcrumbLink(backend));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets-engines/${backend}/list?tab=role`,
|
||||
|
|
@ -203,7 +203,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
await newRole(backend, roleName);
|
||||
await transformationsPage.visitShow({ backend, id: transformation });
|
||||
await settled();
|
||||
assert.dom('[data-test-row-value="Allowed roles"]').hasText(roleName);
|
||||
assert.dom(GENERAL.infoRowValue('Allowed roles')).hasText(roleName);
|
||||
});
|
||||
|
||||
test('it shows a message if an update fails after save', async function (assert) {
|
||||
|
|
@ -217,7 +217,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
await newRole(backend, roleName);
|
||||
await settled();
|
||||
await transformationsPage.visitShow({ backend, id: transformation });
|
||||
assert.dom('[data-test-row-value="Allowed roles"]').hasText(roleName);
|
||||
assert.dom(GENERAL.infoRowValue('Allowed roles')).hasText(roleName);
|
||||
// Edit transformation
|
||||
await click('[data-test-edit-link]');
|
||||
assert.dom('#transformation-edit-modal').exists('Confirmation modal appears');
|
||||
|
|
@ -230,17 +230,17 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
);
|
||||
// remove role
|
||||
await settled();
|
||||
await click('#allowed_roles [data-test-selected-list-button="delete"]');
|
||||
await click(`#allowed_roles ${GENERAL.searchSelect.removeSelected}`);
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
assert.dom('.flash-message.is-info').exists('Shows info message since role could not be updated');
|
||||
assert.dom(GENERAL.flashMessage).exists('Shows info message since role could not be updated');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets-engines/${backend}/show/${transformation}`,
|
||||
'Correctly links to show page for secret'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-row-value="Allowed roles"]')
|
||||
.dom(GENERAL.infoRowValue('Allowed roles'))
|
||||
.doesNotExist('Allowed roles are no longer on the transformation');
|
||||
});
|
||||
|
||||
|
|
@ -249,7 +249,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
await visit('/vault/secrets-engines/enable');
|
||||
const backend = `transform-${uuidv4()}`;
|
||||
await mountBackend('transform', backend);
|
||||
await click('[data-test-secret-list-tab="Templates"]');
|
||||
await click(GENERAL.secretTab('Templates'));
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
|
|
@ -283,7 +283,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
'Links to template edit page'
|
||||
);
|
||||
await settled();
|
||||
assert.dom('[data-test-input="name"]').hasAttribute('readonly');
|
||||
assert.dom(GENERAL.inputByAttr('name')).hasAttribute('readonly');
|
||||
});
|
||||
|
||||
test('it allows creation and edit of an alphabet', async function (assert) {
|
||||
|
|
@ -291,7 +291,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
await visit('/vault/secrets-engines/enable');
|
||||
const backend = `transform-${uuidv4()}`;
|
||||
await mountBackend('transform', backend);
|
||||
await click('[data-test-secret-list-tab="Alphabets"]');
|
||||
await click(GENERAL.secretTab('Alphabets'));
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
|
|
@ -312,8 +312,8 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
`/vault/secrets-engines/${backend}/show/alphabet/${alphabetName}`,
|
||||
'redirects to show alphabet page after submit'
|
||||
);
|
||||
assert.dom('[data-test-row-value="Name"]').hasText(alphabetName);
|
||||
assert.dom('[data-test-row-value="Alphabet"]').hasText('aeiou');
|
||||
assert.dom(GENERAL.infoRowValue('Name')).hasText(alphabetName);
|
||||
assert.dom(GENERAL.infoRowValue('Alphabet')).hasText('aeiou');
|
||||
await alphabetsPage.editLink();
|
||||
await settled();
|
||||
assert.strictEqual(
|
||||
|
|
@ -321,6 +321,6 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) {
|
|||
`/vault/secrets-engines/${backend}/edit/alphabet/${alphabetName}`,
|
||||
'Links to alphabet edit page'
|
||||
);
|
||||
assert.dom('[data-test-input="name"]').hasAttribute('readonly');
|
||||
assert.dom(GENERAL.inputByAttr('name')).hasAttribute('readonly');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, skip } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
module('Integration | Component | transform-role-edit', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
skip('it renders', async function (assert) {
|
||||
// TODO: Fill out these tests, merging without to unblock other work
|
||||
await render(hbs`
|
||||
<TransformRoleEdit />
|
||||
`);
|
||||
|
||||
assert.dom(this.element).hasText('template block text');
|
||||
});
|
||||
});
|
||||
101
ui/tests/integration/components/transform-role-edit-test.ts
Normal file
101
ui/tests/integration/components/transform-role-edit-test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import RoleForm from 'vault/forms/transform/role';
|
||||
import sinon from 'sinon';
|
||||
import type ApiService from 'vault/services/api';
|
||||
|
||||
module('Integration | Component | transform-role-edit', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const router = this.owner.lookup('service:router') as unknown as Record<string, unknown>;
|
||||
router['transitionTo'] = sinon.stub();
|
||||
|
||||
this.set('capabilities', {
|
||||
canDelete: true,
|
||||
canUpdate: true,
|
||||
canRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders in show mode', async function (assert) {
|
||||
this.set(
|
||||
'form',
|
||||
new RoleForm(
|
||||
{
|
||||
name: 'my-role',
|
||||
transformations: ['my-transformation'],
|
||||
backend: 'transform',
|
||||
},
|
||||
{ isNew: false }
|
||||
)
|
||||
);
|
||||
this.set('mode', 'show');
|
||||
|
||||
await render(
|
||||
hbs`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-edit-link]').exists('renders toolbar edit link');
|
||||
assert.dom('[data-test-field]').doesNotExist('does not render form fields in show mode');
|
||||
});
|
||||
|
||||
test('it renders in create mode', async function (assert) {
|
||||
this.set('form', new RoleForm({ backend: 'transform' }, { isNew: true }));
|
||||
this.set('mode', 'create');
|
||||
|
||||
await render(
|
||||
hbs`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-submit]').exists('renders submit button');
|
||||
assert.dom('[data-test-submit]').hasText('Create role');
|
||||
});
|
||||
|
||||
test('it renders in edit mode', async function (assert) {
|
||||
this.set(
|
||||
'form',
|
||||
new RoleForm(
|
||||
{
|
||||
name: 'my-role',
|
||||
transformations: ['my-transformation'],
|
||||
backend: 'transform',
|
||||
},
|
||||
{ isNew: false }
|
||||
)
|
||||
);
|
||||
this.set('mode', 'edit');
|
||||
|
||||
await render(
|
||||
hbs`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
|
||||
);
|
||||
|
||||
assert.dom('[data-test-submit]').exists('renders submit button');
|
||||
assert.dom('[data-test-submit]').hasText('Save');
|
||||
assert.dom('[data-test-input="name"]').hasAttribute('readonly', '', 'name is readonly in edit mode');
|
||||
});
|
||||
|
||||
test('it calls onDelete and transitions to list', async function (assert) {
|
||||
const api = this.owner.lookup('service:api') as unknown as ApiService;
|
||||
const deleteStub = sinon.stub(api.secrets, 'transformDeleteRole').resolves();
|
||||
|
||||
this.set('form', new RoleForm({ name: 'my-role', backend: 'transform' }, { isNew: false }));
|
||||
this.set('mode', 'show');
|
||||
|
||||
await render(
|
||||
hbs`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
|
||||
);
|
||||
|
||||
await click('[data-test-delete]');
|
||||
|
||||
assert.ok(deleteStub.calledWith('my-role', 'transform'), 'calls transformDeleteRole with correct args');
|
||||
deleteStub.restore();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue