UI: Ember Data Migration: Transit Secrets Engine (#15195) (#15232)

* UI: Ember data migration: Transit secrets engine - Show & List views (#15015)

* Adding api calls

* fixing timestamps and actions

* fixing routing and moving functions around for model creation

* UI: Ember Data migration: Transit - Create & Edit  (#15085)

* adding in new create form

* updating form to handle editing

* yielding ttl, updating conditional renders

* a lot of moving around

* test fix 1

* test fix 2

* UI: Ember Data migration: Transit Secrets Engine - Key actions (#15176)

* updating store use to use api calls per actions

* forgot export, fixing some tests

* test fixes

* converting to .ts and minor tweaks

* test fixes

Co-authored-by: Dan Rivera <dan.rivera@hashicorp.com>
This commit is contained in:
Vault Automation 2026-06-08 09:27:02 -06:00 committed by GitHub
parent 06d1577cdb
commit 6f613e8d28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 873 additions and 471 deletions

View file

@ -10,36 +10,16 @@
</Page::Header>
{{#if (eq this.mode "create")}}
<TransitFormCreate
@createOrUpdateKey={{action "createOrUpdateKey" this.mode}}
@setValueOnKey={{action "setValueOnKey" "exportable"}}
@autoRotateInvalid={{this.autoRotateInvalid}}
@handleAutoRotateChange={{action "handleAutoRotateChange"}}
@derivedChange={{action "derivedChange" value="target.checked"}}
@convergentEncryptionChange={{action "convergentEncryptionChange" value="target.checked"}}
@key={{this.key}}
@errorMessage={{this.errorMessage}}
@requestInFlight={{this.requestInFlight}}
/>
<TransitFormCreate @form={{this.form}} @errorMessage={{this.errorMessage}} />
{{else if (eq this.mode "edit")}}
<TransitFormEdit
@createOrUpdateKey={{action "createOrUpdateKey" this.mode}}
@setValueOnKey={{action "setValueOnKey" "deletionAllowed"}}
@autoRotateInvalid={{this.autoRotateInvalid}}
@handleAutoRotateChange={{action "handleAutoRotateChange"}}
@deleteKey={{action "deleteKey"}}
@key={{this.key}}
@requestInFlight={{this.requestInFlight}}
@model={{this.model}}
/>
<TransitFormEdit @form={{this.form}} @capabilities={{this.capabilities}} />
{{else if (eq this.mode "show")}}
<TransitFormShow
@refresh={{action "refresh"}}
@tab={{this.tab}}
@key={{this.key}}
@form={{this.form}}
@capabilities={{this.capabilities}}
@mode={{this.mode}}
@model={{this.model}}
@backend={{this.backend}}
/>
{{else}}
<Hds::ApplicationState class="top-padding-32 is-marginless" as |A|>

View file

@ -45,25 +45,25 @@ export default Component.extend(FocusOnInsertMixin, {
route: 'vault.cluster.secrets',
},
{
label: this.key.backend,
label: this.form.data.backend,
route: 'vault.cluster.secrets.backend.list-root',
model: this.key.backend,
model: this.form.data.backend,
},
];
if (this.mode === 'show') {
return [
...baseCrumbs,
{
label: this.key.id,
label: this.form.data.id,
},
];
} else if (this.mode === 'edit') {
return [
...baseCrumbs,
{
label: this.key.id,
label: this.form.data.id,
route: 'vault.cluster.secrets.backend.show',
models: [this.key.backend, this.key.id],
models: [this.form.data.backend, this.form.data.id],
query: { tab: 'details' },
},
{ label: 'Edit' },
@ -87,7 +87,7 @@ export default Component.extend(FocusOnInsertMixin, {
get subtitle() {
if (this.mode === 'create' || this.mode === 'edit') return '';
return this.key?.id;
return this.form.data.id;
},
waitForKeyUp: task(function* () {

View file

@ -3,141 +3,38 @@
SPDX-License-Identifier: BUSL-1.1
}}
<form data-test-transit-create-form onsubmit={{@createOrUpdateKey}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{@key}} @errorMessage={{@errorMessage}} />
<form data-test-transit-create-form onsubmit={{perform this.save}}>
<div class="box is-fullwidth is-marginless">
<MessageError @model={{@form}} @errorMessage={{this.errorBanner}} />
<NamespaceReminder @mode="create" @noun="transit key" />
<div class="field">
<label for="key-name" class="is-label">Name</label>
<Input id="key-name" @value={{@key.name}} class="input" data-test-transit-key="name" />
</div>
<div class="field">
<TtlPicker
@initialValue="30d"
@initialEnabled={{false}}
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}
@errorMessage={{if @autoRotateInvalid "Duration must be longer than 1 hour"}}
/>
</div>
<div class="field">
<label for="key-type" class="is-label">Type</label>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="key-type"
id="key-type"
onchange={{action (mut @key.type) value="target.value"}}
data-test-transit-key="type"
>
<option selected={{eq @key.type "aes128-gcm96"}} value="aes128-gcm96">
aes128-gcm96
</option>
<option selected={{eq @key.type "aes256-gcm96"}} value="aes256-gcm96">
aes256-gcm96
</option>
<option selected={{eq @key.type "chacha20-poly1305"}} value="chacha20-poly1305">
chacha20-poly1305
</option>
<option selected={{eq @key.type "ecdsa-p256"}} value="ecdsa-p256">
ecdsa-p256
</option>
<option selected={{eq @key.type "ecdsa-p384"}} value="ecdsa-p384">
ecdsa-p384
</option>
<option selected={{eq @key.type "ecdsa-p521"}} value="ecdsa-p521">
ecdsa-p521
</option>
<option selected={{eq @key.type "ed25519"}} value="ed25519">
ed25519
</option>
<option selected={{eq @key.type "rsa-2048"}} value="rsa-2048">
rsa-2048
</option>
<option selected={{eq @key.type "rsa-3072"}} value="rsa-3072">
rsa-3072
</option>
<option selected={{eq @key.type "rsa-4096"}} value="rsa-4096">
rsa-4096
</option>
</select>
</div>
</div>
</div>
<div class="field">
<div class="b-checkbox">
<input
type="checkbox"
id="key-exportable"
class="styled"
checked={{@key.exportable}}
onchange={{@setValueOnKey}}
data-test-transit-key="exportable"
{{#each @form.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@form}} @modelValidations={{this.modelValidations}}>
{{#if (eq attr.name "auto_rotate_period")}}
<div class="field">
<TtlPicker
@initialValue="30d"
@initialEnabled={{false}}
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{this.handleAutoRotateChange}}
/>
</div>
{{/if}}
</FormField>
{{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text="Create key" data-test-transit-key="create" type="submit" />
<Hds::Button
@text="Cancel"
@color="secondary"
@model={{@form.data.backend}}
@route="vault.cluster.secrets.backend.list-root"
/>
<label for="key-exportable" class="is-label">
Exportable
</label>
</div>
</Hds::ButtonSet>
</div>
{{#if
(or
(eq @key.type "aes128-gcm96")
(eq @key.type "aes256-gcm96")
(eq @key.type "chacha20-poly1305")
(eq @key.type "ed25519")
)
}}
<div class="field">
<div class="b-checkbox">
<input
type="checkbox"
id="key-derived"
class="styled"
checked={{@key.derived}}
onchange={{@derivedChange}}
data-test-transit-key="derived"
/>
<label for="key-derived" class="is-label">
Derived
</label>
</div>
</div>
{{/if}}
{{#if (or (eq @key.type "aes128-gcm96") (eq @key.type "aes256-gcm96") (eq @key.type "chacha20-poly1305"))}}
<div class="field">
<div class="b-checkbox">
<input
type="checkbox"
data-test-transit-key="convergent-encryption"
id="key-convergent"
class="styled"
checked={{@key.convergentEncryption}}
onchange={{@convergentEncryptionChange}}
/>
<label for="key-convergent" class="is-label">
Enable convergent encryption
</label>
</div>
</div>
{{/if}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button
@text="Create key"
@icon={{if @requestInFlight "loading"}}
data-test-transit-key="create"
type="submit"
disabled={{@requestInFlight}}
/>
<Hds::Button
@text="Cancel"
@color="secondary"
@model={{@key.backend}}
@route="vault.cluster.secrets.backend.list-root"
/>
</Hds::ButtonSet>
</div>
</form>

View file

@ -0,0 +1,60 @@
/**
* 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 { tracked } from '@glimmer/tracking';
import { HTMLElementEvent } from 'vault/forms';
import FlashMessageService from 'vault/services/flash-messages';
import ApiService from 'vault/services/api';
import RouterService from '@ember/routing/router-service';
import TransitKeyForm from 'vault/forms/transit/key';
import { ValidationMap } from 'vault/vault/app-types';
import { task } from 'ember-concurrency';
interface Args {
form: TransitKeyForm;
}
export default class TransitFormCreate extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly api: ApiService;
@tracked errorBanner = '';
@tracked modelValidations: ValidationMap | null = null;
@action
handleAutoRotateChange(ttlObj: { enabled: boolean; goSafeTimeString: string }) {
const { data } = this.args.form;
if (ttlObj.enabled) {
data.auto_rotate_period = ttlObj.goSafeTimeString;
} else {
data.auto_rotate_period = '0s';
}
}
@task
*save(event: HTMLElementEvent<HTMLFormElement>) {
event.preventDefault();
try {
const { data, isValid, state } = this.args.form.toJSON();
this.modelValidations = isValid ? null : state;
if (isValid) {
yield this.api.secrets.transitCreateKey(data['name'] as string, data['backend'] as string, data);
this.flashMessages.success('Key successfully created.');
this.router.transitionTo('vault.cluster.secrets.backend.show', data['backend'], data['name'], {
queryParams: { tab: 'details' },
});
}
} catch (error) {
const { message } = yield this.api.parseError(error);
this.errorBanner = message;
}
}
}

View file

@ -2,101 +2,85 @@
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<form data-test-transit-edit-form onsubmit={{@createOrUpdateKey}}>
<form data-test-transit-edit-form onsubmit={{this.save}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{@key}} />
<MessageError @model={{@form}} />
<NamespaceReminder @mode="edit" @noun="transit key" />
<div class="field">
<div class="b-checkbox">
<input
id="key-allows-delete"
type="checkbox"
checked={{@key.deletionAllowed}}
class="styled"
onchange={{@setValueOnKey}}
/>
<label for="key-allows-delete" class="is-label">
Allow deletion
</label>
</div>
</div>
<div class="field">
<TtlPicker
@initialValue={{or @key.autoRotatePeriod "30d"}}
@initialEnabled={{not (eq @key.autoRotatePeriod 0)}}
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}
@errorMessage={{if @autoRotateInvalid "Duration must be longer than 1 hour"}}
/>
</div>
<div class="field">
<label for="key-min-decrypt-version" class="is-label">Minimum decryption version</label>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="key-min-decrypt-version"
id="key-min-decrypt-version"
onchange={{action (mut @key.minDecryptionVersion) value="target.value"}}
>
{{#each @key.keyVersions as |version|}}
<option selected={{loose-equal @key.minDecryptionVersion version}} value={{version}}>
<code>{{version}}</code>
</option>
{{/each}}
</select>
</div>
</div>
<p class="help">
The minimum decryption version required to reverse transformations performed with the cryptographic key. Results from
lower key versions may be rewrapped with the new key version using the
<code>rewrap</code>
action.
</p>
</div>
<div class="field">
<label for="key-min-encrypt-version" class="is-label">Minimum encryption version</label>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="key-min-encrypt-version"
id="key-min-encrypt-version"
onchange={{action (mut @key.minEncryptionVersion) value="target.value"}}
>
<option selected={{loose-equal @key.minEncryptionVersion 0}} value={{0}}>
<code>Latest</code>
(currently
{{@key.latestVersion}})
</option>
{{#each @key.encryptionKeyVersions as |version|}}
<option selected={{loose-equal @key.minEncryptionVersion version}} value={{version}}>
<code>{{version}}</code>
</option>
{{/each}}
</select>
</div>
</div>
<p class="help">
The minimum version of the key that can be used to encrypt plaintext, sign payloads, or generate HMACs. You will be
able to specify which version of the key to use for each of these actions. The value specified here must be greater
than or equal to that specified in the
<b>Minimum Decryption Version</b>
selection above.
</p>
</div>
{{#each @form.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@form}} @modelValidations={{this.modelValidations}}>
{{#if (eq attr.name "auto_rotate_period")}}
<div class="field">
<TtlPicker
@initialValue={{or @form.data.auto_rotate_period "30d"}}
@initialEnabled={{not (eq @form.data.auto_rotate_period 0)}}
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{this.handleAutoRotateChange}}
/>
</div>
{{/if}}
{{#if (eq attr.name "min_decryption_version")}}
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="min_decryption_version"
id="min_decryption_version"
onchange={{action (mut @form.data.min_decryption_version) value="target.value"}}
>
{{#each @form.data.keyVersions as |version|}}
<option selected={{loose-equal @form.data.min_decryption_version version}} value={{version}}>
<code>{{version}}</code>
</option>
{{/each}}
</select>
</div>
</div>
<p class="help">
The minimum decryption version required to reverse transformations performed with the cryptographic key. Results
from lower key versions may be rewrapped with the new key version using the
<code>rewrap</code>
action.
</p>
{{/if}}
{{#if (eq attr.name "min_encryption_version")}}
<div class="control is-expanded">
<div class="select is-fullwidth">
<select
name="min_encryption_version"
id="min_encryption_version"
onchange={{action (mut @form.data.min_encryption_version) value="target.value"}}
>
<option selected={{loose-equal @form.data.min_encryption_version 0}} value={{0}}>
<code>Latest</code>
(currently
{{@form.data.latest_version}})
</option>
{{#each @form.data.encryptionKeyVersions as |version|}}
<option selected={{loose-equal @form.data.min_encryption_version version}} value={{version}}>
<code>{{version}}</code>
</option>
{{/each}}
</select>
</div>
</div>
<p class="help">
The minimum version of the key that can be used to encrypt plaintext, sign payloads, or generate HMACs. You will
be able to specify which version of the key to use for each of these actions. The value specified here must be
greater than or equal to that specified in the
<b>Minimum Decryption Version</b>
selection above.
</p>
{{/if}}
</FormField>
{{/each}}
</div>
<div class="field is-grouped is-grouped-split box is-fullwidth is-bottomless">
<div class="field is-grouped">
{{#if @model.canUpdate}}
{{#if @capabilities.canUpdate}}
<div class="control">
<Hds::Button
@text="Update transit key"
@icon={{if @requestInFlight "loading"}}
type="submit"
disabled={{@requestInFlight}}
/>
<Hds::Button @text="Update transit key" type="submit" />
</div>
{{/if}}
<div class="control">
@ -104,13 +88,13 @@
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.show"
@models={{array @key.backend @key.id}}
@models={{array @form.data.backend @form.data.id}}
@query={{hash tab="details"}}
/>
</div>
</div>
{{#if @model.canDelete}}
<ConfirmAction @buttonText="Delete transit key" @onConfirmAction={{@deleteKey}} />
{{#if @capabilities.canDelete}}
<ConfirmAction @buttonText="Delete transit key" @onConfirmAction={{this.deleteKey}} />
{{/if}}
</div>
</form>

View file

@ -0,0 +1,93 @@
/**
* 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 { tracked } from '@glimmer/tracking';
import ApiService from 'vault/services/api';
import FlashMessageService from 'vault/services/flash-messages';
import RouterService from '@ember/routing/router-service';
import TransitKeyForm from 'vault/forms/transit/key';
import { ValidationMap } from 'vault/vault/app-types';
import { HTMLElementEvent } from 'vault/forms';
type TransitKeyConfig = {
name?: string;
backend?: string;
auto_rotate_period?: string;
min_decryption_version?: number;
min_encryption_version?: number;
deletion_allowed?: boolean;
};
interface Args {
form: TransitKeyForm;
}
export default class TransitFormEdit extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly api: ApiService;
@tracked errorBanner = '';
@tracked modelValidations: ValidationMap | null = null;
@action
handleAutoRotateChange(ttlObj: { enabled: boolean; goSafeTimeString: string }) {
const { data } = this.args.form;
if (ttlObj.enabled) {
data.auto_rotate_period = ttlObj.goSafeTimeString;
} else {
data.auto_rotate_period = '0s';
}
}
@action async deleteKey() {
const { backend, id } = this.args.form.data;
try {
await this.api.secrets.transitDeleteKey(id as string, backend as string);
this.flashMessages.success(`'${id}' key deleted.`);
this.router.transitionTo('vault.cluster.secrets.backend.list-root');
} catch (error) {
const { message } = await this.api.parseError(error);
this.errorBanner = message;
}
}
@action async save(event: HTMLElementEvent<HTMLFormElement>) {
event.preventDefault();
try {
const { data, isValid, state } = this.args.form.toJSON();
this.modelValidations = isValid ? null : state;
if (isValid) {
const {
name,
backend,
auto_rotate_period,
min_decryption_version,
min_encryption_version,
deletion_allowed,
}: TransitKeyConfig = data;
await this.api.secrets.transitConfigureKey(name as string, backend as string, {
auto_rotate_period,
min_decryption_version,
min_encryption_version,
deletion_allowed,
});
this.flashMessages.success('Key successfully updated.');
this.router.transitionTo('vault.cluster.secrets.backend.show', backend, name, {
queryParams: { tab: 'details' },
});
}
} catch (error) {
const { message } = await this.api.parseError(error);
this.errorBanner = message;
}
}
}

View file

@ -8,9 +8,9 @@
<ul>
<li class={{if (eq @tab "actions") "is-active"}}>
<SecretLink
@secret={{@key.id}}
@secret={{@form.data.id}}
@mode="show"
@backend={{@key.backend}}
@backend={{@form.data.backend}}
@replace={{true}}
@queryParams={{hash tab="actions"}}
data-test-transit-key-actions-link={{true}}
@ -21,9 +21,9 @@
<li class={{if (eq @tab "details") "is-active"}}>
<SecretLink
@secret={{@key.id}}
@secret={{@form.data.id}}
@mode="show"
@backend={{@key.backend}}
@backend={{@form.data.backend}}
@replace={{true}}
@queryParams={{hash tab="details"}}
data-test-transit-link="details"
@ -34,9 +34,9 @@
<li class={{if (eq @tab "versions") "is-active"}}>
<SecretLink
@secret={{@key.id}}
@secret={{@form.data.id}}
@mode="show"
@backend={{@key.backend}}
@backend={{@form.data.backend}}
@replace={{true}}
@queryParams={{hash tab="versions"}}
data-test-transit-link="versions"
@ -50,7 +50,7 @@
{{#if (not-eq @tab "actions")}}
<Toolbar>
<ToolbarActions>
{{#if (and (eq @tab "versions") @key.canRotate)}}
{{#if (and (eq @tab "versions") @capabilities.canRotate)}}
<ConfirmAction
@buttonText="Rotate key"
class="toolbar-button"
@ -63,8 +63,8 @@
/>
{{/if}}
{{#if (eq @mode "show")}}
{{#if (or @model.canUpdate @model.canDelete)}}
<ToolbarSecretLink @secret={{@key.id}} @backend={{@key.backend}} @mode="edit" replace={{true}}>
{{#if (or @capabilities.canUpdate @capabilities.canDelete)}}
<ToolbarSecretLink @secret={{@form.data.id}} @backend={{@form.data.backend}} @mode="edit" replace={{true}}>
Edit key
</ToolbarSecretLink>
{{/if}}
@ -75,9 +75,9 @@
{{#if (eq @tab "actions")}}
<div class="transit-card-container">
{{#each @model.supportedActions as |supportedAction|}}
{{#each @form.data.supportedActions as |supportedAction|}}
<LinkedBlock
@params={{array "vault.cluster.secrets.backend.actions" @model.backend @model.id}}
@params={{array "vault.cluster.secrets.backend.actions" @form.data.backend @form.data.id}}
@queryParams={{hash action=supportedAction.name}}
class="transit-card"
data-test-transit-card={{supportedAction.name}}
@ -86,7 +86,7 @@
<Icon
@name={{supportedAction.glyph}}
class="has-text-grey auto-width"
aria-label={{concat @backend.path " options"}}
aria-label={{concat @form.data.backend.path " options"}}
/>
</div>
<div class="transit-description">
@ -105,8 +105,10 @@
{{/each}}
</div>
{{else if (eq @tab "versions")}}
{{#if (or (eq @key.type "aes256-gcm96") (eq @key.type "chacha20-poly1305") (eq @key.type "aes128-gcm96"))}}
{{#each-in @key.keys as |version creationTimestamp|}}
{{#if
(or (eq @form.data.type "aes256-gcm96") (eq @form.data.type "chacha20-poly1305") (eq @form.data.type "aes128-gcm96"))
}}
{{#each-in @form.data.keys as |version creationTimestamp|}}
<div class="linked-block list-item-row no-destination" data-test-transit-version={{version}}>
<div class="columns is-mobile">
<div class="column is-3">
@ -118,13 +120,13 @@
<div class="column is-4">
<div class="td is-borderless">
<small class="help has-text-grey">
{{date-from-now creationTimestamp addSuffix=true}}
{{this.getTimestamp creationTimestamp}}
</small>
</div>
</div>
<div class="column is-5">
<div class="td is-borderless">
{{#if (coerce-eq @key.minDecryptionVersion version)}}
{{#if (coerce-eq @form.data.min_decryption_version version)}}
<p class="help level level-left">
<Icon @name="check-circle-fill" class="has-text-success" />
Current minimum decryption version
@ -136,7 +138,7 @@
</div>
{{/each-in}}
{{else}}
{{#each-in @key.keys as |version meta|}}
{{#each-in @form.data.keys as |version meta|}}
<div class="linked-block list-item-row" data-test-transit-version={{version}}>
<div class="columns is-mobile">
<div class="column is-3">
@ -154,7 +156,7 @@
</div>
<div class="column is-4">
<div class="td is-borderless">
{{#if (coerce-eq @key.minDecryptionVersion version)}}
{{#if (coerce-eq @form.data.min_decryption_version version)}}
<p class="help level level-left">
<Icon @name="check-circle-fill" class="has-text-success" />
Current minimum decryption version
@ -178,15 +180,15 @@
{{/each-in}}
{{/if}}
{{else}}
<InfoTableRow @label="Type" @value={{@key.type}} />
<InfoTableRow @label="Type" @value={{@form.data.type}} />
<InfoTableRow
@label="Auto-rotation period"
@value={{or (format-duration @key.autoRotatePeriod) "Key will not be automatically rotated"}}
@value={{or (format-duration @form.data.auto_rotate_period) "Key will not be automatically rotated"}}
/>
<InfoTableRow @label="Deletion allowed" @value={{stringify @key.deletionAllowed}} />
<InfoTableRow @label="Deletion allowed" @value={{stringify @form.data.deletion_allowed}} />
{{#if @key.derived}}
<InfoTableRow @label="Derived" @value={{@key.derived}} />
<InfoTableRow @label="Convergent encryption" @value={{@key.convergentEncryption}} />
{{#if @form.data.derived}}
<InfoTableRow @label="Derived" @value={{@form.data.derived}} />
<InfoTableRow @label="Convergent encryption" @value={{@form.data.convergent_encryption}} />
{{/if}}
{{/if}}

View file

@ -6,28 +6,30 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { buildWaiter } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
const waiter = buildWaiter('transit-form-show');
import { dateFromNow } from 'core/helpers/date-from-now';
export default class TransitFormShow extends Component {
@service store;
@service router;
@service flashMessages;
@service api;
@action async rotateKey() {
const waiterToken = waiter.beginAsync();
const { backend, id } = this.args.key;
const { backend, id } = this.args.form.data;
try {
await this.store.adapterFor('transit-key').keyAction('rotate', { backend, id });
await this.api.secrets.transitRotateKey(id, backend, {});
this.flashMessages.success('Key rotated.');
// must refresh to see the updated versions, a model refresh does not trigger the change.
await this.router.refresh();
} catch (e) {
this.flashMessages.danger(errorMessage(e));
} finally {
waiter.endAsync(waiterToken);
const { message } = this.api.parseError(e);
this.flashMessages.danger(message);
}
}
// Investigate - possibly a bug?
// api returns the same value for creation time as ember data (eg. 1633024800) - but the date isn't rendering the same (ie. ED model pipe returns correct time but api value returns as 56 years ago)
// not sure why the ED model data is taken as milliseconds and the api value is taken as seconds, but this is a workaround to get the correct time.
getTimestamp(time) {
return dateFromNow([time * 1000], { addSuffix: true });
}
}

View file

@ -11,7 +11,6 @@ import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { encodeString } from 'vault/utils/b64';
import errorMessage from 'vault/utils/error-message';
/**
* @module TransitKeyActions
@ -90,9 +89,9 @@ const SUCCESS_MESSAGE_FOR_ACTION = {
};
export default class TransitKeyActions extends Component {
@service store;
@service flashMessages;
@service router;
@service api;
@tracked isModalActive = false;
@tracked errors = null;
@ -126,7 +125,7 @@ export default class TransitKeyActions extends Component {
this.props = { ...this.props, ...resp.data };
// While we do not pass ciphertext as a value to the JsonEditor, so that navigating from rewrap to decrypt will not show ciphertext in the editor, we still want to clear it from the props after rewrapping.
if (action === 'rewrap' && !this.args.key.supportsEncryption) {
if (action === 'rewrap' && !this.args.key.supports_encryption) {
this.props.ciphertext = null;
}
}
@ -211,13 +210,43 @@ export default class TransitKeyActions extends Component {
const payload = formData ? this.compactData(formData) : null;
try {
const resp = yield this.store
.adapterFor('transit-key')
.keyAction(action, { backend, id, payload }, options);
let resp;
if (action === 'encrypt') {
resp = yield this.api.secrets.transitEncrypt(id, backend, payload);
} else if (action === 'decrypt') {
resp = yield this.api.secrets.transitDecrypt(id, backend, payload);
} else if (action === 'datakey') {
resp = yield this.api.secrets.transitGenerateDataKey(id, payload.param, backend, {
...payload,
});
} else if (action === 'rewrap') {
resp = yield this.api.secrets.transitRewrap(id, backend, payload);
} else if (action === 'sign') {
resp = yield this.api.secrets.transitSign(id, backend, payload);
} else if (action === 'verify') {
resp = yield this.api.secrets.transitVerify(id, backend, payload);
} else if (action === 'hmac') {
resp = yield this.api.secrets.transitGenerateHmac(id, backend, payload);
} else if (action === 'export') {
const [type, version] = payload.param;
// if wrap ttl is present, the header needs to be set on the request for the api service to return the wrapped token in response - otherwise it would ignore and return data keys
const overrideOptions = options.wrapTTL
? { headers: { 'X-Vault-Wrap-TTL': options.wrapTTL }, wrapTTL: options.wrapTTL }
: {};
resp = version
? yield this.api.secrets.transitExportKeyVersion(
id,
`${type}-key`,
version,
backend,
overrideOptions
)
: yield this.api.secrets.transitExportKey(id, `${type}-key`, backend, overrideOptions);
}
this.handleSuccess(resp, options, action);
} catch (e) {
this.errors = errorMessage(e);
const { message } = yield this.api.parseError(e);
this.errors = message;
}
}
}

133
ui/app/forms/transit/key.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* 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 { TransitCreateKeyRequest } from '@hashicorp/vault-client-typescript';
import { Validations } from 'vault/vault/app-types';
import { durationToSeconds } from 'core/utils/duration-utils';
type TransitKeyData = TransitCreateKeyRequest & {
backend?: string;
id?: string;
name?: string;
};
export default class TransitKeyForm extends Form<TransitKeyData> {
// these set funcs just exist on edit
set convergentEncryptionValue(val: boolean | undefined) {
if (val === true) {
this.data.derived = val;
}
this.data.convergent_encryption = val;
}
set derivedValue(val: boolean | undefined) {
if (val === false) {
this.data.convergent_encryption = val;
}
this.data.derived = val;
}
get formFields() {
let fields = [];
if (this.isNew) {
fields.push(
...[
new FormField('name', 'string', {
editDisabled: !this.isNew,
}),
new FormField('auto_rotate_period', undefined, {
editType: 'yield',
label: ' ',
}),
new FormField('type', 'string', {
possibleValues: [
'aes128-gcm96',
'aes256-gcm96',
'chacha20-poly1305',
'ecdsa-p256',
'ecdsa-p384',
'ecdsa-p521',
'ed25519',
'rsa-2048',
'rsa-3072',
'rsa-4096',
],
}),
new FormField('exportable', 'boolean', {
label: 'Exportable',
editType: 'checkbox',
}),
new FormField('derived', 'boolean', {
label: 'Derived',
editType: 'checkbox',
}),
new FormField('convergent_encryption', 'boolean', {
label: 'Enable convergent encryption',
editType: 'checkbox',
}),
]
);
if (this.data.type?.startsWith('ecdsa') || this.data.type?.startsWith('rsa')) {
fields = fields.filter((field) => !['convergent_encryption', 'derived'].includes(field.name));
} else if (this.data.type === 'ed25519') {
fields = fields.filter((field) => field.name !== 'convergent_encryption');
}
} else {
fields.push(
...[
new FormField('deletion_allowed', 'boolean', {
label: 'Allow deletion',
editType: 'checkbox',
}),
new FormField('auto_rotate_period', undefined, {
label: ' ',
editType: 'yield',
}),
new FormField('min_decryption_version', undefined, {
label: 'Minimum decryption version',
editType: 'yield',
}),
new FormField('min_encryption_version', undefined, {
label: 'Minimum encryption version',
editType: 'yield',
}),
]
);
}
return fields;
}
validations: Validations = {
name: [{ type: 'presence', message: 'Name is required' }],
auto_rotate_period: [
{
validator(data: TransitKeyData) {
const { auto_rotate_period } = data;
if (auto_rotate_period === undefined) {
return true;
} else {
const duration = durationToSeconds(auto_rotate_period);
// regardless of generateSigningKey, if one key is set they both need to be set.
return duration === 0 || duration >= 3600;
}
},
message: 'Duration must be longer than 1 hour or set to 0 to disable auto-rotation.',
},
],
};
toJSON() {
const { isValid, state, invalidFormMessage } = super.toJSON();
const data = { ...this.data } as Record<string, unknown>;
// set auto_rotate_period to 0 if it's false to avoid api validation error since the form field is a string and the api expects a number
if (data['auto_rotate_period'] === false) {
data['auto_rotate_period'] = 0;
}
return { isValid, state, invalidFormMessage, data };
}
}

View file

@ -30,21 +30,21 @@ export default EditBase.extend({
setupController(controller, model) {
this._super(...arguments);
const { selectedAction } = this.paramsFor(this.routeName);
controller.set('selectedAction', selectedAction || model.secret.supportedActions[0]);
controller.set('selectedAction', selectedAction || model.secret.data.supportedActions[0]);
controller.set('breadcrumbs', [
{
label: 'Secrets',
route: 'vault.cluster.secrets',
},
{
label: model.secret.backend,
label: model.secret.data.backend,
route: 'vault.cluster.secrets.backend.list-root',
model: model.secret.backend,
model: model.secret.data.backend,
},
{
label: model.secret.id,
label: model.secret.data.id,
route: 'vault.cluster.secrets.backend.show',
models: [model.secret.backend, model.secret.id],
models: [model.secret.data.backend, model.secret.data.id],
},
{
label: 'Actions',

View file

@ -9,6 +9,7 @@ import EditBase from './secret-edit';
import KeymgmtKeyForm from 'vault/forms/keymgmt/key';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
import TotpKeyForm from 'vault/forms/totp/key';
import TransitKeyForm from 'vault/forms/transit/key';
import SshRoleForm from 'vault/forms/ssh/role';
import AlphabetForm from 'vault/forms/transform/alphabet';
import TemplateForm from 'vault/forms/transform/template';
@ -65,6 +66,17 @@ export default EditBase.extend({
);
}
if (modelType === 'transit-key') {
return new TransitKeyForm(
{
backend,
type: 'aes256-gcm96',
auto_rotate_period: '0s',
},
{ isNew: true }
);
}
if (modelType === 'role-ssh') {
return new SshRoleForm(
{ backend, key_type: 'ca', not_before_duration: '30s', port: 22 },

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { set } from '@ember/object';
import { set, get } from '@ember/object';
import Ember from 'ember';
import { resolve } from 'rsvp';
import { service } from '@ember/service';
@ -17,6 +17,9 @@ import { isValidProvider } from 'vault/utils/keymgmt-provider-utils';
import KeymgmtKeyForm from 'vault/forms/keymgmt/key';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
import TotpKeyForm from 'vault/forms/totp/key';
import clamp from 'vault/utils/clamp';
import TransitKeyForm from 'vault/forms/transit/key';
import SshRoleForm from 'vault/forms/ssh/role';
import AlphabetForm from 'vault/forms/transform/alphabet';
import TemplateForm from 'vault/forms/transform/template';
@ -28,6 +31,50 @@ import {
SecretsApiTransformListRolesListEnum,
} from '@hashicorp/vault-client-typescript';
// TODO: Move this into a util file or class
const ACTION_VALUES = {
encrypt: {
isSupported: 'supports_encryption',
description: 'Looks up wrapping properties for the given token.',
glyph: 'lock-fill',
},
decrypt: {
isSupported: 'supports_decryption',
description: 'Decrypts the provided ciphertext using this key.',
glyph: 'mail-open',
},
datakey: {
isSupported: 'supports_encryption',
description: 'Generates a new key and value encrypted with this key.',
glyph: 'key',
},
rewrap: {
isSupported: 'supports_encryption',
description: 'Rewraps the ciphertext using the latest version of the named key.',
glyph: 'reload',
},
sign: {
isSupported: 'supports_signing',
description: 'Get the cryptographic signature of the given data.',
glyph: 'pencil-tool',
},
hmac: {
isSupported: true,
description: 'Generate a data digest using a hash algorithm.',
glyph: 'shuffle',
},
verify: {
isSupported: true,
description: 'Validate the provided signature for the given data.',
glyph: 'check-circle',
},
export: {
isSupported: 'exportable',
description: 'Get the named key.',
glyph: 'external-link',
},
};
/**
* @type Class
*/
@ -261,6 +308,104 @@ export default Route.extend({
};
},
async fetchTransitKey(name, backend) {
const res = await this.api.secrets.transitReadKey(name, backend);
const transitModel = {
backend,
id: name,
...res,
...this.transitEncryptionKeyVersions(
res.keys,
res.min_decryption_version,
res.min_encryption_version,
res.latest_version,
res.supports_signing,
res.supports_encryption
),
};
return transitModel;
},
async fetchTransitKeyCapabilities(backend, name, secretModel) {
const rotatePath = this.capabilitiesService.pathFor('transitKeyRotate', { backend, name });
const keyPath = this.capabilitiesService.pathFor('transitKey', { backend, name });
const capabilities = await this.capabilitiesService.fetch([rotatePath, keyPath]);
return {
canRotate: capabilities[rotatePath]?.canUpdate,
canRead: capabilities[keyPath]?.canRead,
canUpdate: capabilities[keyPath]?.canUpdate,
canDelete: capabilities[keyPath]?.canDelete !== false && secretModel.deletion_allowed !== false, // check deletion_allowed from the model in addition to the capability since both are required to allow deletion
};
},
// TODO: Move these into separate classes or utils
transitEncryptionKeyVersions(
keys,
minDecryptionVersion,
minEncryptionVersion,
latestVersion,
supportsSigning,
supportsEncryption
) {
const validKeys = Object.keys(keys);
const keyVersions = [];
const keysForEncryption = [];
// get keyVersions
let maxVersion = Math.max(...validKeys);
while (maxVersion > 0) {
keyVersions.unshift(maxVersion);
maxVersion--;
}
// get encryptionKeyVersions using keyVersions
const encryptionKeyVersions = keyVersions
.filter((version) => {
return version >= minDecryptionVersion;
})
.reverse();
// get keysForEncryption
const minVersion = clamp(minEncryptionVersion - 1, 0, latestVersion);
while (latestVersion > minVersion) {
keysForEncryption.push(latestVersion);
latestVersion--;
}
// get exportKeyTypes
const exportKeyTypes = ['hmac'];
if (supportsSigning) {
exportKeyTypes.unshift('signing');
}
if (supportsEncryption) {
exportKeyTypes.unshift('encryption');
}
return {
encryptionKeyVersions,
keyVersions,
keysForEncryption,
exportKeyTypes,
validKeyVersions: Object.keys(keys),
};
},
transitSupportedActions(secretModel) {
return Object.keys(ACTION_VALUES)
.filter((name) => {
const { isSupported } = ACTION_VALUES[name];
return typeof isSupported === 'boolean' || get(secretModel, isSupported);
})
.map((name) => {
const { description, glyph } = ACTION_VALUES[name];
return { name, description, glyph };
});
},
async fetchKeymgmtKey(backend, name) {
const { data } = await this.api.secrets.keyManagementReadKey(name, backend);
@ -525,6 +670,12 @@ export default Route.extend({
if (modelType === 'totp-key') {
secretModel = await this.fetchTotpKey(backend, secret);
capabilities = await this.fetchTotpKeyCapabilities(backend, secret);
} else if (modelType === 'transit-key') {
secretModel = await this.fetchTransitKey(secret, backend);
capabilities = await this.fetchTransitKeyCapabilities(backend, secret, secretModel);
secretModel.supportedActions = this.transitSupportedActions(secretModel);
// replace secretModel with form
secretModel = new TransitKeyForm(secretModel, { isNew: false });
} else if (modelType === 'keymgmt/key') {
secretModel = await this.fetchKeymgmtKey(backend, secret);
capabilities = await this.fetchKeymgmtKeyCapabilities(backend, secret);

View file

@ -125,8 +125,14 @@ export default class ApiService extends Service {
}
// if the requested path is locked by a control group we need to create a new error response
const json = await this.readJson(response);
// derive from REQUEST, not response - transit export is making an api request but trying to also set vault header on the request, not the response - causing a control group error.
const wasWrapTTLRequested = (context.init.headers as Headers)?.get?.('X-Vault-Wrap-TTL');
const wrapTtl = headers.get('X-Vault-Wrap-TTL');
const isLockedByControlGroup = this.controlGroup.isRequestedPathLocked(json, wrapTtl);
const isLockedByControlGroup = this.controlGroup.isRequestedPathLocked(
json,
wasWrapTTLRequested ? wasWrapTTLRequested : wrapTtl
);
if (isLockedByControlGroup && json) {
const error = {

View file

@ -12,8 +12,8 @@
<:actions>
<SecretLink
class="is-inline has-text-info"
@secret={{this.model.id}}
@backend={{this.model.backend}}
@secret={{this.model.data.id}}
@backend={{this.model.data.backend}}
@mode="show"
@replace={{true}}
@queryParams={{hash tab="actions"}}
@ -28,12 +28,12 @@
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs">
<ul>
{{#each this.model.supportedActions as |supportedAction|}}
{{#each this.model.data.supportedActions as |supportedAction|}}
<li class={{if (eq supportedAction.name this.selectedAction) "is-active"}}>
<SecretLink
@mode="actions"
@backend={{this.model.backend}}
@secret={{this.model.id}}
@backend={{this.model.data.backend}}
@secret={{this.model.data.id}}
@queryParams={{hash action=supportedAction.name}}
data-test-transit-action-link={{supportedAction.name}}
>
@ -50,6 +50,6 @@
</ul>
</nav>
</div>
<TransitKeyActions @selectedAction={{this.selectedAction}} @key={{this.model}} />
<TransitKeyActions @selectedAction={{this.selectedAction}} @key={{this.model.data}} />
</div>
</div>

View file

@ -90,6 +90,8 @@ export const PATH_MAP = {
syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`,
totpKey: apiPath`${'backend'}/keys/${'name'}`,
totpKeys: apiPath`${'backend'}/keys`,
transitKeyRotate: apiPath`${'backend'}/keys/${'name'}/rotate`,
transitKey: apiPath`${'backend'}/keys/${'name'}`,
transformAlphabet: apiPath`${'backend'}/alphabet/${'name'}`,
transformAlphabets: apiPath`${'backend'}/alphabet`,
transformRole: apiPath`${'backend'}/role/${'name'}`,

View file

@ -3,7 +3,17 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, find, currentURL, settled, visit, findAll, waitFor } from '@ember/test-helpers';
import {
click,
fillIn,
find,
currentURL,
settled,
visit,
findAll,
waitFor,
waitUntil,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -233,14 +243,15 @@ module('Acceptance | transit', function (hooks) {
const name = `test-generate-${this.uid}`;
await click(SES.createSecretLink);
await fillIn(SELECTORS.form('name'), name);
await fillIn(SELECTORS.form('type'), type);
await click(SELECTORS.form('exportable'));
await click(SELECTORS.form('derived'));
await click(SELECTORS.form('convergent-encryption'));
await fillIn(GENERAL.inputByAttr('name'), name);
await fillIn(GENERAL.inputByAttr('type'), type);
await click(GENERAL.inputByAttr('exportable'));
await click(GENERAL.inputByAttr('derived'));
await click(GENERAL.inputByAttr('convergent_encryption'));
await click(GENERAL.ttl.toggle('Auto-rotation period'));
await click(SELECTORS.form('create'));
await waitUntil(() => currentURL() === `/vault/secrets-engines/${this.path}/show/${name}?tab=details`);
assert.strictEqual(
currentURL(),
`/vault/secrets-engines/${this.path}/show/${name}?tab=details`,
@ -305,30 +316,30 @@ module('Acceptance | transit', function (hooks) {
},
{
type: 'aes128-gcm96',
'convergent-encryption': true,
convergent_encryption: true,
derived: true,
exportable: true,
},
{
type: 'aes256-gcm96',
'convergent-encryption': true,
convergent_encryption: true,
derived: true,
exportable: true,
},
{
type: 'chacha20-poly1305',
'convergent-encryption': true,
convergent_encryption: true,
derived: true,
exportable: true,
},
];
for (const key of KEY_OPTIONS) {
const { type } = key;
await fillIn(SELECTORS.form('type'), type);
await fillIn(GENERAL.inputByAttr('type'), type);
for (const checkbox of ['exportable', 'derived', 'convergent-encryption']) {
for (const checkbox of ['exportable', 'derived', 'convergent_encryption']) {
const assertion = key[checkbox] ? 'exists' : 'doesNotExist';
assert.dom(SELECTORS.form(checkbox))[assertion](`${type} ${checkbox} ${assertion}`);
assert.dom(GENERAL.inputByAttr(checkbox))[assertion](`${type} ${checkbox} ${assertion}`);
}
}
});

View file

@ -9,6 +9,7 @@ import { click, fillIn, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { capabilitiesStub } from 'vault/tests/helpers/stubs';
import TransitKeyForm from 'vault/forms/transit/key';
const SELECTORS = {
createForm: '[data-test-transit-create-form]',
@ -25,20 +26,20 @@ module('Integration | Component | transit-edit', function (hooks) {
this.server.post('/sys/capabilities-self', () =>
capabilitiesStub('transit-backend/keys/some-key', ['sudo'])
);
this.model = this.store.createRecord('transit-key', { backend: 'transit-backend', id: 'some-key' });
this.model = { backend: 'transit', id: 'some-key' };
this.backendCrumb = {
label: 'transit',
text: 'transit',
path: 'vault.cluster.secrets.backend.list-root',
model: 'transit',
};
this.form = new TransitKeyForm(this.model, { isNew: false });
});
test('it renders in create mode and updates model', async function (assert) {
await render(hbs`
<TransitEdit
@key={{this.model}}
@model={{this.model}}
@form={{this.form}}
@mode="create"
@root={{this.backendCrumb}}
@preferAdvancedEdit={{false}}
@ -48,25 +49,24 @@ module('Integration | Component | transit-edit', function (hooks) {
assert.dom(SELECTORS.ttlToggle).isNotChecked();
// confirm model params update when ttl changes
assert.strictEqual(this.model.autoRotatePeriod, '0');
assert.strictEqual(this.form.data.auto_rotate_period, undefined);
await click(SELECTORS.ttlToggle);
assert.dom(SELECTORS.ttlValue).hasValue('30'); // 30 days
assert.strictEqual(this.model.autoRotatePeriod, '720h');
assert.strictEqual(this.form.data.auto_rotate_period, '720h');
await fillIn(SELECTORS.ttlValue, '10'); // 10 days
assert.strictEqual(this.model.autoRotatePeriod, '240h');
assert.strictEqual(this.form.data.auto_rotate_period, '240h');
});
test('it renders edit form correctly when key has autoRotatePeriod=0', async function (assert) {
this.model.autoRotatePeriod = 0;
this.model.keys = {
this.form.data.auto_rotate_period = 0;
this.form.data.keys = {
1: 1684882652000,
};
await render(hbs`
<TransitEdit
@key={{this.model}}
@model={{this.model}}
@form={{this.form}}
@mode="edit"
@root={{this.backendCrumb}}
@preferAdvancedEdit={{false}}
@ -74,26 +74,25 @@ module('Integration | Component | transit-edit', function (hooks) {
assert.dom(SELECTORS.editForm).exists();
assert.dom(SELECTORS.ttlToggle).isNotChecked();
assert.strictEqual(this.model.autoRotatePeriod, 0);
assert.strictEqual(this.form.data.auto_rotate_period, 0);
await click(SELECTORS.ttlToggle);
assert.dom(SELECTORS.ttlToggle).isChecked();
assert.dom(SELECTORS.ttlValue).hasValue('30');
assert.strictEqual(this.model.autoRotatePeriod, '720h', 'model value changes with toggle');
assert.strictEqual(this.form.data.auto_rotate_period, '720h', 'model value changes with toggle');
await click(SELECTORS.ttlToggle);
assert.strictEqual(this.model.autoRotatePeriod, 0); // reverts to original value when toggled back on
assert.strictEqual(this.form.data.auto_rotate_period, '0s'); // reverts to original value when toggled back on
});
test('it renders edit form correctly when key has non-zero rotation period', async function (assert) {
this.model.autoRotatePeriod = '5h';
this.model.keys = {
this.form.data.auto_rotate_period = '5h';
this.form.data.keys = {
1: 1684882652000,
};
await render(hbs`
<TransitEdit
@key={{this.model}}
@model={{this.model}}
@form={{this.form}}
@mode="edit"
@root={{this.backendCrumb}}
@preferAdvancedEdit={{false}}
@ -104,12 +103,20 @@ module('Integration | Component | transit-edit', function (hooks) {
await click(SELECTORS.ttlToggle);
assert.dom(SELECTORS.ttlToggle).isNotChecked();
assert.strictEqual(this.model.autoRotatePeriod, 0, 'model value changes back to 0 when toggled off');
assert.strictEqual(
this.form.data.auto_rotate_period,
'0s',
'model value changes back to 0 when toggled off'
);
await click(SELECTORS.ttlToggle);
assert.strictEqual(this.model.autoRotatePeriod, '5h'); // reverts to original value when toggled back on
assert.strictEqual(
this.form.data.auto_rotate_period,
'5h',
'model value changes to original value when toggled back on'
);
await fillIn(SELECTORS.ttlValue, '10');
assert.strictEqual(this.model.autoRotatePeriod, '10h');
assert.strictEqual(this.form.data.auto_rotate_period, '10h');
});
});

View file

@ -3,52 +3,19 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { run } from '@ember/runloop';
import { resolve } from 'rsvp';
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, find, fillIn, blur, triggerEvent, waitFor } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { encodeString } from 'vault/utils/b64';
import waitForError from 'vault/tests/helpers/wait-for-error';
import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import codemirror, { setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const storeStub = Service.extend({
callArgs: null,
keyActionReturnVal: null,
rootKeyActionReturnVal: null,
adapterFor() {
const self = this;
return {
keyAction(action, { backend, id, payload }, options) {
self.set('callArgs', { action, backend, id, payload });
self.set('callArgsOptions', options);
const rootResp = { ...self.get('rootKeyActionReturnVal') };
const resp =
Object.keys(rootResp).length > 0
? rootResp
: {
data: { ...self.get('keyActionReturnVal') },
};
return resolve(resp);
},
};
},
});
import sinon from 'sinon';
module('Integration | Component | transit key actions', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
run(() => {
this.owner.unregister('service:store');
this.owner.register('service:store', storeStub);
this.storeService = this.owner.lookup('service:store');
});
});
test('it requires `key`', async function (assert) {
const promise = waitForError();
render(hbs`
@ -141,120 +108,144 @@ module('Integration | Component | transit key actions', function (hooks) {
});
async function doEncrypt(assert, actions = [], keyattrs = {}) {
const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat(actions) };
const keyDefaults = {
backend: 'transit',
id: 'akey',
supportedActions: ['encrypt'].concat(actions),
};
const key = { ...keyDefaults, ...keyattrs };
this.set('key', key);
this.set('selectedAction', 'encrypt');
this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' });
this.apiStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitEncrypt').resolves({
data: { ciphertext: 'secret' },
});
await render(hbs`
<TransitKeyActions @selectedAction={{this.selectedAction}} @key={{this.key}} />`);
<TransitKeyActions
@selectedAction={{this.selectedAction}}
@key={{this.key}}
/>
`);
let editor;
await waitFor('.cm-editor');
editor = codemirror('#plaintext-control');
setCodeEditorValue(editor, 'plaintext');
await click('button[type="submit"]');
assert.deepEqual(
this.storeService.callArgs,
{
action: 'encrypt',
backend: 'transit',
id: 'akey',
payload: {
plaintext: encodeString('plaintext'),
},
},
'passes expected args to the adapter'
assert.true(this.apiStub.calledOnce, 'calls the API to encrypt');
assert.true(
this.apiStub.calledWith('akey', 'transit', {
plaintext: encodeString('plaintext'),
}),
'passes expected args to transitEncrypt'
);
assert.strictEqual(find('[data-test-encrypted-value="ciphertext"]').innerText, 'secret');
// exit modal
await click('dialog button');
// Encrypt again, with pre-encoded value and checkbox selected
const preEncodedValue = encodeString('plaintext');
await waitFor('.cm-editor');
editor = codemirror('#plaintext-control');
setCodeEditorValue(editor, preEncodedValue);
await click('input[data-test-transit-input="encodedBase64"]');
await click('button[type="submit"]');
assert.deepEqual(
this.storeService.callArgs,
{
action: 'encrypt',
backend: 'transit',
id: 'akey',
payload: {
plaintext: preEncodedValue,
},
},
'passes expected args to the adapter'
assert.strictEqual(this.apiStub.callCount, 2, 'calls the API to encrypt again');
assert.true(
this.apiStub.secondCall.calledWith('akey', 'transit', {
plaintext: preEncodedValue,
}),
'passes pre-encoded value without re-encoding'
);
await click('dialog button');
}
test('it encrypts', doEncrypt);
test('it shows key version selection', async function (assert) {
const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat([]) };
const keyattrs = { keysForEncryption: [3, 2, 1], latestVersion: 3 };
const keyDefaults = {
backend: 'transit',
id: 'akey',
supportedActions: ['encrypt'],
};
const keyattrs = {
keysForEncryption: [3, 2, 1],
latestVersion: 3,
};
const key = { ...keyDefaults, ...keyattrs };
this.set('key', key);
this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' });
const encryptStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitEncrypt').resolves({
data: { ciphertext: 'secret' },
});
await render(hbs`
<TransitKeyActions @selectedAction="encrypt" @key={{this.key}} />`);
<TransitKeyActions
@selectedAction="encrypt"
@key={{this.key}}
/>
`);
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, 'plaintext');
assert.dom('#key_version').exists({ count: 1 }, 'it renders the key version selector');
await triggerEvent('#key_version', 'change');
await click('button[type="submit"]');
assert.deepEqual(
this.storeService.callArgs,
{
action: 'encrypt',
backend: 'transit',
id: 'akey',
payload: {
plaintext: encodeString('plaintext'),
key_version: '0',
},
},
assert.true(encryptStub.calledOnce, 'calls transitEncrypt');
assert.true(
encryptStub.calledWith('akey', 'transit', {
plaintext: encodeString('plaintext'),
key_version: '0',
}),
'includes key_version in the payload'
);
});
test('it hides key version selection', async function (assert) {
const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat([]) };
const keyDefaults = {
backend: 'transit',
id: 'akey',
supportedActions: ['encrypt'],
};
const keyattrs = { keysForEncryption: [1] };
const key = { ...keyDefaults, ...keyattrs };
this.set('key', key);
this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' });
await render(hbs`
<TransitKeyActions @selectedAction="encrypt" @key={{this.key}} />`);
<TransitKeyActions
@selectedAction="encrypt"
@key={{this.key}}
/>
`);
await waitFor('.cm-editor');
const editor = codemirror('#plaintext-control');
setCodeEditorValue(editor, 'plaintext');
assert.dom('#key_version').doesNotExist('it does not render the selector when there is only one key');
});
test('it does not carry ciphertext value over to decrypt', async function (assert) {
assert.expect(4);
const plaintext = 'not so secret';
await doEncrypt.call(this, assert, ['decrypt']);
this.set('storeService.keyActionReturnVal', { plaintext });
this.set('selectedAction', 'decrypt');
await waitFor('.cm-editor');
const editor = codemirror('#ciphertext-control');
assert.strictEqual(getCodeEditorValue(editor), '', 'does not prefill ciphertext value');
});
const setupExport = async function () {
this.set('key', {
backend: 'transit',
@ -268,23 +259,30 @@ module('Integration | Component | transit key actions', function (hooks) {
};
test('it can export a key:default behavior', async function (assert) {
this.set('storeService.rootKeyActionReturnVal', { wrap_info: { token: 'wrapped-token' } });
const exportStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitExportKey').resolves({
wrap_info: { token: 'wrapped-token' },
});
await setupExport.call(this);
await click('button[type="submit"]');
assert.true(exportStub.calledOnce);
assert.deepEqual(
this.storeService.callArgs,
{
action: 'export',
backend: 'transit',
id: 'akey',
payload: {
param: ['encryption'],
exportStub.firstCall.args,
[
'akey',
'encryption-key',
'transit',
{
headers: {
'X-Vault-Wrap-TTL': '30m',
},
wrapTTL: '30m',
},
},
'passes expected args to the adapter'
],
'passes expected args to api service'
);
assert.strictEqual(this.storeService.callArgsOptions.wrapTTL, '30m', 'passes value for wrapTTL');
assert.strictEqual(
find('[data-test-encrypted-value="export"]').innerText,
'wrapped-token',
@ -293,77 +291,112 @@ module('Integration | Component | transit key actions', function (hooks) {
});
test('it can export a key:unwrapped behavior', async function (assert) {
const response = { keys: { a: 'key' } };
this.set('storeService.keyActionReturnVal', response);
const response = {
data: {
keys: { a: 'key' },
type: 'encryption',
name: 'akey',
},
};
sinon.stub(this.owner.lookup('service:api').secrets, 'transitExportKey').resolves(response);
await setupExport.call(this);
await click('[data-test-toggle-label="Wrap response"]');
await click(GENERAL.submitButton);
assert.dom('#transit-export-modal').exists('Modal opens after export');
assert.deepEqual(
JSON.parse(find('[data-test-encrypted-value="export"]').innerText),
response,
{
keys: { a: 'key' },
type: 'encryption',
name: 'akey',
},
'prints json response'
);
});
test('it can export a key: unwrapped, single version', async function (assert) {
const response = { keys: { a: 'key' } };
this.set('storeService.keyActionReturnVal', response);
const response = {
data: {
keys: { a: 'key' },
type: 'encryption',
name: 'akey',
},
};
const exportVersionStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'transitExportKeyVersion')
.resolves(response);
await setupExport.call(this);
await click('[data-test-toggle-label="Wrap response"]');
await click('#exportVersion');
await triggerEvent('#exportVersion', 'change');
await click(GENERAL.submitButton);
assert.dom('#transit-export-modal').exists('Modal opens after export');
assert.deepEqual(
JSON.parse(find('[data-test-encrypted-value="export"]').innerText),
response,
{
keys: { a: 'key' },
type: 'encryption',
name: 'akey',
},
'prints json response'
);
assert.true(exportVersionStub.calledOnce);
assert.deepEqual(
this.storeService.callArgs,
{
action: 'export',
backend: 'transit',
id: 'akey',
payload: {
param: ['encryption', 1],
},
},
'passes expected args to the adapter'
exportVersionStub.firstCall.args,
['akey', 'encryption-key', 1, 'transit', {}],
'passes expected args to api service'
);
});
test('it includes algorithm param for HMAC', async function (assert) {
// Return mocked data so a11y-testing doesn't get mad about empty copy button contents
this.set('storeService.rootKeyActionReturnVal', { data: { hmac: 'vault:v1:hmac-token' } });
const hmacStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitGenerateHmac').resolves({
data: {
hmac: 'vault:v1:hmac-token',
},
});
this.set('key', {
backend: 'transit',
id: 'akey',
supportedActions: ['hmac'],
validKeyVersions: [1],
});
await render(hbs`
<TransitKeyActions @key={{this.key}} @selectedAction="hmac" />`);
<TransitKeyActions
@key={{this.key}}
@selectedAction="hmac"
/>
`);
await fillIn('#algorithm', 'sha2-384');
await blur('#algorithm');
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, 'plaintext');
await click('input[data-test-transit-input="encodedBase64"]');
await click(GENERAL.submitButton);
assert.true(hmacStub.calledOnce, 'calls transitGenerateHmac');
assert.deepEqual(
this.storeService.callArgs,
{
action: 'hmac',
backend: 'transit',
id: 'akey',
payload: {
hmacStub.firstCall.args,
[
'akey',
'transit',
{
algorithm: 'sha2-384',
input: 'plaintext',
},
},
'passes expected args to the adapter'
],
'passes expected args to the API'
);
});
});