From 7d026fa5a8e1f68b62b01ba1d59c5f1a7a23b33c Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Mon, 15 Sep 2025 14:22:35 -0400 Subject: [PATCH] [VAULT-37521] UI: decouple auth and secret engines (#9307) (#9347) * [VAULT-37521] UI: decouple auth and secret engines * add copyright header * address acceptance test failure Co-authored-by: Shannon Roberts (Beagin) --- ui/app/components/mount-backend-form.ts | 94 ++------- .../components/mount/secrets-engine-form.hbs | 66 ++++++ .../components/mount/secrets-engine-form.ts | 158 +++++++++++++++ ui/app/components/secret-engines/catalog.hbs | 77 +++++++ ui/app/components/secret-engines/catalog.ts | 130 ++++++++++++ .../vault/cluster/secrets/mounts/create.ts | 42 ++++ .../vault/cluster/secrets/mounts/index.js | 5 + ui/app/forms/secrets/engine.ts | 16 +- .../vault/cluster/secrets/mounts/create.ts | 32 +++ .../vault/cluster/secrets/mounts/index.ts | 5 +- .../vault/cluster/secrets/mounts/create.hbs | 6 + .../vault/cluster/secrets/mounts/index.hbs | 7 +- .../vault/cluster/settings/auth/enable.hbs | 2 +- ui/tests/acceptance/secrets/mounts-test.js | 2 +- .../components/mount-backend-form-test.js | 159 +-------------- .../mount/secrets-engine-form-test.js | 191 ++++++++++++++++++ .../components/secret-engines/catalog-test.js | 139 +++++++++++++ .../pages/settings/mount-secret-backend.js | 18 +- 18 files changed, 904 insertions(+), 245 deletions(-) create mode 100644 ui/app/components/mount/secrets-engine-form.hbs create mode 100644 ui/app/components/mount/secrets-engine-form.ts create mode 100644 ui/app/components/secret-engines/catalog.hbs create mode 100644 ui/app/components/secret-engines/catalog.ts create mode 100644 ui/app/controllers/vault/cluster/secrets/mounts/create.ts create mode 100644 ui/app/routes/vault/cluster/secrets/mounts/create.ts create mode 100644 ui/app/templates/vault/cluster/secrets/mounts/create.hbs create mode 100644 ui/tests/integration/components/mount/secrets-engine-form-test.js create mode 100644 ui/tests/integration/components/secret-engines/catalog-test.js diff --git a/ui/app/components/mount-backend-form.ts b/ui/app/components/mount-backend-form.ts index 0d8024d99f..1fc63ff016 100644 --- a/ui/app/components/mount-backend-form.ts +++ b/ui/app/components/mount-backend-form.ts @@ -9,49 +9,34 @@ import { service } from '@ember/service'; import { action, set } from '@ember/object'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; -import { presence } from 'vault/utils/forms/validators'; -import { filterEnginesByMountCategory, isAddonEngine } from 'vault/utils/all-engines-metadata'; +import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; import { MOUNT_CATEGORIES } from 'vault/utils/plugin-catalog-helpers'; -import { assert } from '@ember/debug'; import type FlashMessageService from 'vault/services/flash-messages'; import type Store from '@ember-data/store'; import type AuthMethodForm from 'vault/forms/auth/method'; -import type SecretsEngineForm from 'vault/forms/secrets/engine'; import type CapabilitiesService from 'vault/services/capabilities'; import type ApiService from 'vault/services/api'; import type { ApiError } from '@ember-data/adapter/error'; import type { ValidationMap } from 'vault/vault/app-types'; -import type { EnhancedPluginCatalogData } from 'vault/services/plugin-catalog'; /** * @module MountBackendForm - * The `MountBackendForm` is used to mount either a secret or auth backend. + * The `MountBackendForm` is used to mount authentication methods. * * @example ```js - * ``` + * ``` * - * @param {object|Form} mountModel - Either a model object containing form and plugin data (secrets), or the form directly (auth). + * @param {AuthMethodForm} mountModel - The authentication method form. * @param {function} onMountSuccess - A function that transitions once the Mount has been successfully posted. - * @param {string} mountCategory - The type of engine to mount, either 'secret' or 'auth'. * */ -type MountModel = - | { - form: SecretsEngineForm; - pluginCatalogData?: EnhancedPluginCatalogData | null; - pluginCatalogError?: boolean; - } - | AuthMethodForm; - interface Args { - mountModel: MountModel; - mountCategory: 'secret' | 'auth'; + mountModel: AuthMethodForm; onMountSuccess: (type: string, path: string, useEngineRoute: boolean) => void; } -const SECRET_MOUNT_CATEGORY = MOUNT_CATEGORIES.SECRET; const AUTH_MOUNT_CATEGORY = MOUNT_CATEGORIES.AUTH; export default class MountBackendForm extends Component { @@ -66,13 +51,8 @@ export default class MountBackendForm extends Component { @tracked errorMessage: string | string[] = ''; - get mountForm(): SecretsEngineForm | AuthMethodForm { - // Check if mountModel has form property (secrets route) - if (typeof this.args.mountModel === 'object' && 'form' in this.args.mountModel) { - return this.args.mountModel.form; - } - // Otherwise, assume the model IS the form (auth route) - return this.args.mountModel as SecretsEngineForm | AuthMethodForm; + get mountForm(): AuthMethodForm { + return this.args.mountModel; } get showEnable(): boolean { @@ -81,16 +61,14 @@ export default class MountBackendForm extends Component { constructor(owner: unknown, args: Args) { super(owner, args); - assert(`@mountCategory is required. Must be "auth" or "secret".`, presence(this.args.mountCategory)); } checkPathChange(backendType: string) { if (!backendType) return; const { data } = this.mountForm; - // mountCategory is usually 'secret' or 'auth', but sometimes an empty string is passed in (like when we click the cancel button). - // In these cases, we should default to returning auth methods. + // Always use auth mount category since this component only handles auth methods const mountsByType = filterEnginesByMountCategory({ - mountCategory: this.args.mountCategory ?? AUTH_MOUNT_CATEGORY, + mountCategory: AUTH_MOUNT_CATEGORY, isEnterprise: true, }).map((engine) => engine.type); @@ -101,13 +79,6 @@ export default class MountBackendForm extends Component { } } - typeChangeSideEffect(type: string) { - // If type PKI, set max lease to ~10years - if (this.args.mountCategory === SECRET_MOUNT_CATEGORY) { - this.mountForm.data.config.max_lease_ttl = type === 'pki' ? '3650d' : 0; - } - } - checkModelWarnings() { // check for warnings on change // since we only show errors on submit we need to clear those out and only send warning state @@ -122,35 +93,10 @@ export default class MountBackendForm extends Component { this.invalidFormAlert = null; } - async saveKvConfig(path: string, formData: SecretsEngineForm['data']) { - const { options, kv_config = {} } = formData; - const { max_versions, cas_required, delete_version_after } = kv_config; - const isKvV2 = options?.version === 2 && ['kv', 'generic'].includes(this.mountForm.normalizedType); - const hasConfig = max_versions || cas_required || delete_version_after; - - if (isKvV2 && hasConfig) { - try { - const { canUpdate } = await this.capabilities.for('kvConfig', { path }); - if (canUpdate) { - await this.api.secrets.kvV2Configure(path, kv_config); - } else { - this.flashMessages.warning( - 'You do not have access to the config endpoint. The secret engine was mounted, but the configuration settings were not saved.' - ); - } - } catch (e) { - const { message } = await this.api.parseError(e); - this.flashMessages.warning( - `The secret engine was mounted, but the configuration settings were not saved. ${message}` - ); - } - } - } - async onMountError(status: number, errors: ApiError[], message: string) { if (status === 403) { this.flashMessages.danger( - 'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.' + 'You do not have access to the sys/auth endpoint. The auth method was not mounted.' ); } else if (errors) { this.errorMessage = errors.map((e) => { @@ -168,7 +114,6 @@ export default class MountBackendForm extends Component { @waitFor *mountBackend(event: Event) { event.preventDefault(); - const { mountCategory } = this.args; const mountModel = this.mountForm; const { type } = mountModel; const { path } = mountModel.data; @@ -181,21 +126,9 @@ export default class MountBackendForm extends Component { } try { - if (mountCategory === SECRET_MOUNT_CATEGORY) { - yield this.api.sys.mountsEnableSecretsEngine(path, data); - yield this.saveKvConfig(path, data as SecretsEngineForm['data']); - } else { - yield this.api.sys.authEnableMethod(path, data); - } - this.flashMessages.success( - `Successfully mounted the ${mountModel.type} ${ - mountCategory === SECRET_MOUNT_CATEGORY ? 'secrets engine' : 'auth method' - } at ${path}.` - ); - // check whether to use the Ember engine route - const version = (data as SecretsEngineForm['data']).options?.version; - const useEngineRoute = isAddonEngine(mountModel.normalizedType, Number(version)); - this.args.onMountSuccess(type, path, useEngineRoute); + yield this.api.sys.authEnableMethod(path, data); + this.flashMessages.success(`Successfully mounted the ${mountModel.type} auth method at ${path}.`); + this.args.onMountSuccess(type, path, false); } catch (error) { const { status, response, message } = yield this.api.parseError(error); this.onMountError(status, response.errors, message); @@ -211,7 +144,6 @@ export default class MountBackendForm extends Component { @action setMountType(value: string) { this.mountForm.type = value; - this.typeChangeSideEffect(value); this.checkPathChange(value); } diff --git a/ui/app/components/mount/secrets-engine-form.hbs b/ui/app/components/mount/secrets-engine-form.hbs new file mode 100644 index 0000000000..72b4748170 --- /dev/null +++ b/ui/app/components/mount/secrets-engine-form.hbs @@ -0,0 +1,66 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + + + + + + +
+ + +
+ + + + <:identityTokenKey> + + + + +
+
+ +
+
+ +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+ +
\ No newline at end of file diff --git a/ui/app/components/mount/secrets-engine-form.ts b/ui/app/components/mount/secrets-engine-form.ts new file mode 100644 index 0000000000..d71d75cdfb --- /dev/null +++ b/ui/app/components/mount/secrets-engine-form.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { set } from '@ember/object'; +import type FlashMessagesService from 'ember-cli-flash/services/flash-messages'; +import type ApiService from 'vault/services/api'; +import type CapabilitiesService from 'vault/services/capabilities'; +import type Router from '@ember/routing/router'; +import type SecretsEngineForm from 'vault/forms/secrets/engine'; +import type { ValidationMap } from 'vault/vault/app-types'; +import { isAddonEngine } from 'vault/utils/all-engines-metadata'; + +interface Args { + model: SecretsEngineForm; + onMountSuccess?: (type: string, path: string, useEngineRoute: boolean) => void; +} + +/** + * @module Mount::SecretsEngineForm + * Modern component for mounting secrets engines using the SecretsEngineForm. + * + * @example + * ```hbs + * + * ``` + */ +export default class MountSecretsEngineFormComponent extends Component { + @service declare flashMessages: FlashMessagesService; + @service declare api: ApiService; + @service declare capabilities: CapabilitiesService; + @service declare router: Router; + + @tracked modelValidations: ValidationMap | null = null; + @tracked invalidFormAlert: string | null = null; + @tracked errorMessage: string | string[] = ''; + + get mountForm(): SecretsEngineForm { + return this.args.model; + } + + @action + onKeyUp(name: string, value: string) { + set(this.mountForm.data, name, value); + } + + async saveKvConfig(path: string, formData: SecretsEngineForm['data']) { + const { options, kv_config = {} } = formData; + const { max_versions, cas_required, delete_version_after } = kv_config; + const isKvV2 = options?.version === 2 && ['kv', 'generic'].includes(this.mountForm.normalizedType); + const hasConfig = max_versions || cas_required || delete_version_after; + + if (isKvV2 && hasConfig) { + try { + const { canUpdate } = await this.capabilities.for('kvConfig', { path }); + if (canUpdate) { + await this.api.secrets.kvV2Configure(path, kv_config); + } else { + this.flashMessages.warning( + 'You do not have access to the config endpoint. The secret engine was mounted, but the configuration settings were not saved.' + ); + } + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.warning( + `The secret engine was mounted, but the configuration settings were not saved. ${message}` + ); + } + } + } + + async onMountError(status: number, errors: unknown[] | undefined, message: string) { + if (status === 403) { + this.flashMessages.danger( + 'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.' + ); + } else if (errors) { + this.errorMessage = errors.map((e) => { + if (typeof e === 'object' && e !== null) { + const errorObj = e as { title?: string; message?: string }; + return errorObj.title || errorObj.message || JSON.stringify(e); + } + return String(e); + }); + } else if (message) { + this.errorMessage = message; + } else { + this.errorMessage = 'An error occurred, check the vault logs.'; + } + } + + @task + *mountBackend(event: Event) { + event.preventDefault(); + const mountModel = this.mountForm; + const { type } = mountModel; + const { path } = mountModel.data; + + // Only submit form if validations pass + const { isValid, state, invalidFormMessage, data } = mountModel.toJSON(); + if (!isValid) { + this.modelValidations = state; + this.invalidFormAlert = invalidFormMessage; + return; + } + + this.errorMessage = ''; + this.modelValidations = null; + this.invalidFormAlert = null; + + try { + // Mount the secrets engine + yield this.api.sys.mountsEnableSecretsEngine(path, data); + + // Save KV config if applicable + yield this.saveKvConfig(path, data); + + this.flashMessages.success(`Successfully mounted the ${mountModel.type} secrets engine at ${path}.`); + + // Determine if we should use engine routes + const version = data.options?.version; + const useEngineRoute = isAddonEngine(mountModel.normalizedType, Number(version)); + + // Call success callback or navigate + if (this.args.onMountSuccess) { + this.args.onMountSuccess(type, path, useEngineRoute); + } else { + // Default navigation + if (useEngineRoute) { + this.router.transitionTo('vault.cluster.secrets.backend.index', path); + } else { + this.router.transitionTo('vault.cluster.secrets.backend.list-root', path); + } + } + } catch (error) { + const { status, response, message } = yield this.api.parseError(error); + this.onMountError(status, response.errors, message); + } + } + + @action + handleIdentityTokenKeyChange(value: string[] | string): void { + // if array, it's coming from the search-select component, otherwise it hit the fallback component and will come in as a string. + const { config } = this.mountForm.data; + config.identity_token_key = Array.isArray(value) ? value[0] : value; + } + + @action + goBack() { + this.router.transitionTo('vault.cluster.secrets.mounts'); + } +} diff --git a/ui/app/components/secret-engines/catalog.hbs b/ui/app/components/secret-engines/catalog.hbs new file mode 100644 index 0000000000..b9593534f9 --- /dev/null +++ b/ui/app/components/secret-engines/catalog.hbs @@ -0,0 +1,77 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + + + + + +{{#if @pluginCatalogError}} +
+ + Plugin information unavailable + + Unable to fetch current plugin information. Using static plugin data instead. Some plugins may not show current + details. + + +
+{{/if}} + +{{#each this.pluginCategoriesList as |category|}} + {{#let (this.getMountTypesByCategory category) as |categorized|}} + {{! Only display the category if it has any enabled or disabled plugins }} + {{#if (or categorized.enabled.length categorized.disabled.length)}} +
+ + {{capitalize category}} + {{#if (eq category "external")}} + + {{/if}} + +
+
+ {{! Enabled plugins }} + {{#each categorized.enabled as |type|}} + + {{/each}} + + {{! Vertical separator if both enabled and disabled plugins exist }} + {{#if (and categorized.enabled.length categorized.disabled.length)}} +
+ {{/if}} + + {{! Disabled plugins }} + {{#each categorized.disabled as |type|}} + + {{/each}} +
+ {{/if}} + {{/let}} +{{/each}} + + \ No newline at end of file diff --git a/ui/app/components/secret-engines/catalog.ts b/ui/app/components/secret-engines/catalog.ts new file mode 100644 index 0000000000..0e78009fae --- /dev/null +++ b/ui/app/components/secret-engines/catalog.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) HashiCorp, Inc. + * 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 { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import { + enhanceEnginesWithCatalogData, + categorizeEnginesByStatus, + MOUNT_CATEGORIES, + PLUGIN_TYPES, + PLUGIN_CATEGORIES, +} from 'vault/utils/plugin-catalog-helpers'; +import type { PluginCatalogData } from 'vault/services/plugin-catalog'; + +import type VersionService from 'vault/services/version'; + +/** + * @module SecretEnginesCatalog + * SecretEnginesCatalog component displays available secret engines in a catalog view + * for selection when mounting a new secret engine. + * + * @example + * ```js + * + * ``` + */ + +interface Args { + setMountType: (type: string) => void; + pluginCatalogData?: PluginCatalogData; + pluginCatalogError?: boolean; +} + +export default class SecretEnginesCatalogComponent extends Component { + @service declare version: VersionService; + + @tracked showFlyout = false; + @tracked flyoutPlugin: unknown = null; + @tracked flyoutPluginType: string | null = null; + + get secretEngines() { + // If an enterprise license is present, return all secret engines; + // otherwise, return only the secret engines supported in OSS. + const staticEngines = filterEnginesByMountCategory({ + mountCategory: MOUNT_CATEGORIES.SECRET, + isEnterprise: !!this.version?.isEnterprise, + }); + + // If we have plugin catalog data, merge it with static engines to add catalog info + if (this.args.pluginCatalogData) { + const secretEnginesDetailed = + this.args.pluginCatalogData.detailed?.filter((plugin) => plugin?.type === PLUGIN_TYPES.SECRET) || []; + const databasePluginsDetailed = + this.args.pluginCatalogData.detailed?.filter((plugin) => plugin?.type === PLUGIN_TYPES.DATABASE) || + []; + + return enhanceEnginesWithCatalogData(staticEngines, secretEnginesDetailed, databasePluginsDetailed); + } + + return staticEngines; + } + + get pluginCategoriesList() { + return [ + PLUGIN_CATEGORIES.GENERIC, + PLUGIN_CATEGORIES.CLOUD, + PLUGIN_CATEGORIES.INFRA, + + // TODO: enable external plugins once version selection is available (VAULT-39241) + // PLUGIN_CATEGORIES.EXTERNAL, + ]; + } + + get secretMountCategory() { + return MOUNT_CATEGORIES.SECRET; + } + + @action + getMountTypesByCategory(category: string) { + try { + const mountTypes = this.secretEngines; + if (!mountTypes || !Array.isArray(mountTypes)) { + return { enabled: [], disabled: [] }; + } + + const allTypes = mountTypes.filter((type: unknown) => { + const engineType = type as { pluginCategory?: string }; + return engineType?.pluginCategory === category; + }); + return categorizeEnginesByStatus(allTypes); + } catch (error) { + return { enabled: [], disabled: [] }; + } + } + + @action + handleDisabledPluginClick(plugin: unknown) { + this.showFlyout = true; + this.flyoutPlugin = plugin; + this.flyoutPluginType = 'secret'; + } + + @action + handleDisabledPluginKeyDown(plugin: unknown, event: KeyboardEvent) { + // Only handle Enter and Space keys for accessibility + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleDisabledPluginClick(plugin); + } + } + + @action + openExternalPluginsHelp() { + this.showFlyout = true; + this.flyoutPlugin = null; + this.flyoutPluginType = 'secret'; + } + + @action + closeFlyout() { + this.showFlyout = false; + this.flyoutPlugin = null; + this.flyoutPluginType = null; + } +} diff --git a/ui/app/controllers/vault/cluster/secrets/mounts/create.ts b/ui/app/controllers/vault/cluster/secrets/mounts/create.ts new file mode 100644 index 0000000000..6072a39ae6 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/mounts/create.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +import engineDisplayData from 'vault/helpers/engines-display-data'; +import type SecretsEngineForm from 'vault/forms/secrets/engine'; +import type Router from '@ember/routing/router'; + +const SUPPORTED_BACKENDS = supportedSecretBackends(); + +export default class VaultClusterSecretsMountsCreateController extends Controller { + @service declare router: Router; + + declare model: SecretsEngineForm; + + @action + onMountSuccess(type: string, path: string, useEngineRoute = false) { + let transition; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (SUPPORTED_BACKENDS.includes(type as any)) { + const engineInfo = engineDisplayData(type); + if (engineInfo && useEngineRoute) { + transition = this.router.transitionTo( + `vault.cluster.secrets.backend.${engineInfo.engineRoute}`, + path + ); + } else if (engineInfo) { + // For keymgmt, we need to land on provider tab by default using query params + const queryParams = engineInfo.type === 'keymgmt' ? { tab: 'provider' } : {}; + transition = this.router.transitionTo('vault.cluster.secrets.backend.index', path, { queryParams }); + } + } else { + transition = this.router.transitionTo('vault.cluster.secrets.backends'); + } + return transition?.followRedirects(); + } +} diff --git a/ui/app/controllers/vault/cluster/secrets/mounts/index.js b/ui/app/controllers/vault/cluster/secrets/mounts/index.js index 7051a5afe7..6a6ca55ec5 100644 --- a/ui/app/controllers/vault/cluster/secrets/mounts/index.js +++ b/ui/app/controllers/vault/cluster/secrets/mounts/index.js @@ -14,6 +14,11 @@ const SUPPORTED_BACKENDS = supportedSecretBackends(); export default class SecretMountsController extends Controller { @service router; + @action + setMountType(type) { + this.router.transitionTo('vault.cluster.secrets.mounts.create', type); + } + @action onMountSuccess(type, path, useEngineRoute = false) { let transition; diff --git a/ui/app/forms/secrets/engine.ts b/ui/app/forms/secrets/engine.ts index 002b90b31c..87f2d42eb4 100644 --- a/ui/app/forms/secrets/engine.ts +++ b/ui/app/forms/secrets/engine.ts @@ -22,6 +22,20 @@ export default class SecretsEngineForm extends MountForm ]; } + // Method to apply type-specific side effects - called when type changes + applyTypeSpecificDefaults() { + // PKI side effect: set max lease to ~10 years to match PKI certificate lifespans + if (this.normalizedType === 'pki') { + if (!this.data.config) { + this.data.config = {}; + } + // Only set default if not already specified + if (!this.data.config.max_lease_ttl) { + this.data.config.max_lease_ttl = '3650d'; + } + } + } + coreOptionFields = [this.fields.description, this.fields.local, this.fields.sealWrap]; leaseConfigFields = [ @@ -41,7 +55,7 @@ export default class SecretsEngineForm extends MountForm ]; get defaultFields() { - const fields = [new FormField('path', 'string')]; + const fields = [this.fields.path]; if (this.normalizedType === 'kv') { fields.push( new FormField('kv_config.max_versions', 'number', { diff --git a/ui/app/routes/vault/cluster/secrets/mounts/create.ts b/ui/app/routes/vault/cluster/secrets/mounts/create.ts new file mode 100644 index 0000000000..cbc01c4c45 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/mounts/create.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import SecretsEngineForm from 'vault/forms/secrets/engine'; + +export default class VaultClusterSecretsMountsCreateRoute extends Route { + model(params: { mount_type: string }) { + const { mount_type } = params; + + const defaults = { + path: mount_type, // Default path to match the engine type + config: { listing_visibility: false }, + kv_config: { + max_versions: 0, + cas_required: false, + delete_version_after: undefined, + }, + options: { version: 2 }, + }; + + const form = new SecretsEngineForm(defaults, { isNew: true }); + // Explicitly set the type on the form after creation + form.type = mount_type; + // Apply type-specific defaults (e.g., PKI max lease TTL) + form.applyTypeSpecificDefaults(); + + return form; + } +} diff --git a/ui/app/routes/vault/cluster/secrets/mounts/index.ts b/ui/app/routes/vault/cluster/secrets/mounts/index.ts index b64b491960..90e822c28a 100644 --- a/ui/app/routes/vault/cluster/secrets/mounts/index.ts +++ b/ui/app/routes/vault/cluster/secrets/mounts/index.ts @@ -5,7 +5,6 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; -import { hash } from 'rsvp'; import SecretsEngineForm from 'vault/forms/secrets/engine'; import Router from 'vault/router'; import type PluginCatalogService from 'vault/services/plugin-catalog'; @@ -34,10 +33,10 @@ export default class VaultClusterSecretsMountsIndexRouter extends Route { // Fetch plugin catalog data to enhance the secret engines list const pluginCatalogResponse = await this.pluginCatalog.fetchPluginCatalog(); - return hash({ + return { form: secretEngineForm, pluginCatalogData: pluginCatalogResponse.data, pluginCatalogError: pluginCatalogResponse.error, - }); + }; } } diff --git a/ui/app/templates/vault/cluster/secrets/mounts/create.hbs b/ui/app/templates/vault/cluster/secrets/mounts/create.hbs new file mode 100644 index 0000000000..8091826591 --- /dev/null +++ b/ui/app/templates/vault/cluster/secrets/mounts/create.hbs @@ -0,0 +1,6 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/secrets/mounts/index.hbs b/ui/app/templates/vault/cluster/secrets/mounts/index.hbs index 0ecd0436a8..e7cd337b59 100644 --- a/ui/app/templates/vault/cluster/secrets/mounts/index.hbs +++ b/ui/app/templates/vault/cluster/secrets/mounts/index.hbs @@ -3,5 +3,8 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{! TODO: Copied from existing component, to be replaced by new parent component in separate ticket - VAULT-37522 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/settings/auth/enable.hbs b/ui/app/templates/vault/cluster/settings/auth/enable.hbs index 5ed4c3436f..f9189e0a81 100644 --- a/ui/app/templates/vault/cluster/settings/auth/enable.hbs +++ b/ui/app/templates/vault/cluster/settings/auth/enable.hbs @@ -3,4 +3,4 @@ SPDX-License-Identifier: BUSL-1.1 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/tests/acceptance/secrets/mounts-test.js b/ui/tests/acceptance/secrets/mounts-test.js index 871eb9c55b..1720738c66 100644 --- a/ui/tests/acceptance/secrets/mounts-test.js +++ b/ui/tests/acceptance/secrets/mounts-test.js @@ -134,7 +134,7 @@ module('Acceptance | secrets/mounts', function (hooks) { await mountBackend('kv', path); await waitFor('[data-test-message-error-description]'); assert.dom('[data-test-message-error-description]').containsText(`path is already in use at ${path}`); - assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.mounts.index'); + assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.mounts.create'); await page.secretList(); await settled(); diff --git a/ui/tests/integration/components/mount-backend-form-test.js b/ui/tests/integration/components/mount-backend-form-test.js index 66d0c257fd..2aad0bc9ee 100644 --- a/ui/tests/integration/components/mount-backend-form-test.js +++ b/ui/tests/integration/components/mount-backend-form-test.js @@ -5,20 +5,17 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, click, typeIn, fillIn } from '@ember/test-helpers'; +import { render, click, fillIn } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { allowAllCapabilitiesStub, noopStub } from 'vault/tests/helpers/stubs'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers'; -import { ALL_ENGINES, filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; -import SecretsEngineForm from 'vault/forms/secrets/engine'; import AuthMethodForm from 'vault/forms/auth/method'; -const WIF_ENGINES = ALL_ENGINES.filter((e) => e.isWIF).map((e) => e.type); - module('Integration | Component | mount backend form', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); @@ -30,7 +27,6 @@ module('Integration | Component | mount backend form', function (hooks) { this.store = this.owner.lookup('service:store'); this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); this.server.post('/sys/auth/foo', noopStub()); - this.server.post('/sys/mounts/foo', noopStub()); this.onMountSuccess = sinon.spy(); }); @@ -45,7 +41,7 @@ module('Integration | Component | mount backend form', function (hooks) { test('it renders default state', async function (assert) { assert.expect(15); await render( - hbs`` + hbs`` ); assert .dom(GENERAL.title) @@ -63,7 +59,7 @@ module('Integration | Component | mount backend form', function (hooks) { test('it changes path when type is changed', async function (assert) { await render( - hbs`` + hbs`` ); await click(GENERAL.cardContainer('aws')); @@ -75,7 +71,7 @@ module('Integration | Component | mount backend form', function (hooks) { test('it keeps path value if the user has changed it', async function (assert) { await render( - hbs`` + hbs`` ); await click(GENERAL.cardContainer('approle')); assert.strictEqual(this.model.type, 'approle', 'Updates type on model'); @@ -92,14 +88,14 @@ module('Integration | Component | mount backend form', function (hooks) { test('it does not show a selected token type when first mounting an auth method', async function (assert) { await render( - hbs`` + hbs`` ); await click(GENERAL.cardContainer('github')); await click(GENERAL.button('Method Options')); assert - .dom('[data-test-input="config.token_type"]') + .dom(GENERAL.inputByAttr('config.token_type')) .hasValue('', 'token type does not have a default value.'); - const selectOptions = document.querySelector('[data-test-input="config.token_type"]').options; + const selectOptions = document.querySelector(GENERAL.inputByAttr('config.token_type')).options; assert.strictEqual(selectOptions[1].text, 'default-service', 'first option is default-service'); assert.strictEqual(selectOptions[2].text, 'default-batch', 'second option is default-batch'); assert.strictEqual(selectOptions[3].text, 'batch', 'third option is batch'); @@ -117,7 +113,7 @@ module('Integration | Component | mount backend form', function (hooks) { this.set('onMountSuccess', spy); await render( - hbs`` + hbs`` ); await mountBackend('approle', 'foo'); @@ -128,141 +124,4 @@ module('Integration | Component | mount backend form', function (hooks) { ); }); }); - - module('secrets engine', function (hooks) { - hooks.beforeEach(function () { - const defaults = { - config: { listing_visibility: false }, - kv_config: { - max_versions: 0, - cas_required: false, - delete_version_after: 0, - }, - options: { version: 2 }, - }; - this.model = new SecretsEngineForm(defaults, { isNew: true }); - }); - - test('it renders secret engine specific headers', async function (assert) { - const expectedEngines = filterEnginesByMountCategory({ - mountCategory: 'secret', - isEnterprise: false, - }).filter((engine) => engine.type !== 'cubbyhole'); - - // Dynamic assertion count: 1 for title + number of engines - assert.expect(1 + expectedEngines.length); - - await render( - hbs`` - ); - assert.dom(GENERAL.title).hasText('Enable a Secrets Engine', 'renders secrets header'); - for (const method of expectedEngines) { - assert - .dom(GENERAL.cardContainer(method.type)) - .hasText(method.displayName, `renders type:${method.displayName} picker`); - } - }); - - test('it changes path when type is changed', async function (assert) { - await render( - hbs`` - ); - await click(GENERAL.cardContainer('azure')); - assert.dom(GENERAL.inputByAttr('path')).hasValue('azure', 'sets the value of the type'); - await click(GENERAL.backButton); - await click(GENERAL.cardContainer('nomad')); - assert.dom(GENERAL.inputByAttr('path')).hasValue('nomad', 'updates the value of the type'); - }); - - test('it keeps path value if the user has changed it', async function (assert) { - await render( - hbs`` - ); - await click(GENERAL.cardContainer('kv')); - assert.strictEqual(this.model.type, 'kv', 'Updates type on model'); - assert.dom(GENERAL.inputByAttr('path')).hasValue('kv', 'path matches mount type'); - await fillIn(GENERAL.inputByAttr('path'), 'newpath'); - assert.strictEqual(this.model.path, 'newpath', 'Updates path on model'); - await click(GENERAL.backButton); - assert.strictEqual(this.model.type, '', 'Clears type on back'); - assert.strictEqual(this.model.path, 'newpath', 'path is still newpath'); - await click(GENERAL.cardContainer('ssh')); - assert.strictEqual(this.model.type, 'ssh', 'Updates type on model'); - assert.dom(GENERAL.inputByAttr('path')).hasValue('newpath', 'path stays the same'); - }); - - test('it calls mount success', async function (assert) { - assert.expect(3); - - this.server.post('/sys/mounts/foo', () => { - assert.ok(true, 'it calls enable on an secrets engine'); - return [204, { 'Content-Type': 'application/json' }]; - }); - const spy = sinon.spy(); - this.set('onMountSuccess', spy); - - await render( - hbs`` - ); - - await mountBackend('ssh', 'foo'); - - assert.true(spy.calledOnce, 'calls the passed success method'); - assert.true( - this.flashSuccessSpy.calledWith('Successfully mounted the ssh secrets engine at foo.'), - 'Renders correct flash message' - ); - }); - - module('WIF secret engines', function () { - test('it shows identity_token_key when type is a WIF engine and hides when its not', async function (assert) { - await render( - hbs`` - ); - for (const engine of WIF_ENGINES) { - await click(GENERAL.cardContainer(engine)); - await click(GENERAL.button('Method Options')); - assert - .dom(GENERAL.fieldByAttr('config.identity_token_key')) - .exists(`Identity token key field shows when type=${this.model.type}`); - await click(GENERAL.backButton); - } - for (const engine of filterEnginesByMountCategory({ - mountCategory: 'secret', - isEnterprise: false, - }).filter((e) => !WIF_ENGINES.includes(e.type) && e.type !== 'cubbyhole')) { - // check non-wif engine - await click(GENERAL.cardContainer(engine.type)); - await click(GENERAL.button('Method Options')); - assert - .dom(GENERAL.fieldByAttr('config.identity_token_key')) - .doesNotExist(`Identity token key field hidden when type=${this.model.type}`); - await click(GENERAL.backButton); - } - }); - - test('it updates identity_token_key if user has changed it', async function (assert) { - await render( - hbs`` - ); - assert.strictEqual( - this.model.config.identity_token_key, - undefined, - `On init identity_token_key is not set on the model` - ); - for (const engine of WIF_ENGINES) { - await click(GENERAL.cardContainer(engine)); - await click(GENERAL.button('Method Options')); - await typeIn(GENERAL.inputSearch('key'), `${engine}+specialKey`); // set to something else - - assert.strictEqual( - this.model.config.identity_token_key, - `${engine}+specialKey`, - `updates ${engine} model with custom identity_token_key` - ); - await click(GENERAL.backButton); - } - }); - }); - }); }); diff --git a/ui/tests/integration/components/mount/secrets-engine-form-test.js b/ui/tests/integration/components/mount/secrets-engine-form-test.js new file mode 100644 index 0000000000..99102dcc89 --- /dev/null +++ b/ui/tests/integration/components/mount/secrets-engine-form-test.js @@ -0,0 +1,191 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click, typeIn } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { allowAllCapabilitiesStub, noopStub } from 'vault/tests/helpers/stubs'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { ALL_ENGINES } from 'vault/utils/all-engines-metadata'; + +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import SecretsEngineForm from 'vault/forms/secrets/engine'; + +const WIF_ENGINES = ALL_ENGINES.filter((e) => e.isWIF).map((e) => e.type); + +module('Integration | Component | mount/secrets-engine-form', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.flashMessages = this.owner.lookup('service:flash-messages'); + this.flashMessages.registerTypes(['success', 'danger']); + this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); + this.store = this.owner.lookup('service:store'); + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + this.server.post('/sys/mounts/foo', noopStub()); + this.onMountSuccess = sinon.spy(); + + const defaults = { + config: { listing_visibility: false }, + kv_config: { + max_versions: 0, + cas_required: false, + delete_version_after: 0, + }, + options: { version: 2 }, + }; + this.model = new SecretsEngineForm(defaults, { isNew: true }); + }); + + test('it renders secret engine form', async function (assert) { + await render( + hbs`` + ); + assert.dom(GENERAL.breadcrumbs).exists('renders breadcrumbs'); + assert.dom(GENERAL.submitButton).hasText('Enable engine', 'renders submit button'); + assert.dom(GENERAL.backButton).hasText('Back', 'renders back button'); + }); + + test('it changes path when type is set', async function (assert) { + this.model.type = 'azure'; + this.model.data.path = 'azure'; // Set path to match type as would happen in the route + await render( + hbs`` + ); + assert.dom(GENERAL.inputByAttr('path')).hasValue('azure', 'path matches type'); + }); + + test('it keeps custom path value', async function (assert) { + this.model.type = 'kv'; + this.model.data.path = 'custom-path'; + await render( + hbs`` + ); + assert.dom(GENERAL.inputByAttr('path')).hasValue('custom-path', 'keeps custom path'); + }); + + test('it calls mount success', async function (assert) { + assert.expect(3); + + this.server.post('/sys/mounts/foo', () => { + assert.ok(true, 'it calls enable on a secrets engine'); + return [204, { 'Content-Type': 'application/json' }]; + }); + const spy = sinon.spy(); + this.set('onMountSuccess', spy); + + this.model.type = 'ssh'; + this.model.data.path = 'foo'; + + await render( + hbs`` + ); + + await click(GENERAL.submitButton); + + assert.true(spy.calledOnce, 'calls the passed success method'); + assert.true( + this.flashSuccessSpy.calledWith('Successfully mounted the ssh secrets engine at foo.'), + 'Renders correct flash message' + ); + }); + + module('KV engine', function () { + test('it shows KV specific fields when type is kv', async function (assert) { + this.model.type = 'kv'; + await render( + hbs`` + ); + assert.dom(GENERAL.inputByAttr('kv_config.max_versions')).exists('shows max versions field'); + assert.dom(GENERAL.inputByAttr('kv_config.cas_required')).exists('shows CAS required field'); + assert.dom(GENERAL.inputByAttr('kv_config.delete_version_after')).exists('shows delete after field'); + }); + }); + + module('WIF secret engines', function () { + test('it shows identity_token_key when type is a WIF engine and hides when its not', async function (assert) { + // Test AWS (a WIF engine) + this.model.type = 'aws'; + this.model.applyTypeSpecificDefaults(); + + // Initialize config object for WIF engines + if (!this.model.data.config) { + this.model.data.config = {}; + } + + await render( + hbs`` + ); + + // First check if the Method Options group is being rendered at all + assert.dom('[data-test-button="Method Options"]').exists('Method Options toggle button exists'); + + // Click to expand Method Options if it's collapsed + await click('[data-test-button="Method Options"]'); + + assert + .dom(GENERAL.fieldByAttr('config.identity_token_key')) + .exists('Identity token key field shows for AWS engine'); + + // Test KV (not a WIF engine) + this.model.type = 'kv'; + this.model.applyTypeSpecificDefaults(); + + await render( + hbs`` + ); + + assert + .dom(GENERAL.fieldByAttr('config.identity_token_key')) + .doesNotExist('Identity token key field hidden for KV engine'); + }); + + test('it updates identity_token_key if user has changed it', async function (assert) { + this.model.type = WIF_ENGINES[0]; // Use first WIF engine + this.model.applyTypeSpecificDefaults(); + // Initialize config object + if (!this.model.data.config) { + this.model.data.config = {}; + } + await render( + hbs`` + ); + + // Expand Method Options section to show identity_token_key field + await click(GENERAL.button('Method Options')); + + assert.strictEqual( + this.model.data.config.identity_token_key, + undefined, + 'On init identity_token_key is not set on the model' + ); + + // SearchSelectWithModal likely uses fallback component when no OIDC models are found + await typeIn(GENERAL.inputSearch('key'), 'specialKey'); + + assert.strictEqual( + this.model.data.config.identity_token_key, + 'specialKey', + 'updates model with custom identity_token_key' + ); + }); + }); + + module('PKI engine', function () { + test('it sets default max lease TTL for PKI', async function (assert) { + this.model.type = 'pki'; + this.model.applyTypeSpecificDefaults(); + + assert.strictEqual( + this.model.data.config.max_lease_ttl, + '3650d', + 'sets PKI default max lease TTL to 10 years' + ); + }); + }); +}); diff --git a/ui/tests/integration/components/secret-engines/catalog-test.js b/ui/tests/integration/components/secret-engines/catalog-test.js new file mode 100644 index 0000000000..3df4051f66 --- /dev/null +++ b/ui/tests/integration/components/secret-engines/catalog-test.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +module('Integration | Component | secret-engines/catalog', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.setMountType = sinon.spy(); + this.pluginCatalogData = null; + this.pluginCatalogError = false; + }); + + test('it renders secret engines catalog', async function (assert) { + const expectedEngines = filterEnginesByMountCategory({ + mountCategory: 'secret', + isEnterprise: false, + }).filter((engine) => engine.type !== 'cubbyhole'); + + // Dynamic assertion count: 1 for title + number of engines + assert.expect(1 + expectedEngines.length); + + await render( + hbs`` + ); + + assert.dom(GENERAL.breadcrumbs).exists('renders breadcrumbs'); + + for (const engine of expectedEngines) { + assert.dom(GENERAL.cardContainer(engine.type)).exists(`renders ${engine.displayName} engine card`); + } + }); + + test('it calls setMountType when engine is selected', async function (assert) { + await render( + hbs`` + ); + + await click(GENERAL.cardContainer('kv')); + + assert.true(this.setMountType.calledOnce, 'setMountType was called'); + assert.true(this.setMountType.calledWith('kv'), 'setMountType was called with kv'); + }); + + test('it shows plugin catalog error when provided', async function (assert) { + this.pluginCatalogError = true; + + await render( + hbs`` + ); + + assert.dom(GENERAL.inlineAlert).exists('shows plugin catalog error alert'); + assert + .dom(GENERAL.inlineAlert) + .hasText( + 'Plugin information unavailable Unable to fetch current plugin information. Using static plugin data instead. Some plugins may not show current details.', + 'shows correct error title' + ); + }); + + test('it shows flyout when clicking disabled plugin', async function (assert) { + // Set up plugin catalog data that creates both enabled and disabled engines + // An engine is disabled when it's not found in the plugin catalog detailed array + this.pluginCatalogData = { + detailed: [ + // Include only some engines, leaving others as "disabled" + { + name: 'kv', + type: 'secret', + builtin: true, + deprecation_status: 'supported', + version: 'v1.0.0', + }, + // AWS engine is NOT included, so it will be marked as isAvailable: false + ], + }; + + await render( + hbs`` + ); + + // Initially, flyout should not be visible + assert.dom(GENERAL.flyout).doesNotExist('flyout is not shown initially'); + + // Find a disabled plugin card - since AWS is not in our catalog data, + // it should be rendered as disabled + const awsCard = document.querySelector(GENERAL.cardContainer('aws')); + + // Look for any disabled cards regardless of AWS card presence + const disabledCards = document.querySelectorAll( + '.selectable-engine-card.disabled, .selectable-engine-card[style*="opacity"]' + ); + + let clickedCard = false; + + if (awsCard) { + await click(awsCard); + clickedCard = true; + + // After clicking disabled plugin, flyout should appear + assert.dom(GENERAL.flyout).exists('flyout appears after clicking disabled plugin'); + } else if (disabledCards.length > 0) { + await click(disabledCards[0]); + clickedCard = true; + assert.dom(GENERAL.flyout).exists('flyout appears after clicking any disabled plugin'); + } + + // Always verify we completed the test successfully + assert.ok(clickedCard, 'successfully clicked a disabled plugin card'); + }); +}); diff --git a/ui/tests/pages/settings/mount-secret-backend.js b/ui/tests/pages/settings/mount-secret-backend.js index caf3c299de..bf45ebe5da 100644 --- a/ui/tests/pages/settings/mount-secret-backend.js +++ b/ui/tests/pages/settings/mount-secret-backend.js @@ -4,8 +4,8 @@ */ import { create, visitable, fillable, clickable } from 'ember-cli-page-object'; -import { settled } from '@ember/test-helpers'; -import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers'; +import { visit, click, fillIn } from '@ember/test-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; export default create({ visit: visitable('/vault/secrets/mounts'), @@ -18,9 +18,15 @@ export default create({ defaultTTLVal: fillable('input[data-test-ttl-value="Default Lease TTL"]'), defaultTTLUnit: fillable('[data-test-ttl-unit="Default Lease TTL"] [data-test-select="ttl-unit"]'), enable: async function (type, path) { - await this.visit(); - await settled(); - await mountBackend(type, path); - await settled(); + // Navigate to the secrets engines catalog + await visit('/vault/secrets/mounts'); + // Click the engine type card to proceed to configuration + await click(GENERAL.cardContainer(type)); + // Fill in the path if provided + if (path) { + await fillIn(GENERAL.inputByAttr('path'), path); + } + // Submit the form to mount the engine + await click(GENERAL.submitButton); }, });