diff --git a/ui/app/components/role-ssh-edit.hbs b/ui/app/components/role-ssh-edit.hbs index ffaaaf2fd4..7a327f8c15 100644 --- a/ui/app/components/role-ssh-edit.hbs +++ b/ui/app/components/role-ssh-edit.hbs @@ -6,7 +6,7 @@ <:breadcrumbs> - {{#if this.model.canDelete}} + {{#if @capabilities.canDelete}}
{{/if}} - {{#if (eq this.model.keyType "otp")}} - + {{#if (eq @form.data.key_type "otp")}} + Generate Credential {{else}} - + Sign Keys {{/if}} - {{#if (or this.model.canUpdate this.model.canDelete)}} - + {{#if (or @capabilities.canUpdate @capabilities.canDelete)}} + Edit role {{/if}} @@ -48,9 +48,8 @@ {{#if (or (eq this.mode "edit") (eq this.mode "create"))}}
- - +
@@ -60,7 +59,7 @@ @text="Cancel" @color="secondary" @route="vault.cluster.secrets.backend.list-root" - @model={{this.model.backend}} + @model={{@form.data.backend}} @query={{hash tab="role"}} /> {{else}} @@ -68,7 +67,7 @@ @text="Cancel" @color="secondary" @route="vault.cluster.secrets.backend.show" - @models={{array this.model.backend this.model.id}} + @models={{array @form.data.backend @form.data.name}} /> {{/if}} @@ -76,18 +75,14 @@ {{else}}
- {{#each this.model.showFields as |attr|}} - {{#if (eq attr.type "object")}} + {{#each this.displayFields as |field|}} + {{#let (get @form field.name) as |value|}} - {{else}} - - {{/if}} + {{/let}} {{/each}}
{{/if}} \ No newline at end of file diff --git a/ui/app/components/role-ssh-edit.js b/ui/app/components/role-ssh-edit.js index d4ea002318..18d785b97f 100644 --- a/ui/app/components/role-ssh-edit.js +++ b/ui/app/components/role-ssh-edit.js @@ -3,14 +3,21 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import RoleEdit from './role-edit'; +import Component from '@ember/component'; +import { service } from '@ember/service'; import { computed } from '@ember/object'; +import { isBlank } from '@ember/utils'; -export default RoleEdit.extend({ - init() { - this._super(...arguments); - this.set('backendType', 'ssh'); - }, +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default Component.extend({ + api: service(), + router: service(), + flashMessages: service(), + + mode: null, + form: null, breadcrumbs: computed('root', 'title', function () { return [ @@ -31,17 +38,52 @@ export default RoleEdit.extend({ } }), - subtitle: computed('mode', 'model.id', function () { + subtitle: computed('mode', 'form.name', function () { if (this.mode === 'create' || this.mode === 'edit') return; + return this.form.name; + }), - return this.model.id; + displayFields: computed('form.{data.key_type,displayFields}', function () { + return this.form?.displayFields ?? []; }), actions: { + async createOrUpdate(type, event) { + event.preventDefault(); + + const { form } = this; + const { name, id, backend, ...roleData } = form.data; + const roleName = id || name; + + if (type === 'create' && isBlank(roleName)) { + this.flashMessages.danger('Role name is required'); + return; + } + + try { + await this.api.secrets.sshWriteRole(roleName, backend, roleData); + this.router.transitionTo(SHOW_ROUTE, roleName); + } catch (error) { + const { message } = await this.api.parseError(error); + this.flashMessages.danger(message); + } + }, + + async delete() { + const { form } = this; + const { name, backend } = form.data; + + try { + await this.api.secrets.sshDeleteRole(name, backend); + this.router.transitionTo(LIST_ROOT_ROUTE); + } catch (error) { + const { message } = await this.api.parseError(error); + this.flashMessages.danger(message); + } + }, + updateTtl(path, val) { - const model = this.model; - const valueToSet = val.enabled === true ? `${val.seconds}s` : undefined; - model.set(path, valueToSet); + this.form[path] = val.enabled === true ? `${val.seconds}s` : undefined; }, }, }); diff --git a/ui/app/components/secret-list/ssh-role-item.hbs b/ui/app/components/secret-list/ssh-role-item.hbs index 712df06a9f..365c91d4a7 100644 --- a/ui/app/components/secret-list/ssh-role-item.hbs +++ b/ui/app/components/secret-list/ssh-role-item.hbs @@ -5,7 +5,7 @@ {{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}}
- - {{#if @item.zeroAddress}} + + {{#if @item.zero_address}} Zero-Address {{/if}}
@@ -43,30 +43,18 @@ @hasChevron={{false}} data-test-popup-menu-trigger /> - {{#if (eq @item.keyType "otp")}} - {{#if @item.generatePath.isPending}} - - - - {{else if @item.canGenerate}} - Generate credentials - {{/if}} - {{else if (eq @item.keyType "ca")}} - {{#if @item.signPath.isPending}} - - - - {{else if @item.canGenerate}} - Sign Keys - {{/if}} + {{#if (and (eq @item.key_type "otp") @item.canGenerate)}} + Generate credentials + {{else if (and (eq @item.key_type "ca") @item.canSign)}} + Sign Keys {{/if}} {{#if @loadingToggleZeroAddress}} @@ -74,35 +62,29 @@ {{else if @item.canEditZeroAddress}} - {{if @item.zeroAddress "Disable Zero Address" "Enable Zero Address"}} + {{if @item.zero_address "Disable Zero Address" "Enable Zero Address"}} {{/if}} - {{#if @item.updatePath.isPending}} - - - - {{else}} - {{#if @item.canRead}} - Details - {{/if}} - {{#if @item.canEdit}} - Edit - {{/if}} - {{#if @item.canDelete}} - Delete - {{/if}} + {{#if @item.canRead}} + Details + {{/if}} + {{#if @item.canEdit}} + Edit + {{/if}} + {{#if @item.canDelete}} + Delete {{/if}} {{/if}} diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js index ec880dc41a..f3a6ccff9b 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/list.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -7,6 +7,7 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { or } from '@ember/object/computed'; import { service } from '@ember/service'; +import { SecretsApiSshListRolesListEnum } from '@hashicorp/vault-client-typescript'; import ListController from 'core/mixins/list-controller'; import { keyIsFolder } from 'core/utils/key-utils'; import BackendCrumbMixin from 'vault/mixins/backend-crumb'; @@ -39,18 +40,32 @@ export default Controller.extend(ListController, BackendCrumbMixin, { this.set('selectedAction', action); }, - toggleZeroAddress(item, backend) { - item.toggleProperty('zeroAddress'); + // Adds or removes the given SSH role from the zero-address config, then reloads the list. + async toggleZeroAddress(item) { + const backendPath = item.backend; this.set('loading-' + item.id, true); - backend - .saveZeroAddressConfig() - .catch((e) => { - item.set('zeroAddress', false); - this.flashMessages.danger(e.message); - }) - .finally(() => { - this.set('loading-' + item.id, false); - }); + try { + const response = await this.api.secrets.sshListRoles( + backendPath, + SecretsApiSshListRolesListEnum.TRUE + ); + const allRoles = this.api.keyInfoToArray(response); + const newValue = !item.zero_address; + const zeroAddressRoles = allRoles + .filter((role) => (role.id === item.id ? newValue : role.zero_address)) + .map((role) => role.id); + if (zeroAddressRoles.length === 0) { + await this.api.secrets.sshDeleteZeroAddressConfiguration(backendPath); + } else { + await this.api.secrets.sshConfigureZeroAddress(backendPath, { roles: zeroAddressRoles }); + } + this.send('reload'); + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.danger(message); + } finally { + this.set('loading-' + item.id, false); + } }, async delete(item) { @@ -83,6 +98,15 @@ export default Controller.extend(ListController, BackendCrumbMixin, { const { message } = await this.api.parseError(e); this.flashMessages.danger(message); } + } else if (this.backendType === 'ssh') { + try { + await this.api.secrets.sshDeleteRole(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 diff --git a/ui/app/forms/ssh/role.ts b/ui/app/forms/ssh/role.ts new file mode 100644 index 0000000000..1c6b93bb33 --- /dev/null +++ b/ui/app/forms/ssh/role.ts @@ -0,0 +1,190 @@ +/** + * 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 FormFieldGroup from 'vault/utils/forms/field-group'; + +const OTP_FIELD_ORDER = [ + 'name', + 'key_type', + 'default_user', + 'admin_user', + 'port', + 'allowed_users', + 'cidr_list', + 'exclude_cidr_list', +]; + +const CA_FIELD_ORDER = [ + 'name', + 'key_type', + 'allow_user_certificates', + 'allow_host_certificates', + 'default_user', + 'allowed_users', + 'allowed_users_template', + 'allowed_domains', + 'allowed_domains_template', + 'ttl', + 'max_ttl', + 'allowed_critical_options', + 'default_critical_options', + 'allowed_extensions', + 'default_extensions', + 'allow_bare_domains', + 'allow_subdomains', + 'allow_empty_principals', + 'allow_user_key_ids', + 'key_id_format', + 'not_before_duration', + 'algorithm_signer', +]; + +// All field names across both key types, deduplicated, for proxy discovery +const ALL_FIELD_NAMES = [...new Set([...OTP_FIELD_ORDER, ...CA_FIELD_ORDER])]; + +const FIELDS: Record = { + name: new FormField('name', 'string', { + label: 'Role name', + fieldValue: 'name', + }), + key_type: new FormField('key_type', 'string', { + possibleValues: ['ca', 'otp'], + }), + admin_user: new FormField('admin_user', 'string', { + helpText: 'Username of the admin user at the remote host', + }), + default_user: new FormField('default_user', 'string', { + helpText: "Username to use when one isn't specified", + }), + allowed_users: new FormField('allowed_users', 'string', { + helpText: + 'Create a list of users who are allowed to use this key (e.g. `admin, dev`, or use `*` to allow all.)', + }), + allowed_users_template: new FormField('allowed_users_template', 'boolean', { + helpText: + 'Specifies that Allowed Users can be templated e.g. {{identity.entity.aliases.mount_accessor_xyz.name}}', + }), + allowed_domains: new FormField('allowed_domains', 'string', { + helpText: + 'List of domains for which a client can request a certificate (e.g. `example.com`, or `*` to allow all)', + }), + allowed_domains_template: new FormField('allowed_domains_template', 'boolean', { + helpText: + 'Specifies that Allowed Domains can be set using identity template policies. Non-templated domains are also permitted.', + }), + cidr_list: new FormField('cidr_list', 'string', { + helpText: 'List of CIDR blocks for which this role is applicable', + }), + exclude_cidr_list: new FormField('exclude_cidr_list', 'string', { + helpText: 'List of CIDR blocks that are not accepted by this role', + }), + port: new FormField('port', 'number', { + helpText: 'Port number for the SSH connection (default is `22`)', + }), + allowed_critical_options: new FormField('allowed_critical_options', 'string', { + helpText: 'List of critical options that certificates have when signed', + }), + default_critical_options: new FormField('default_critical_options', 'object', { + helpText: 'Map of critical options certificates should have if none are provided when signing', + }), + allowed_extensions: new FormField('allowed_extensions', 'string', { + helpText: 'List of extensions that certificates can have when signed', + }), + default_extensions: new FormField('default_extensions', 'object', { + helpText: 'Map of extensions certificates should have if none are provided when signing', + }), + allow_user_certificates: new FormField('allow_user_certificates', 'boolean', { + helpText: 'Specifies if certificates are allowed to be signed for us as a user', + }), + allow_host_certificates: new FormField('allow_host_certificates', 'boolean', { + helpText: 'Specifies if certificates are allowed to be signed for us as a host', + }), + allow_bare_domains: new FormField('allow_bare_domains', 'boolean', { + helpText: + 'Specifies if host certificates that are requested are allowed to use the base domains listed in Allowed Domains', + }), + allow_subdomains: new FormField('allow_subdomains', 'boolean', { + helpText: + 'Specifies if host certificates that are requested are allowed to be subdomains of those listed in Allowed Domains', + }), + allow_empty_principals: new FormField('allow_empty_principals', 'boolean', { + helpText: + 'Allow signing certificates with no valid principals (e.g. any valid principal). For backwards compatibility only. The default of false is highly recommended.', + }), + allow_user_key_ids: new FormField('allow_user_key_ids', 'boolean', { + helpText: 'Specifies if users can override the key ID for a signed certificate with the "key_id" field', + }), + key_id_format: new FormField('key_id_format', 'string', { + helpText: 'When supplied, this value specifies a custom format for the key id of a signed certificate', + }), + not_before_duration: new FormField('not_before_duration', 'string', { + helpText: 'Specifies the duration by which to backdate the ValidAfter property', + editType: 'ttl', + }), + ttl: new FormField('ttl', 'string', { + editType: 'ttl', + }), + max_ttl: new FormField('max_ttl', 'string', { + editType: 'ttl', + }), + algorithm_signer: new FormField('algorithm_signer', 'string', { + helpText: 'When supplied, this value specifies a signing algorithm for the key', + possibleValues: ['default', 'ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512'], + }), +}; + +type SshRoleData = { + name: string; + id: string; + backend: string; + key_type: string; + [key: string]: unknown; +}; + +export default class SshRoleForm extends Form { + /* + * formFieldGroups always returns all possible fields so the proxy can + * discover every data key regardless of which key_type is active. + * This is called directly on the raw target inside the proxy handler, + * so it must not read any proxied data properties (e.g. this.key_type). + */ + get formFieldGroups() { + return [ + new FormFieldGroup( + 'default', + ALL_FIELD_NAMES.map((name) => FIELDS[name]).filter((f): f is FormField => !!f) + ), + ]; + } + + /* + * fieldGroups is what FormFieldGroupsLoop reads. It is accessed through + * the proxy, so this.key_type correctly resolves to data.key_type. + */ + get fieldGroups() { + const isOtp = this.data.key_type === 'otp'; + const defaultFieldsNum = isOtp ? 3 : 4; + const fieldOrder = isOtp ? [...OTP_FIELD_ORDER] : [...CA_FIELD_ORDER]; + const defaultFieldNames = fieldOrder.splice(0, defaultFieldsNum); + return [ + new FormFieldGroup( + 'default', + defaultFieldNames.map((name) => FIELDS[name]).filter((f): f is FormField => !!f) + ), + new FormFieldGroup( + 'Options', + fieldOrder.map((name) => FIELDS[name]).filter((f): f is FormField => !!f) + ), + ]; + } + + // Flat ordered list of fields for the current key_type, used by the show view. + get displayFields() { + const fieldOrder = this.data.key_type === 'otp' ? OTP_FIELD_ORDER : CA_FIELD_ORDER; + return fieldOrder.map((name) => FIELDS[name]).filter((f): f is FormField => !!f); + } +} diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index 87d2a2e2cf..fb62269d22 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -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 SshRoleForm from 'vault/forms/ssh/role'; import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript'; const secretModel = (store, backend, key) => { @@ -68,7 +69,10 @@ export default EditBase.extend({ } if (modelType === 'role-ssh') { - return this.store.createRecord(modelType, { keyType: 'ca' }); + return new SshRoleForm( + { backend, key_type: 'ca', not_before_duration: '30s', port: 22 }, + { isNew: true } + ); } if (modelType === 'transform') { modelType = transformModel(transition.to.queryParams); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 327c1cc12d..09c5aa48f5 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -22,6 +22,7 @@ import { SecretsApiKeyManagementListKeysListEnum, SecretsApiKeyManagementListKmsProvidersListEnum, SecretsApiTotpListKeysListEnum, + SecretsApiSshListRolesListEnum, } from '@hashicorp/vault-client-typescript'; const SUPPORTED_BACKENDS = supportedSecretBackends(); @@ -98,8 +99,8 @@ export default Route.extend({ return this.router.transitionTo('vault.cluster.secrets.backend.kv.list', backend); } const modelType = this.getModelType(effectiveType, tab); - // Keymgmt and TOTP routes use API-backed forms instead of Ember Data models, so skip model hydration. - if (effectiveType === 'keymgmt' || effectiveType === 'totp') { + // Keymgmt, TOTP, and SSH routes use API-backed forms instead of Ember Data models, so skip model hydration. + if (effectiveType === 'keymgmt' || effectiveType === 'totp' || effectiveType === 'ssh') { return resolve(); } @@ -240,6 +241,72 @@ export default Route.extend({ } }, + async fetchSshRolesWithCapabilities(backend) { + // Fetch roles and zero-address config in parallel (zero-address may 404 if never configured) + const [listResponse, zeroAddressResult] = await Promise.allSettled([ + this.api.secrets.sshListRoles(backend, SecretsApiSshListRolesListEnum.TRUE), + this.api.secrets.sshReadZeroAddressConfigurationRaw({ ssh_mount_path: backend }), + ]); + + if (listResponse.status === 'rejected') throw listResponse.reason; + + const roles = this.api.keyInfoToArray(listResponse.value); + + // Build set of zero-address role names from the config endpoint + let zeroAddressRoles = new Set(); + if (zeroAddressResult.status === 'fulfilled') { + const body = await zeroAddressResult.value.raw.json(); + const names = body?.data?.roles; + if (Array.isArray(names)) { + zeroAddressRoles = new Set(names); + } + } + + // Build all capability paths for batch fetch + const zeroAddressPath = this.capabilitiesService.pathFor('sshZeroAddress', { backend }); + const rolePaths = roles.map((role) => ({ + role: this.capabilitiesService.pathFor('sshRole', { backend, id: role.id }), + credentials: this.capabilitiesService.pathFor('sshCredentials', { backend, id: role.id }), + sign: this.capabilitiesService.pathFor('sshSign', { backend, id: role.id }), + })); + + // Fetch all capabilities in a single request + const allPaths = [ + ...rolePaths.flatMap((paths) => [paths.role, paths.credentials, paths.sign]), + zeroAddressPath, + ]; + const capabilities = await this.capabilitiesService.fetch(allPaths); + + // Merge role data with capabilities + return roles.map((role, index) => { + const paths = rolePaths[index]; + return { + ...role, + backend, + zero_address: zeroAddressRoles.has(role.id), + canRead: capabilities[paths.role]?.canRead || false, + canEdit: capabilities[paths.role]?.canUpdate || false, + canDelete: capabilities[paths.role]?.canDelete || false, + canGenerate: capabilities[paths.credentials]?.canUpdate || false, + canSign: capabilities[paths.sign]?.canUpdate || false, + canEditZeroAddress: capabilities[zeroAddressPath]?.canUpdate || false, + }; + }); + }, + + async fetchSshRoles(backend, page, pageFilter) { + try { + const roles = await this.fetchSshRolesWithCapabilities(backend); + return paginate(roles, { 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); @@ -247,7 +314,7 @@ export default Route.extend({ const effectiveType = getEffectiveEngineType(backendModel.engineType); const modelType = this.getModelType(effectiveType, params.tab); - // Handle keymgmt and TOTP resources with API service + // Handle TOTP, keymgmt and ssh resources with API service let secrets; if (effectiveType === 'totp') { const page = getValidPage(params.page); @@ -261,6 +328,11 @@ export default Route.extend({ ? await this.fetchKeymgmtProviders(backend, page, filter) : await this.fetchKeymgmtKeys(backend, page, filter); + this.set('has404', false); + } else if (effectiveType === 'ssh') { + const page = getValidPage(params.page); + const filter = params.pageFilter; + secrets = await this.fetchSshRoles(backend, page, filter); this.set('has404', false); } else { try { diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index b4a3f79e1c..4c72ccc9a4 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -17,6 +17,7 @@ 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 SshRoleForm from 'vault/forms/ssh/role'; import { SecretsApiKeyManagementListKmsProvidersForKeyListEnum, SecretsApiTransformListRolesListEnum, @@ -127,8 +128,13 @@ export default Route.extend({ buildModel(secret, queryParams) { const backend = getEnginePathParam(this); const modelType = this.modelType(backend, secret, { queryParams }); - // Keymgmt and TOTP resources are loaded through API-backed forms, so Ember Data hydration is unnecessary. - if (modelType === 'secret' || modelType.startsWith('keymgmt/') || modelType === 'totp-key') { + // Keymgmt, TOTP, and SSH role resources are loaded through API-backed forms, so Ember Data hydration is unnecessary. + if ( + modelType === 'secret' || + modelType.startsWith('keymgmt/') || + modelType === 'totp-key' || + modelType === 'role-ssh' + ) { return resolve(); } return this.pathHelp.hydrateModel(modelType, backend); @@ -332,6 +338,38 @@ export default Route.extend({ }; }, + async fetchSshRole(backend, name) { + try { + const { data } = await this.api.secrets.sshReadRole(name, backend); + return new SshRoleForm({ ...data, name, id: name, backend }, { isNew: false }); + } catch (error) { + const { message } = await this.api.parseError(error); + throw new Error(message); + } + }, + + async fetchSshRoleCapabilities(backend, name) { + try { + const rolePath = this.capabilitiesService.pathFor('sshRole', { backend, id: name }); + const credentialsPath = this.capabilitiesService.pathFor('sshCredentials', { backend, id: name }); + const signPath = this.capabilitiesService.pathFor('sshSign', { backend, id: name }); + + const capabilities = await this.capabilitiesService.fetch([rolePath, credentialsPath, signPath]); + + return { + canDelete: capabilities[rolePath]?.canDelete, + canUpdate: capabilities[rolePath]?.canUpdate, + canEdit: capabilities[rolePath]?.canUpdate, + canRead: capabilities[rolePath]?.canRead, + canGenerate: capabilities[credentialsPath]?.canUpdate, + canSign: capabilities[signPath]?.canUpdate, + }; + } catch (error) { + const { message } = await this.api.parseError(error); + throw new Error(message); + } + }, + 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 @@ -371,7 +409,6 @@ export default Route.extend({ let transformRoles; let capabilities; - // Handle TOTP resources with API service if (modelType === 'totp-key') { secretModel = await this.fetchTotpKey(backend, secret); capabilities = await this.fetchTotpKeyCapabilities(backend, secret); @@ -381,6 +418,9 @@ export default Route.extend({ } else if (modelType === 'keymgmt/provider') { secretModel = await this.fetchKeymgmtProvider(backend, secret); capabilities = await this.fetchKeymgmtProviderCapabilities(backend, secret); + } else if (modelType === 'role-ssh') { + secretModel = await this.fetchSshRole(backend, secret); + capabilities = await this.fetchSshRoleCapabilities(backend, secret); } else { capabilities = await this.capabilities(secret, modelType); try { @@ -419,9 +459,9 @@ export default Route.extend({ // mode will be 'show', 'edit', 'create' const mode = this.routeName.split('.').pop().replace('-root', ''); - // Handle keymgmt and TOTP forms differently - Resource or Form doesn't have setProperties + // Handle keymgmt, TOTP, and SSH forms differently - Resource or Form doesn't have setProperties const modelType = this.modelType(backend, secret); - const formModelTypes = ['keymgmt/key', 'keymgmt/provider', 'totp-key']; + const formModelTypes = ['keymgmt/key', 'keymgmt/provider', 'totp-key', 'role-ssh']; if (!formModelTypes.includes(modelType)) { model.secret.setProperties({ backend }); } diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index a7e21e320d..f25ddfc860 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -80,6 +80,10 @@ export const PATH_MAP = { pkiTidy: apiPath`${'backend'}/tidy`, pkiTidyStatus: apiPath`${'backend'}/tidy/status`, policy: apiPath`sys/policies/${'policyType'}/${'id'}`, + sshCredentials: apiPath`${'backend'}/creds/${'id'}`, + sshRole: apiPath`${'backend'}/roles/${'id'}`, + sshSign: apiPath`${'backend'}/sign/${'id'}`, + sshZeroAddress: apiPath`${'backend'}/config/zeroaddress`, syncActivate: apiPath`sys/activation-flags/secrets-sync/activate`, syncDestination: apiPath`sys/sync/destinations/${'type'}/${'name'}`, syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`, diff --git a/ui/tests/acceptance/secrets/backend/ssh/roles-test.js b/ui/tests/acceptance/secrets/backend/ssh/roles-test.js index 2de39cf5b6..bb2e857748 100644 --- a/ui/tests/acceptance/secrets/backend/ssh/roles-test.js +++ b/ui/tests/acceptance/secrets/backend/ssh/roles-test.js @@ -46,10 +46,10 @@ module('Acceptance | ssh | roles', function (hooks) { name: 'carole', credsRoute: 'vault.cluster.secrets.backend.sign', async fillInCreate() { - await click(GENERAL.inputByAttr('allowUserCertificates')); + await click(GENERAL.inputByAttr('allow_user_certificates')); await click(GENERAL.button('Options')); // it's recommended to keep allow_empty_principals false, check for testing so we don't have to input an extra field when signing a key - await click(GENERAL.inputByAttr('allowEmptyPrincipals')); + await click(GENERAL.inputByAttr('allow_empty_principals')); }, async fillInGenerate() { await fillIn(GENERAL.inputByAttr('publicKey'), PUB_KEY); @@ -58,13 +58,12 @@ module('Acceptance | ssh | roles', function (hooks) { await click(GENERAL.ttl.toggle('TTL')); await fillIn(GENERAL.selectByAttr('ttl-unit'), 'm'); - document.querySelector(GENERAL.ttl.input('TTL')).value = 30; + await fillIn(GENERAL.ttl.input('TTL'), '30'); }, assertBeforeGenerate(assert) { - assert.dom('[data-test-form-field-from-model]').exists('renders the FormFieldFromModel'); + assert.dom(GENERAL.ttl.input('TTL')).exists('renders the TTL field'); const value = document.querySelector('[data-test-ttl-value="TTL"]').value; - // confirms that the actions are correctly being passed down to the FormFieldFromModel component - assert.strictEqual(value, '30', 'renders action updateTtl'); + assert.strictEqual(value, '30', 'TTL value is set correctly'); }, assertAfterGenerate(assert, sshPath) { assert.strictEqual( @@ -83,9 +82,9 @@ module('Acceptance | ssh | roles', function (hooks) { name: 'otprole', credsRoute: 'vault.cluster.secrets.backend.credentials', async fillInCreate() { - await fillIn(GENERAL.inputByAttr('defaultUser'), 'admin'); + await fillIn(GENERAL.inputByAttr('default_user'), 'admin'); await click(GENERAL.button('Options')); - await fillIn(GENERAL.inputByAttr('cidrList'), '1.2.3.4/32'); + await fillIn(GENERAL.inputByAttr('cidr_list'), '1.2.3.4/32'); }, async fillInGenerate() { await fillIn(GENERAL.inputByAttr('username'), 'admin'); @@ -126,7 +125,7 @@ module('Acceptance | ssh | roles', function (hooks) { .includesText('SSH Role', `${role.type}: renders the create page`); await fillIn(GENERAL.inputByAttr('name'), role.name); - await fillIn(GENERAL.inputByAttr('keyType'), role.type); + await fillIn(GENERAL.inputByAttr('key_type'), role.type); await role.fillInCreate(); await settled(); @@ -179,11 +178,10 @@ module('Acceptance | ssh | roles', function (hooks) { module('Acceptance | ssh | otp role', function () { const createOTPRole = async (name) => { await fillIn(GENERAL.inputByAttr('name'), name); - await fillIn(GENERAL.inputByAttr('keyType'), name); + await fillIn(GENERAL.inputByAttr('key_type'), 'otp'); await click(GENERAL.button('Options')); - await fillIn(GENERAL.inputByAttr('keyType'), 'otp'); - await fillIn(GENERAL.inputByAttr('defaultUser'), 'admin'); - await fillIn(GENERAL.inputByAttr('cidrList'), '0.0.0.0/0'); + await fillIn(GENERAL.inputByAttr('default_user'), 'admin'); + await fillIn(GENERAL.inputByAttr('cidr_list'), '0.0.0.0/0'); await click(SES.ssh.createRole); }; test('it deletes a role from list view', async function (assert) {