Ember Data Migration - Keymanagement Provider views | VAULT-44905 (#14816) (#14828)

* VAULT-44904 - edm for keymgmt key views



* resolved pr review comments



* moved distribution fields to component and added util tests

* fixed review comments

* updated key-edit component to use form and fixed failing tests

* VAULT-44905 - edm keymgmt provider views

---------

Co-authored-by: mohit-hashicorp <mohit.ojha@hashicorp.com>
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Vault Automation 2026-05-15 11:39:49 -06:00 committed by GitHub
parent 86e46a1691
commit 3679be155f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 726 additions and 159 deletions

View file

@ -15,16 +15,15 @@
<div class="field" data-test-keymgmt-dist-key>
<SearchSelect
@id="key"
@models={{array "keymgmt/key"}}
@options={{this.keyOptions}}
@onChange={{this.handleKeySelect}}
@passObject={{true}}
@inputValue={{this.formData.key}}
@subText="Type to use the name of an existing key that youd like to add to this provider, or to create one."
@wildcardLabel="key"
@label="Key name"
@fallbackComponent="string-list"
@fallbackComponent={{unless this.canListKeys "string-list"}}
@selectLimit="1"
@backend={{@backend}}
@disallowNewItems={{false}}
>
{{#if (and this.validMatchError.key (not this.isNewKey))}}
@ -94,15 +93,14 @@
<div class="field">
<SearchSelect
@id="provider"
@models={{array "keymgmt/provider"}}
@options={{this.providerOptions}}
@onChange={{this.handleProvider}}
@passObject={{false}}
@inputValue={{this.formData.provider}}
@subText="Select a provider in Vault. If it doesnt exist yet, youll need to add it first."
@label="Provider"
@fallbackComponent="input-search"
@fallbackComponent={{unless this.canListProviders "input-search"}}
@selectLimit="1"
@backend={{@backend}}
@disallowNewItems={{true}}
data-test-keymgmt-dist-provider
>

View file

@ -9,7 +9,11 @@ import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript';
import {
KeyManagementUpdateKeyRequestTypeEnum,
SecretsApiKeyManagementListKeysListEnum,
SecretsApiKeyManagementListKmsProvidersListEnum,
} from '@hashicorp/vault-client-typescript';
const KEY_TYPES = Object.values(KeyManagementUpdateKeyRequestTypeEnum);
@ -21,7 +25,7 @@ const KEY_TYPES = Object.values(KeyManagementUpdateKeyRequestTypeEnum);
* ```js
* <KeymgmtDistribute @backend="keymgmt" @key="my-key" @provider="my-kms" />
* ```
* @param {string} backend - name of backend, which will be the basis of other store queries
* @param {string} backend - name of backend, which is used in API requests
* @param {string} [key] - key is the name of the existing key which is being distributed. Will hide the key field in UI
* @param {string} [provider] - provider is the name of the existing provider which is being distributed to. Will hide the provider field in UI
*/
@ -39,14 +43,16 @@ const VALID_TYPES_BY_PROVIDER = {
azurekeyvault: ['rsa-2048', 'rsa-3072', 'rsa-4096'],
};
export default class KeymgmtDistribute extends Component {
@service store;
@service api;
@service flashMessages;
@service router;
@tracked keyModel;
@tracked isNewKey = false;
@tracked keyOptions = [];
@tracked canListKeys = true;
@tracked providerType;
@tracked providerOptions = [];
@tracked canListProviders = true;
@tracked formData;
@tracked formErrors;
@ -59,9 +65,13 @@ export default class KeymgmtDistribute extends Component {
// Side effects to get types of key or provider passed in
if (this.args.provider) {
this.getProviderType(this.args.provider);
} else {
this.fetchProviderOptions();
}
if (this.args.key) {
this.getKeyInfo(this.args.key);
} else {
this.fetchKeyOptions();
}
this.formData.operations = [];
}
@ -145,19 +155,52 @@ export default class KeymgmtDistribute extends Component {
}
}
async fetchKeyOptions() {
try {
const { keys } = await this.api.secrets.keyManagementListKeys(
this.args.backend,
SecretsApiKeyManagementListKeysListEnum.TRUE
);
this.keyOptions = (keys || []).map((name) => ({ id: name, name }));
this.canListKeys = true;
} catch (error) {
const { status } = await this.api.parseError(error);
if (status === 403) {
this.canListKeys = false;
}
this.keyOptions = [];
}
}
async getProviderType(id) {
if (!id) {
this.providerType = '';
return;
}
const provider = await this.store
.queryRecord('keymgmt/provider', {
backend: this.args.backend,
id,
})
.catch(() => {});
this.providerType = provider?.provider;
try {
const { data } = await this.api.secrets.keyManagementReadKmsProvider(id, this.args.backend);
this.providerType = data.provider;
} catch {
this.providerType = '';
}
}
async fetchProviderOptions() {
try {
const { keys } = await this.api.secrets.keyManagementListKmsProviders(
this.args.backend,
SecretsApiKeyManagementListKmsProvidersListEnum.TRUE
);
this.providerOptions = (keys || []).map((name) => ({ id: name, name }));
this.canListProviders = true;
} catch (error) {
const { status } = await this.api.parseError(error);
if (status === 403) {
this.canListProviders = false;
}
this.providerOptions = [];
}
}
destroyKey() {
@ -224,13 +267,33 @@ export default class KeymgmtDistribute extends Component {
@action
async handleKeySelect(selected) {
const selectedKey = selected[0] || null;
if (!selectedKey) {
let keyName;
let isNew = false;
if (typeof selected === 'string') {
keyName = selected;
} else if (Array.isArray(selected)) {
const selectedKey = selected[0] || null;
if (!selectedKey) {
this.formData.key = null;
return this.destroyKey();
}
if (typeof selectedKey === 'string') {
keyName = selectedKey;
} else {
keyName = selectedKey.id;
isNew = !!selectedKey.isNew;
}
}
if (!keyName) {
this.formData.key = null;
return this.destroyKey();
}
this.formData.key = selectedKey.id;
return this.getKeyInfo(selectedKey.id, selectedKey.isNew);
this.formData.key = keyName;
return this.getKeyInfo(keyName, isNew);
}
@task

View file

@ -9,7 +9,7 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { isValidProvider } from 'vault/utils/keymgmt-provider-validator';
import { isValidProvider } from 'vault/utils/keymgmt-provider-utils';
/**
* @module KeymgmtKeyEdit

View file

@ -10,20 +10,24 @@
</Page::Header>
{{#if this.isDistributing}}
<Keymgmt::Distribute @backend={{@model.backend}} @provider={{@model.id}} @onClose={{fn (mut this.isDistributing) false}} />
<Keymgmt::Distribute
@backend={{@form.data.backend}}
@provider={{@form.data.name}}
@onClose={{fn (mut this.isDistributing) false}}
/>
{{else}}
{{#if this.isShowing}}
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs" aria-label="navigation for managing providers">
<ul>
<li class={{unless this.viewingKeys "active"}} data-test-kms-provider-tab="details">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab=""}}>
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@form.data.name}} @query={{hash tab=""}}>
Details
</LinkTo>
</li>
{{#if @model.canListKeys}}
{{#if @capabilities.canListKeys}}
<li class={{if this.viewingKeys "active"}} data-test-kms-provider-tab="keys">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@form.data.name}} @query={{hash tab="keys"}}>
Keys
</LinkTo>
</li>
@ -34,10 +38,10 @@
{{#unless this.viewingKeys}}
<Toolbar
data-test-kms-provider-details-actions
aria-label="options for managing for key/management provider {{@model.id}}"
aria-label="options for managing for key/management provider {{@form.data.name}}"
>
<ToolbarActions>
{{#if @model.canDelete}}
{{#if @capabilities.canDelete}}
<ConfirmAction
@buttonText="Delete provider"
class="toolbar-button"
@ -45,20 +49,20 @@
@confirmTitle="Delete this provider?"
@onConfirmAction={{this.onDelete}}
@disabledMessage={{if
@model.keys.length
this.keyCount
(concat
"This provider cannot be deleted until all "
@model.keys.length
this.keyCount
" key(s) distributed to it are revoked. This can be done from the Keys tab."
)
}}
data-test-kms-provider-delete
/>
{{/if}}
{{#if (and @model.canDelete (or @model.canListKeys @model.canEdit))}}
{{#if (and @capabilities.canDelete (or @capabilities.canListKeys @capabilities.canEdit))}}
<div class="toolbar-separator"></div>
{{/if}}
{{#if (or @model.canListKeys @model.canCreateKeys)}}
{{#if (or @capabilities.canListKeys @capabilities.canCreateKeys)}}
<Hds::Button
@text="Distribute key"
@icon="chevron-right"
@ -69,13 +73,13 @@
data-test-distribute-key
/>
{{/if}}
{{#if @model.canEdit}}
{{#if @capabilities.canEdit}}
<ToolbarSecretLink
@secret={{@model.id}}
@secret={{@form.data.name}}
@mode="edit"
@replace={{true}}
@queryParams={{hash itemType="provider"}}
disabled={{(not @model.canEdit)}}
disabled={{(not @capabilities.canEdit)}}
>
Update credentials
</ToolbarSecretLink>
@ -87,16 +91,21 @@
<form aria-label="update credentials" {{on "submit" this.onSave}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{#if this.isCreating}}
{{#each @model.createFields as |attr index|}}
{{#each @form.createFields as |attr index|}}
{{#if (eq index 2)}}
<div class="has-border-top-light">
<h2 class="title is-5 has-top-margin-l has-bottom-margin-m" data-test-kms-provider-config-title>
Provider configuration
</h2>
</div>
{{#if @model.provider}}
{{#if @form.data.provider}}
{{! Only show last field if provider selected }}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
<FormField
@attr={{attr}}
@model={{@form}}
@modelValidations={{this.modelValidations}}
@onChange={{this.onFieldChange}}
/>
{{else}}
<Hds::ApplicationState class="top-padding-32 bottom-padding-32 is-marginless" as |A|>
<A.Header @title="No provider selected" @titleTag="h2" data-test-empty-state-title />
@ -104,7 +113,12 @@
</Hds::ApplicationState>
{{/if}}
{{else}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
<FormField
@attr={{attr}}
@model={{@form}}
@modelValidations={{this.modelValidations}}
@onChange={{this.onFieldChange}}
/>
{{/if}}
{{/each}}
{{/if}}
@ -116,8 +130,13 @@
Old credentials cannot be read and will be lost as soon as new ones are added. Do this carefully.
</p>
{{/unless}}
{{#each @model.credentialFields as |cred|}}
<FormField @attr={{cred}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{#each @form.credentialFields as |cred|}}
<FormField
@attr={{cred}}
@model={{@form}}
@modelValidations={{this.modelValidations}}
@onChange={{this.onFieldChange}}
/>
{{/each}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
@ -133,7 +152,7 @@
@text="Cancel"
@color="secondary"
@route={{if this.isCreating @root.path "vault.cluster.secrets.backend.show"}}
@model={{if this.isCreating @root.model @model.id}}
@model={{if this.isCreating @root.model @form.data.name}}
@query={{if this.isCreating (hash tab="provider") (hash itemType="provider")}}
disabled={{this.saveTask.isRunning}}
data-test-kms-provider-cancel
@ -147,8 +166,8 @@
<div class="has-bottom-margin-s">
{{#if this.viewingKeys}}
{{#let (options-for-backend "keymgmt" "key") as |options|}}
{{#if @model.keys.meta.total}}
{{#each @model.keys as |key|}}
{{#if @form.data.keys.meta.total}}
{{#each @form.data.keys as |key|}}
<SecretList::Item
@item={{key}}
@backendModel={{@root}}
@ -161,11 +180,11 @@
/>
{{/each}}
<Hds::Pagination::Numbered
@currentPage={{@model.keys.meta.currentPage}}
@currentPageSize={{@model.keys.meta.pageSize}}
@currentPage={{@form.data.keys.meta.currentPage}}
@currentPageSize={{@form.data.keys.meta.pageSize}}
@route="vault.cluster.secrets.backend.show"
@showSizeSelector={{false}}
@totalItems={{@model.keys.meta.total}}
@totalItems={{@form.data.keys.meta.total}}
@onPageChange={{perform this.fetchKeys}}
/>
@ -185,28 +204,28 @@
{{/if}}
{{/let}}
{{else}}
{{#each @model.showFields as |attr|}}
{{#if attr.hasBlock}}
<InfoTableRow @label={{attr.label}} @value={{attr.value}} data-test-kms-provider-field={{attr.name}}>
{{#if attr.icon}}
<Icon @name={{attr.icon}} class="icon" />
{{/if}}
{{#if attr.isLink}}
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
{{attr.value}}
{{#each this.displayFields as |field|}}
{{#if (eq field "provider")}}
<InfoTableRow
@label={{this.label field}}
@value={{this.providerTypeName @form.data.provider}}
data-test-kms-provider-field={{field}}
>
<Icon @name={{this.providerIcon @form.data.provider}} class="icon" />
{{this.providerTypeName @form.data.provider}}
</InfoTableRow>
{{else if (eq field "keys")}}
<InfoTableRow @label={{this.label field}} @value={{this.keysValue}} data-test-kms-provider-field={{field}}>
{{#if this.keyCount}}
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@form.data.name}} @query={{hash tab="keys"}}>
{{this.keysValue}}
</LinkTo>
{{else}}
{{attr.value}}
{{this.keysValue}}
{{/if}}
</InfoTableRow>
{{else}}
<InfoTableRow
@alwaysRender={{true}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
@defaultShown={{attr.options.defaultShown}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
/>
<InfoTableRow @alwaysRender={{true}} @label={{this.label field}} @value={{get @form.data field}} />
{{/if}}
{{/each}}
{{/if}}

View file

@ -9,7 +9,9 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { removeFromArray } from 'vault/helpers/remove-from-array';
import { paginate } from 'core/utils/paginate-list';
import { getKeymgmtProviderIcon } from 'vault/utils/keymgmt-provider-utils';
import { SecretsApiKeyManagementListKeysInKmsProviderListEnum } from '@hashicorp/vault-client-typescript';
/**
* @module KeymgmtProviderEdit
@ -17,17 +19,23 @@ import { removeFromArray } from 'vault/helpers/remove-from-array';
*
* @example
* ```js
* <KeymgmtProviderEdit @model={model} @mode="show" />
* <KeymgmtProviderEdit @form={form} @mode="show" />
* ```
* @param {object} model - model is the data from the store
* @param {object} form - form is the data backing create/edit/show
* @param {string} mode - mode controls which view is shown on the component - show | create |
* @param {string} [tab] - Options are "details" or "keys" for the show mode only
*/
export default class KeymgmtProviderEdit extends Component {
@service api;
@service capabilities;
@service router;
@service flashMessages;
@tracked modelValidations;
@tracked invalidFormAlert;
@tracked isDistributing = false;
constructor() {
super(...arguments);
// key count displayed in details tab and keys are listed in keys tab
@ -36,25 +44,62 @@ export default class KeymgmtProviderEdit extends Component {
}
}
@tracked modelValidations;
displayFields = ['name', 'provider', 'key_collection', 'keys'];
breadcrumbs = [
{
label: 'Vault',
icon: 'vault',
route: 'vault.cluster.dashboard',
},
{
label: 'Secrets engines',
route: 'vault.cluster.secrets.backends',
},
{
label: this.args.model.backend,
route: 'vault.cluster.secrets.backend.list-root',
model: this.args.model.backend,
},
{ label: this.title },
];
label(field) {
const labels = {
name: 'Provider name',
provider: 'Type',
key_collection: 'Key Vault instance name',
keys: 'Keys',
};
return labels[field] || field;
}
providerTypeName(provider) {
return (
{
azurekeyvault: 'Azure Key Vault',
awskms: 'AWS Key Management Service',
gcpckms: 'Google Cloud Key Management Service',
}[provider] || provider
);
}
providerIcon(provider) {
return getKeymgmtProviderIcon(provider);
}
get keyCount() {
return this.args.form.data.keys?.length || 0;
}
get keysValue() {
if (this.keyCount) {
return `${this.keyCount} ${this.keyCount > 1 ? 'keys' : 'key'}`;
}
return this.args.capabilities?.canListKeys ? 'None' : 'You do not have permission to list keys';
}
get breadcrumbs() {
return [
{
label: 'Vault',
icon: 'vault',
route: 'vault.cluster.dashboard',
},
{
label: 'Secrets engines',
route: 'vault.cluster.secrets.backends',
},
{
label: this.args.form.data.backend,
route: 'vault.cluster.secrets.backend.list-root',
model: this.args.form.data.backend,
},
{ label: this.title },
];
}
get title() {
if (this.isDistributing) {
@ -69,7 +114,7 @@ export default class KeymgmtProviderEdit extends Component {
}
get subtitle() {
return this.isShowing ? this.args.model.id : '';
return this.isShowing ? this.args.form.data.name : '';
}
get isShowing() {
@ -85,30 +130,81 @@ export default class KeymgmtProviderEdit extends Component {
@task
@waitFor
*saveTask() {
const { model } = this.args;
const { form } = this.args;
const { backend, name, provider, key_collection, credentials } = form.data;
try {
yield model.save();
this.router.transitionTo('vault.cluster.secrets.backend.show', model.id, {
yield this.api.secrets.keyManagementWriteKmsProvider(name, backend, {
provider,
key_collection,
credentials,
});
this.router.transitionTo('vault.cluster.secrets.backend.show', name, {
queryParams: { itemType: 'provider' },
});
} catch (error) {
this.flashMessages.danger(error.errors.join('. '));
const { message } = yield this.api.parseError(error);
this.flashMessages.danger(message);
}
}
@task
@waitFor
*fetchKeys(page) {
const { form, capabilities } = this.args;
const backend = form.data.backend;
const providerName = form.data.name;
if (!capabilities?.canListKeys) {
form.data.keys = [];
return;
}
try {
yield this.args.model.fetchKeys(page);
const { keys } = yield this.api.secrets.keyManagementListKeysInKmsProvider(
providerName,
backend,
SecretsApiKeyManagementListKeysInKmsProviderListEnum.TRUE
);
const keyNames = keys || [];
const pathsToFetch = keyNames.map((keyName) =>
this.capabilities.pathFor('keymgmtKey', { backend, name: keyName })
);
const keyCapabilities = yield this.capabilities.fetch(pathsToFetch);
const keysList = keyNames.map((keyName) => {
const keyPath = this.capabilities.pathFor('keymgmtKey', { backend, name: keyName });
return {
id: keyName,
name: keyName,
backend,
icon: 'key',
type: 'key',
canRead: keyCapabilities[keyPath]?.canRead || false,
canEdit: keyCapabilities[keyPath]?.canUpdate || false,
canDelete: keyCapabilities[keyPath]?.canDelete || false,
};
});
form.data.keys = paginate(keysList, { page: Number(page) || 1 });
} catch (error) {
this.flashMessages.danger(error.errors.join('. '));
const { message, status } = yield this.api.parseError(error);
if (status === 404) {
form.data.keys = [];
} else {
this.flashMessages.danger(message);
}
}
}
@action
async onSave(event) {
event.preventDefault();
const { isValid, state } = await this.args.model.validate();
const { isValid, state, invalidFormMessage } = this.args.form.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
this.modelValidations = null;
this.saveTask.perform();
@ -116,24 +212,38 @@ export default class KeymgmtProviderEdit extends Component {
this.modelValidations = state;
}
}
@action
onFieldChange(path) {
if (path !== 'provider') return;
// Clear stale validation state on updating Type field so old provider errors do not persist.
this.modelValidations = null;
this.invalidFormAlert = null;
}
@action
async onDelete() {
try {
const { model, root } = this.args;
await model.destroyRecord();
const { form, root } = this.args;
await this.api.secrets.keyManagementDeleteKmsProvider(form.data.name, form.data.backend);
this.router.transitionTo(root.path, root.model, { queryParams: { tab: 'provider' } });
} catch (error) {
this.flashMessages.danger(error.errors.join('. '));
const { message } = await this.api.parseError(error);
this.flashMessages.danger(message);
}
}
@action
async onDeleteKey(model) {
try {
const providerKeys = removeFromArray(this.args.model.keys, model);
await model.destroyRecord();
this.args.model.keys = providerKeys;
const backend = this.args.form.data.backend;
const providerName = this.args.form.data.name;
await this.api.secrets.keyManagementDeleteKeyInKmsProvider(model.id, providerName, backend);
this.fetchKeys.perform(this.args.form.data.keys?.meta.currentPage || 1);
} catch (error) {
this.flashMessages.danger(error.errors.join('. '));
const { message } = await this.api.parseError(error);
this.flashMessages.danger(message);
}
}
}

View file

@ -53,20 +53,27 @@ export default Controller.extend(ListController, BackendCrumbMixin, {
});
},
delete(item) {
async delete(item) {
const name = item.id;
// Handle keymgmt keys (plain objects from API service)
// Handle keymgmt list items (plain objects from API service)
if (this.backendType === 'keymgmt' && item.type === 'key') {
this.api.secrets
.keyManagementDeleteKey(name, item.backend)
.then(() => {
this.flashMessages.success(`${name} was successfully deleted.`);
this.send('reload');
})
.catch(async (e) => {
const { message } = await this.api.parseError(e);
this.flashMessages.danger(message);
});
try {
await this.api.secrets.keyManagementDeleteKey(name, item.backend);
this.flashMessages.success(`${name} was successfully deleted.`);
this.send('reload');
} catch (e) {
const { message } = await this.api.parseError(e);
this.flashMessages.danger(message);
}
} else if (this.backendType === 'keymgmt' && item.type === 'provider') {
try {
await this.api.secrets.keyManagementDeleteKmsProvider(name, item.backend);
this.flashMessages.success(`${name} was successfully deleted.`);
this.send('reload');
} catch (e) {
const { message } = await this.api.parseError(e);
this.flashMessages.danger(message);
}
} else {
// Handle Ember Data models
item

View file

@ -0,0 +1,161 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Validations } from 'vault/app-types';
import { get } from '@ember/object';
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import FormFieldGroup from 'vault/utils/forms/field-group';
import {
KeyManagementWriteKmsProviderRequest,
KeyManagementWriteKmsProviderRequestProviderEnum,
} from '@hashicorp/vault-client-typescript';
type ProviderFormData = KeyManagementWriteKmsProviderRequest & {
name: string;
keys?: Array<Record<string, unknown>>;
};
type ProviderCredentialValidationModel = {
provider?: keyof typeof CRED_PROPS;
credentialProps?: string[];
credentials?: Record<string, string | undefined | null>;
};
interface Validator {
message: string;
validator(model: ProviderCredentialValidationModel): boolean | string;
}
export const PROVIDER_TYPES = Object.values(KeyManagementWriteKmsProviderRequestProviderEnum);
export const CRED_PROPS = {
azurekeyvault: ['client_id', 'client_secret', 'tenant_id'],
awskms: ['access_key', 'secret_key', 'session_token', 'endpoint'],
gcpckms: ['service_account_file'],
};
const CRED_PROPS_LABEL = {
client_id: 'Client ID',
client_secret: 'Client secret',
tenant_id: 'Tenant ID',
access_key: 'Access key',
secret_key: 'Secret key',
session_token: 'Session token',
endpoint: 'Endpoint',
service_account_file: 'Service account file',
};
export const OPTIONAL_CRED_PROPS = ['session_token', 'endpoint'];
export default class KeymgmtProviderForm extends Form<ProviderFormData> {
icon = 'key';
idPrefix = 'provider/';
type = 'provider';
constructor(...args: ConstructorParameters<typeof Form<ProviderFormData>>) {
super(...args);
// Provider read responses do not include credentials, but the form binds nested
// values like "credentials.client_id". Ensure the parent object always exists.
if (!this.data.credentials) {
this.data.credentials = {};
}
}
get credentialProps() {
const provider = this.data.provider;
if (!provider) return [];
return CRED_PROPS[provider as KeyManagementWriteKmsProviderRequestProviderEnum] || [];
}
get createFields() {
return [
new FormField('provider', 'string', {
label: 'Type',
subText: 'Choose the provider type.',
possibleValues: PROVIDER_TYPES,
noDefault: true,
}),
new FormField('name', 'string', {
label: 'Provider name',
subText:
'This is the name of the provider that will be displayed in Vault. This cannot be edited later.',
}),
new FormField('key_collection', 'string', {
label: 'Key Vault instance name',
subText: 'The name of a Key Vault instance must be supplied. This cannot be edited later.',
}),
];
}
get credentialFields() {
return this.credentialProps.map((prop) => {
const options = {
label: CRED_PROPS_LABEL[prop as keyof typeof CRED_PROPS_LABEL],
...(prop === 'service_account_file'
? { subText: 'The path to a Google service account key file, not the file itself.' }
: {}),
};
return new FormField(`credentials.${prop}`, 'string', options);
});
}
get formFieldGroups() {
const groups: FormFieldGroup[] = [];
if (this.isNew) {
// Create mode: provider type, name, key_collection, credentials
groups.push(new FormFieldGroup('default', [...this.createFields]));
// Add credential fields based on provider type
if (this.credentialProps.length > 0) {
groups.push(new FormFieldGroup('credentials', this.credentialFields));
}
} else {
// Edit mode: only credentials if any
if (this.credentialProps.length > 0) {
groups.push(new FormFieldGroup('credentials', this.credentialFields));
}
}
return groups;
}
// since we have dynamic credential attributes based on provider we need a dynamic presence validator
// add validators for all cred props and return true for value if not associated with selected provider
get credValidators() {
return (Object.keys(CRED_PROPS) as Array<keyof typeof CRED_PROPS>).reduce(
(obj, providerKey) => {
CRED_PROPS[providerKey].forEach((prop: string) => {
if (!OPTIONAL_CRED_PROPS.includes(prop)) {
obj[`credentials.${prop}`] = [
{
message: `${CRED_PROPS_LABEL[prop as keyof typeof CRED_PROPS_LABEL]} is required`,
validator(model: ProviderCredentialValidationModel) {
const selectedProvider = model.provider;
if (!selectedProvider) return true;
if (!CRED_PROPS[selectedProvider]?.includes(prop)) return true;
const value = get(model, `credentials.${prop}`);
return typeof value === 'string' ? value.trim().length > 0 : Boolean(value);
},
},
];
}
});
return obj;
},
{} as Record<string, Validator[]>
);
}
validations: Validations = {
name: [{ type: 'presence', message: 'Provider name is required' }],
key_collection: [{ type: 'presence', message: 'Key Vault instance name is required' }],
...this.credValidators,
};
}

View file

@ -7,6 +7,7 @@ import { hash } from 'rsvp';
import { service } from '@ember/service';
import EditBase from './secret-edit';
import KeymgmtKeyForm from 'vault/forms/keymgmt/key';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript';
const secretModel = (store, backend, key) => {
@ -40,6 +41,14 @@ export default EditBase.extend({
return new KeymgmtKeyForm(defaultValues, { isNew: true });
}
if (modelType === 'keymgmt/provider') {
const defaultValues = {
backend,
credentials: {},
};
return new KeymgmtProviderForm(defaultValues, { isNew: true });
}
if (modelType === 'role-ssh') {
return this.store.createRecord(modelType, { keyType: 'ca' });
}

View file

@ -14,9 +14,13 @@ import engineDisplayData from 'vault/helpers/engines-display-data';
import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends';
import { getEnginePathParam } from 'vault/utils/backend-route-helpers';
import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers';
import { getKeymgmtProviderIcon } from 'vault/utils/keymgmt-provider-utils';
import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers';
import { normalizePath } from 'vault/utils/path-encoding-helpers';
import { SecretsApiKeyManagementListKeysListEnum } from '@hashicorp/vault-client-typescript';
import {
SecretsApiKeyManagementListKeysListEnum,
SecretsApiKeyManagementListKmsProvidersListEnum,
} from '@hashicorp/vault-client-typescript';
const SUPPORTED_BACKENDS = supportedSecretBackends();
@ -146,6 +150,60 @@ export default Route.extend({
}
},
async fetchProvidersWithCapabilities(backend) {
const { keys: providerNames } = await this.api.secrets.keyManagementListKmsProviders(
backend,
SecretsApiKeyManagementListKmsProvidersListEnum.TRUE
);
const providersWithData = await Promise.all(
(providerNames || []).map(async (providerName) => {
const { data } = await this.api.secrets.keyManagementReadKmsProvider(providerName, backend);
return {
...data,
id: providerName,
name: providerName,
backend,
type: 'provider',
icon: getKeymgmtProviderIcon(data.provider),
};
})
);
const pathsToFetch = providersWithData.map((provider) =>
this.capabilitiesService.pathFor('keymgmtProvider', { backend, id: provider.id })
);
const capabilities = await this.capabilitiesService.fetch(pathsToFetch);
const providersList = providersWithData.map((provider) => {
const providerPath = this.capabilitiesService.pathFor('keymgmtProvider', {
backend,
id: provider.id,
});
return {
...provider,
canRead: capabilities[providerPath]?.canRead || false,
canEdit: capabilities[providerPath]?.canUpdate || false,
canDelete: capabilities[providerPath]?.canDelete || false,
};
});
return { providersList, capabilities };
},
async fetchKeymgmtProviders(backend, page, pageFilter) {
try {
const { providersList } = await this.fetchProvidersWithCapabilities(backend);
return paginate(providersList, { page, filter: pageFilter });
} catch (error) {
const { status } = await this.api.parseError(error);
if (status === 404) {
return [];
}
throw error;
}
},
async model(params) {
const secret = this.secretParam() || '';
const backend = getEnginePathParam(this);
@ -154,11 +212,15 @@ export default Route.extend({
const modelType = this.getModelType(effectiveType, params.tab);
// Handle keymgmt keys with API service
const isKeymgmtKeys = effectiveType === 'keymgmt' && params.tab !== 'provider';
let secrets;
if (isKeymgmtKeys) {
secrets = await this.fetchKeymgmtKeys(backend, getValidPage(params.page), params.pageFilter);
if (effectiveType === 'keymgmt') {
const page = getValidPage(params.page);
const filter = params.pageFilter;
secrets =
params.tab === 'provider'
? await this.fetchKeymgmtProviders(backend, page, filter)
: await this.fetchKeymgmtKeys(backend, page, filter);
this.set('has404', false);
} else {
try {

View file

@ -13,8 +13,9 @@ import { keyIsFolder, parentKeyForKey } from 'core/utils/key-utils';
import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers';
import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers';
import { getBackendEffectiveType, getEnginePathParam } from 'vault/utils/backend-route-helpers';
import { isValidProvider } from 'vault/utils/keymgmt-provider-validator';
import { isValidProvider } from 'vault/utils/keymgmt-provider-utils';
import KeymgmtKeyForm from 'vault/forms/keymgmt/key';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
import { SecretsApiKeyManagementListKmsProvidersForKeyListEnum } from '@hashicorp/vault-client-typescript';
/**
@ -250,6 +251,44 @@ export default Route.extend({
};
},
async fetchKeymgmtProvider(backend, name) {
const { data } = await this.api.secrets.keyManagementReadKmsProvider(name, backend);
const form = new KeymgmtProviderForm(
{
...data,
name,
backend,
keys: [],
},
{ isNew: false }
);
return form;
},
async fetchKeymgmtProviderCapabilities(backend, name) {
const providerPath = this.capabilitiesService.pathFor('keymgmtProvider', { backend, id: name });
const providersPath = this.capabilitiesService.pathFor('keymgmtProviders', { backend });
const providerKeysPath = this.capabilitiesService.pathFor('keymgmtProviderKeys', { backend, id: name });
const capabilities = await this.capabilitiesService.fetch([
providerPath,
providersPath,
providerKeysPath,
]);
return {
canDelete: capabilities[providerPath]?.canDelete,
canUpdate: capabilities[providerPath]?.canUpdate,
canEdit: capabilities[providerPath]?.canUpdate,
canRead: capabilities[providerPath]?.canRead,
canList: capabilities[providersPath]?.canList,
canListKeys: capabilities[providerKeysPath]?.canList,
canCreateKeys: capabilities[providerKeysPath]?.canCreate,
};
},
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
@ -288,11 +327,15 @@ export default Route.extend({
let secretModel;
let capabilities;
// Handle keymgmt/key with API service
// Handle keymgmt resources with API service
if (modelType === 'keymgmt/key') {
secretModel = await this.fetchKeymgmtKey(backend, secret);
const caps = await this.fetchKeymgmtKeyCapabilities(backend, secret);
capabilities = caps;
} else if (modelType === 'keymgmt/provider') {
secretModel = await this.fetchKeymgmtProvider(backend, secret);
const caps = await this.fetchKeymgmtProviderCapabilities(backend, secret);
capabilities = caps;
} else {
capabilities = this.capabilities(secret, modelType);
try {
@ -325,15 +368,15 @@ export default Route.extend({
const backendType = this.backendType();
const mode = this.routeName.split('.').pop().replace('-root', '');
// Handle keymgmt/key differently - Resource or Form doesn't have setProperties
// Handle keymgmt forms differently - Resource or Form doesn't have setProperties
const modelType = this.modelType(backend, secret);
if (modelType !== 'keymgmt/key') {
if (!['keymgmt/key', 'keymgmt/provider'].includes(modelType)) {
model.secret.setProperties({ backend });
}
controller.setProperties({
model: model.secret,
form: modelType === 'keymgmt/key' ? model.secret : null,
form: ['keymgmt/key', 'keymgmt/provider'].includes(modelType) ? model.secret : null,
capabilities: model.capabilities,
baseKey: { id: secret },
mode,

View file

@ -26,6 +26,9 @@ export const PATH_MAP = {
keymgmtKey: apiPath`${'backend'}/key/${'name'}`,
keymgmtKeys: apiPath`${'backend'}/key`,
keymgmtKeyProviders: apiPath`${'backend'}/key/${'name'}/kms`,
keymgmtProvider: apiPath`${'backend'}/kms/${'id'}`,
keymgmtProviders: apiPath`${'backend'}/kms`,
keymgmtProviderKeys: apiPath`${'backend'}/kms/${'id'}/key`,
kmipCredentialsRevoke: apiPath`${'backend'}/scope/${'scope'}/role/${'role'}/credentials/revoke`,
kmipRole: apiPath`${'backend'}/scopes/${'scope'}/roles/${'name'}`,
kmipScope: apiPath`${'backend'}/scopes/${'name'}`,

View file

@ -0,0 +1,30 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
/**
* Validates if a provider value is a valid string that can be used for API calls.
* Provider must be a non-empty string to be valid.
* This prevents passing objects like { permissionsError: true } to API calls.
* @param {*} provider - The provider value to validate
* @returns {boolean} true if provider is a valid non-empty string, false otherwise
*/
export function isValidProvider(provider: unknown): boolean {
return typeof provider === 'string' && provider.trim().length > 0;
}
/**
* Returns the icon name associated with a keymgmt provider type.
* @param {string | undefined} providerType - The keymgmt provider type
* @returns {string} Icon token used by the UI
*/
export function getKeymgmtProviderIcon(providerType?: string): string {
return (
{
azurekeyvault: 'azure-color',
awskms: 'aws-color',
gcpckms: 'gcp-color',
}[providerType || ''] || 'key'
);
}

View file

@ -11,6 +11,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, settled, fillIn } from '@ember/test-helpers';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
const ts = 'data-test-kms-provider';
const root = {
@ -25,20 +26,18 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.store.push({
data: {
id: 'foo-bar',
type: 'keymgmt/provider',
attributes: {
name: 'foo-bar',
provider: 'azurekeyvault',
keyCollection: 'keyvault-1',
backend: 'keymgmt',
},
},
this.form = new KeymgmtProviderForm({
name: 'foo-bar',
provider: 'azurekeyvault',
key_collection: 'keyvault-1',
backend: 'keymgmt',
});
this.model = this.store.peekRecord('keymgmt/provider', 'foo-bar');
this.capabilities = {
canDelete: false,
canListKeys: false,
canEdit: false,
canCreateKeys: false,
};
this.root = root;
this.owner.lookup('service:router').reopen({
currentURL: '/ui/vault/secrets-engines/keymgmt/show/foo-bar',
@ -60,11 +59,7 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
test('it should render show view', async function (assert) {
assert.expect(11);
// override capability getters
Object.defineProperties(this.model, {
canDelete: { value: true },
canListKeys: { value: true },
});
this.capabilities = { canDelete: true, canListKeys: true, canEdit: false, canCreateKeys: false };
this.server.post('/sys/capabilities-self', () => ({}));
this.server.get('/keymgmt/kms/foo-bar/key', () => {
@ -83,7 +78,8 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
@capabilities={{this.capabilities}}
@mode="show"
@tab={{this.tab}}
/>`);
@ -117,11 +113,7 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
test('it should delete a provider', async function (assert) {
assert.expect(5);
// override capability getters
Object.defineProperties(this.model, {
canDelete: { value: true },
canListKeys: { value: true },
});
this.capabilities = { canDelete: true, canListKeys: true, canEdit: false, canCreateKeys: false };
this.server.post('/sys/capabilities-self', () => ({}));
this.server.get('/keymgmt/kms/foo-bar/key', () => {
@ -146,7 +138,8 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
@capabilities={{this.capabilities}}
@mode="show"
@tab={{this.tab}}
/>`);
@ -162,10 +155,8 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
test('it should render create view', async function (assert) {
assert.expect(14);
this.server.put('/keymgmt/kms/foo', (schema, req) => {
this.server.post('/keymgmt/kms/foo', (schema, req) => {
const params = {
name: 'foo',
backend: 'keymgmt',
provider: 'gcpckms',
key_collection: 'keyvault-1',
credentials: {
@ -186,12 +177,12 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
},
});
this.model = this.store.createRecord('keymgmt/provider', { backend: 'keymgmt' });
this.form = new KeymgmtProviderForm({ backend: 'keymgmt' }, { isNew: true });
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
@mode="create"
/>`);
@ -216,18 +207,16 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
assert.dom(`[data-test-input="credentials.service_account_file"]`).exists(`GCP - cred field renders`);
await fillIn('[data-test-input="name"]', 'foo');
await fillIn('[data-test-input="keyCollection"]', 'keyvault-1');
await fillIn('[data-test-input="key_collection"]', 'keyvault-1');
await fillIn('[data-test-input="credentials.service_account_file"]', 'test');
await click(`[${ts}-submit]`);
});
test('it should render edit view', async function (assert) {
assert.expect(3);
assert.expect(7);
this.server.put('/keymgmt/kms/foo', (schema, req) => {
this.server.post('/keymgmt/kms/foo-bar', (schema, req) => {
const params = {
name: 'foo-bar',
backend: 'keymgmt',
provider: 'azurekeyvault',
key_collection: 'keyvault-1',
credentials: {
@ -246,14 +235,14 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
'vault.cluster.secrets.backend.show',
'Show route sent in transitionTo on save'
);
assert.strictEqual(model, 'foo', 'Model id sent in transitionTo on save');
assert.strictEqual(model, 'foo-bar', 'Provider name sent in transitionTo on save');
assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
},
});
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
@mode="edit"
/>`);
@ -266,4 +255,28 @@ module('Integration | Component | keymgmt/provider-edit', function (hooks) {
}
await click(`[${ts}-submit]`);
});
test('it should clear all validations when provider type changes', async function (assert) {
assert.expect(3);
this.form = new KeymgmtProviderForm({ backend: 'keymgmt' }, { isNew: true });
await render(hbs`
<Keymgmt::ProviderEdit
@root={{this.root}}
@form={{this.form}}
@mode="create"
/>`);
await click(`[${ts}-submit]`);
assert.dom('[data-test-validation-error]').exists('Validation errors shown after submit');
await fillIn('[data-test-input="provider"]', 'gcpckms');
assert
.dom('[data-test-validation-error]')
.doesNotExist('Validation errors cleared when provider changes');
await click(`[${ts}-submit]`);
assert.dom('[data-test-validation-error]').exists('Validation errors re-appear after next submit');
});
});

View file

@ -0,0 +1,49 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { getKeymgmtProviderIcon, isValidProvider } from 'vault/utils/keymgmt-provider-utils';
import { module, test } from 'qunit';
module('Unit | Util | keymgmt-provider-utils', function () {
test('it returns true for valid provider strings', function (assert) {
assert.true(isValidProvider('azure-provider'), 'Valid provider name');
assert.true(isValidProvider('test-provider'), 'Another valid provider');
assert.true(isValidProvider('a'), 'Single character string');
assert.true(isValidProvider(' valid-provider '), 'Provider with leading/trailing spaces');
});
test('it returns false for objects', function (assert) {
assert.false(isValidProvider({ permissionsError: true }), 'Object with permissionsError');
assert.false(isValidProvider({}), 'Empty object');
assert.false(isValidProvider({ name: 'provider' }), 'Object with properties');
});
test('it returns false for non-string primitives', function (assert) {
assert.false(isValidProvider(123), 'Number returns false');
assert.false(isValidProvider(true), 'Boolean true returns false');
assert.false(isValidProvider(false), 'Boolean false returns false');
});
test('it returns false for arrays', function (assert) {
assert.false(isValidProvider([]), 'Empty array');
assert.false(isValidProvider(['provider']), 'Array with string');
});
test('it returns provider-specific icons for known provider types', function (assert) {
assert.strictEqual(getKeymgmtProviderIcon('azurekeyvault'), 'azure-color', 'Azure icon is returned');
assert.strictEqual(getKeymgmtProviderIcon('awskms'), 'aws-color', 'AWS icon is returned');
assert.strictEqual(getKeymgmtProviderIcon('gcpckms'), 'gcp-color', 'GCP icon is returned');
});
test('it returns default icon for unknown or empty provider types', function (assert) {
assert.strictEqual(
getKeymgmtProviderIcon('unknown-provider'),
'key',
'Unknown provider uses default icon'
);
assert.strictEqual(getKeymgmtProviderIcon(''), 'key', 'Empty string uses default icon');
assert.strictEqual(getKeymgmtProviderIcon(), 'key', 'Undefined provider uses default icon');
});
});