From 5a7eb390771d1ff50366dd633cab170c5fa28df6 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Mon, 1 Jun 2026 13:15:48 -0600 Subject: [PATCH] [UI] Ember Data Migration - SSH Role Sign Key and Generate Credential Views | Vault-45234 (#15100) (#15107) * migrated ssh views - list, detail, create and edit * adds validation for role name and update test attributes for consistency * updated sign key attr name in test * migrated ssh views - list, detail, create and edit * adds validation for role name and update test attributes for consistency * updated sign key attr name in test * moved flat ordering logic to form as per dynamic selection * Humanized TTL field display value * Apply suggestions from code review * fixed prettier issue * VAULT-45234 - Migrates SSH credential generation and signing components with forms and Api service * fixed review comments * Apply suggestions from code review --------- Co-authored-by: mohit-hashicorp Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com> --- .../components/generate-credentials-ssh.hbs | 77 ++++++++++++ ui/app/components/generate-credentials-ssh.ts | 90 ++++++++++++++ ui/app/components/ssh-sign-key.hbs | 81 +++++++++++++ ui/app/components/ssh-sign-key.ts | 90 ++++++++++++++ ui/app/forms/ssh/otp-credential.ts | 24 ++++ ui/app/forms/ssh/sign.ts | 49 ++++++++ .../cluster/secrets/backend/credentials.js | 4 - .../vault/cluster/secrets/backend/sign.js | 46 +++---- .../cluster/secrets/backend/credentials.hbs | 4 +- .../vault/cluster/secrets/backend/sign.hbs | 113 +----------------- .../secrets/backend/ssh/roles-test.js | 6 +- 11 files changed, 433 insertions(+), 151 deletions(-) create mode 100644 ui/app/components/generate-credentials-ssh.hbs create mode 100644 ui/app/components/generate-credentials-ssh.ts create mode 100644 ui/app/components/ssh-sign-key.hbs create mode 100644 ui/app/components/ssh-sign-key.ts create mode 100644 ui/app/forms/ssh/otp-credential.ts create mode 100644 ui/app/forms/ssh/sign.ts diff --git a/ui/app/components/generate-credentials-ssh.hbs b/ui/app/components/generate-credentials-ssh.hbs new file mode 100644 index 0000000000..3a857eccfe --- /dev/null +++ b/ui/app/components/generate-credentials-ssh.hbs @@ -0,0 +1,77 @@ +{{! + Copyright IBM Corp. 2016, 2026 + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:breadcrumbs> + + + + +{{#if this.otpData}} +
+ + Warning + + You will not be able to access this information later, so please copy the information below. + + + {{#each this.otpDisplayRows as |row|}} + {{#if row.masked}} + + + + {{else}} + + {{/if}} + {{/each}} +
+
+
+ +
+
+ +
+
+{{else}} +
+
+ + + {{#each this.credentialForm.formFields as |attr|}} + + {{/each}} + {{#if this.invalidFormAlert}} + + {{/if}} +
+ + + + +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/generate-credentials-ssh.ts b/ui/app/components/generate-credentials-ssh.ts new file mode 100644 index 0000000000..f7e9118151 --- /dev/null +++ b/ui/app/components/generate-credentials-ssh.ts @@ -0,0 +1,90 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * 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 { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import SshOtpCredentialForm from 'vault/forms/ssh/otp-credential'; + +import type ApiService from 'vault/services/api'; +import type ControlGroupService from 'vault/vault/services/control-group'; + +interface Args { + backendPath: string; + roleName: string; +} + +export default class GenerateCredentialsSsh extends Component { + @service declare readonly api: ApiService; + @service declare readonly controlGroup: ControlGroupService; + + @tracked credentialForm = new SshOtpCredentialForm(); + @tracked otpData: Record | null = null; + @tracked errorMessage: string | null = null; + @tracked modelValidations: Record | null = null; + @tracked invalidFormAlert: string | null = null; + + get otpDisplayRows() { + const data = this.otpData; + if (!data) return []; + return [ + { label: 'Username', value: data['username'] }, + { label: 'IP Address', value: data['ip'] }, + { label: 'Key', value: data['key'], masked: true }, + { label: 'Key type', value: data['key_type'] }, + { label: 'Port', value: data['port'] }, + ].filter((f) => f.value != null && f.value !== ''); + } + + get breadcrumbs() { + const { backendPath, roleName } = this.args; + return [ + { label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath }, + { label: 'Credentials', route: 'vault.cluster.secrets.backend', model: backendPath }, + { label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName }, + { label: 'Generate SSH credentials' }, + ]; + } + + generate = task( + waitFor(async (evt: Event) => { + evt.preventDefault(); + this.errorMessage = null; + + const { isValid, state, invalidFormMessage, data } = this.credentialForm.toJSON(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = isValid ? null : invalidFormMessage; + if (!isValid) return; + try { + const result = await this.api.secrets.sshGenerateCredentials( + this.args.roleName, + this.args.backendPath, + data + ); + this.otpData = (result.data as Record) ?? {}; + } catch (error) { + const { message, response } = await this.api.parseError(error); + if (response?.isControlGroupError) { + this.controlGroup.saveTokenFromError(response); + this.errorMessage = this.controlGroup.logFromError(response).content; + } else { + this.errorMessage = message; + } + } + }) + ); + + @action + reset() { + this.otpData = null; + this.credentialForm = new SshOtpCredentialForm(); + this.errorMessage = null; + this.modelValidations = null; + this.invalidFormAlert = null; + } +} diff --git a/ui/app/components/ssh-sign-key.hbs b/ui/app/components/ssh-sign-key.hbs new file mode 100644 index 0000000000..891967afad --- /dev/null +++ b/ui/app/components/ssh-sign-key.hbs @@ -0,0 +1,81 @@ +{{! + Copyright IBM Corp. 2016, 2026 + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:breadcrumbs> + + + + +{{#if this.signedKeyData}} +
+ + Warning + + You will not be able to access this information later, so please copy the information below. + + + {{#each this.signDisplayRows as |row|}} + + {{/each}} +
+
+
+ +
+ {{#if this.signedKeyData.lease_id}} +
+ +
+ {{/if}} +
+ +
+
+{{else}} +
+
+ + + + {{#if this.invalidFormAlert}} + + {{/if}} +
+ + + + +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/ssh-sign-key.ts b/ui/app/components/ssh-sign-key.ts new file mode 100644 index 0000000000..232025c46c --- /dev/null +++ b/ui/app/components/ssh-sign-key.ts @@ -0,0 +1,90 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * 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 { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import SshSignForm from 'vault/forms/ssh/sign'; + +import type ApiService from 'vault/services/api'; +import type ControlGroupService from 'vault/vault/services/control-group'; + +interface Args { + roleName: string; + backendPath: string; +} + +export default class SshSignKey extends Component { + @service declare readonly api: ApiService; + @service declare readonly controlGroup: ControlGroupService; + + @tracked signForm = new SshSignForm({ cert_type: 'user' }); + @tracked signedKeyData: Record | null = null; + @tracked errorMessage: string | null = null; + @tracked modelValidations: Record | null = null; + @tracked invalidFormAlert: string | null = null; + + get signDisplayRows() { + const data = this.signedKeyData; + if (!data) return []; + return [ + { label: 'Signed key', value: data['signed_key'] }, + { label: 'Lease ID', value: data['lease_id'] }, + { label: 'Renewable', value: data['renewable'] }, + { label: 'Lease duration', value: data['lease_duration'] }, + { label: 'Serial number', value: data['serial_number'] }, + ].filter((f) => f.value != null && f.value !== ''); + } + + get breadcrumbs() { + const { backendPath, roleName } = this.args; + return [ + { label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath }, + { label: 'Sign', route: 'vault.cluster.secrets.backend', model: backendPath }, + { label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName }, + { label: 'Sign SSH Key' }, + ]; + } + + sign = task( + waitFor(async (evt: Event) => { + evt.preventDefault(); + this.errorMessage = null; + + const { isValid, state, invalidFormMessage, data } = this.signForm.toJSON(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = isValid ? null : invalidFormMessage; + if (!isValid) return; + try { + const result = await this.api.secrets.sshSignCertificate( + this.args.roleName, + this.args.backendPath, + data + ); + this.signedKeyData = { ...result, ...(result.data as Record) }; + } catch (error) { + const { message, response } = await this.api.parseError(error); + if (response?.isControlGroupError) { + this.controlGroup.saveTokenFromError(response); + this.errorMessage = this.controlGroup.logFromError(response).content; + } else { + this.errorMessage = message; + } + } + }) + ); + + @action + reset() { + this.signedKeyData = null; + this.signForm = new SshSignForm({ cert_type: 'user' }); + this.errorMessage = null; + this.modelValidations = null; + this.invalidFormAlert = null; + } +} diff --git a/ui/app/forms/ssh/otp-credential.ts b/ui/app/forms/ssh/otp-credential.ts new file mode 100644 index 0000000000..23eb1cf1a1 --- /dev/null +++ b/ui/app/forms/ssh/otp-credential.ts @@ -0,0 +1,24 @@ +/** + * 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 { Validations } from 'vault/vault/app-types'; + +interface SshOtpCredentialData { + username: string; + ip: string; +} + +export default class SshOtpCredentialForm extends Form { + formFields = [ + new FormField('username', 'string', { label: 'Username' }), + new FormField('ip', 'string', { label: 'IP address' }), + ]; + + validations: Validations = { + ip: [{ type: 'presence', message: 'IP address is required' }], + }; +} diff --git a/ui/app/forms/ssh/sign.ts b/ui/app/forms/ssh/sign.ts new file mode 100644 index 0000000000..0bfb66e7a5 --- /dev/null +++ b/ui/app/forms/ssh/sign.ts @@ -0,0 +1,49 @@ +/** + * 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'; +import { Validations } from 'vault/vault/app-types'; + +interface SshSignData { + public_key: string; + key_id?: string; + valid_principals?: string; + cert_type?: string; + critical_options?: Record; + extensions?: Record; + ttl?: string; +} + +export default class SshSignForm extends Form { + get formFieldGroups() { + return [ + new FormFieldGroup('default', [ + new FormField('public_key', 'string', { label: 'Public key' }), + new FormField('valid_principals', 'string', { + label: 'Valid principals', + helpText: + 'Specifies valid principals, either usernames or hostnames, that the certificate should be signed for. Required unless the role has specified allow_empty_principals.', + }), + ]), + new FormFieldGroup('More options', [ + new FormField('key_id', 'string', { label: 'Key ID' }), + new FormField('cert_type', 'string', { + label: 'Certificate Type', + possibleValues: ['user', 'host'], + defaultValue: 'user', + }), + new FormField('critical_options', 'object', { label: 'Critical Options' }), + new FormField('extensions', 'object', { label: 'Extensions' }), + new FormField('ttl', 'string', { label: 'TTL', editType: 'ttl' }), + ]), + ]; + } + + validations: Validations = { + public_key: [{ type: 'presence', message: 'Public Key is required' }], + }; +} diff --git a/ui/app/routes/vault/cluster/secrets/backend/credentials.js b/ui/app/routes/vault/cluster/secrets/backend/credentials.js index c30c2e8948..da1061d354 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/credentials.js +++ b/ui/app/routes/vault/cluster/secrets/backend/credentials.js @@ -20,10 +20,6 @@ export default Route.extend({ if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendType)) { return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backendPath); } - // hydrate model if backend type is ssh - if (backendType === 'ssh') { - this.pathHelp.hydrateModel('ssh-otp-credential', backendPath); - } // assign back button route if (backendType === 'totp') { diff --git a/ui/app/routes/vault/cluster/secrets/backend/sign.js b/ui/app/routes/vault/cluster/secrets/backend/sign.js index 41a44577c7..d29c604adf 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/sign.js +++ b/ui/app/routes/vault/cluster/secrets/backend/sign.js @@ -1,32 +1,21 @@ /** - * Copyright IBM Corp. 2016, 2025 + * Copyright IBM Corp. 2016, 2026 * SPDX-License-Identifier: BUSL-1.1 */ import Route from '@ember/routing/route'; -import UnloadModel from 'vault/mixins/unload-model-route'; import { service } from '@ember/service'; -export default Route.extend(UnloadModel, { +export default Route.extend({ router: service(), - store: service(), + capabilities: service(), templateName: 'vault/cluster/secrets/backend/sign', backendModel() { return this.modelFor('vault.cluster.secrets.backend'); }, - pathQuery(role, backend) { - return { - id: `${backend}/sign/${role}`, - }; - }, - - pathForType() { - return 'sign'; - }, - - model(params) { + async model(params) { const role = params.secret; const backendModel = this.backendModel(); const backend = backendModel.id; @@ -34,23 +23,16 @@ export default Route.extend(UnloadModel, { if (backendModel.type !== 'ssh') { return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend); } - return this.store.queryRecord('capabilities', this.pathQuery(role, backend)).then((capabilities) => { - if (!capabilities.canUpdate) { - return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend); - } - return this.store.createRecord('ssh-sign', { - role: { - backend, - id: role, - name: role, - }, - id: `${backend}-${role}`, - }); - }); - }, - setupController(controller) { - this._super(...arguments); - controller.set('backend', this.backendModel()); + const signPath = this.capabilities.pathFor('sshSign', { backend, id: role }); + const capabilities = await this.capabilities.fetch([signPath]); + if (!capabilities[signPath]?.canUpdate) { + return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend); + } + + return { + roleName: role, + backendPath: backend, + }; }, }); diff --git a/ui/app/templates/vault/cluster/secrets/backend/credentials.hbs b/ui/app/templates/vault/cluster/secrets/backend/credentials.hbs index a8249b31c3..788143fbe9 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/credentials.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/credentials.hbs @@ -1,5 +1,5 @@ {{! - Copyright IBM Corp. 2016, 2025 + Copyright IBM Corp. 2016, 2026 SPDX-License-Identifier: BUSL-1.1 }} @@ -18,6 +18,8 @@ @keyName={{this.model.keyName}} @totpCodePeriod={{this.model.totpCodePeriod}} /> +{{else if (eq this.model.backendType "ssh")}} + {{else}} - <:breadcrumbs> - - - - -{{#if this.model.signedKey}} -
- - Warning - - You will not be able to access this information later, so please copy the information below. - - - {{#each this.model.attrs as |attr|}} - {{#if (eq attr.type "object")}} - - {{else}} - - {{/if}} - {{/each}} -
-
-
- -
- {{#if this.model.leaseId}} -
- -
- {{/if}} -
- -
-
-{{else}} -
-
- - - {{#if this.model.attrs}} - {{#let (find-by "name" "publicKey" this.model.attrs) as |attr|}} - - {{/let}} - {{! valid_principals is required unless allow_empty_principals is true (not recommended) }} - {{#let (find-by "name" "validPrincipals" this.model.attrs) as |attr|}} - - {{/let}} - - {{#if this.showOptions}} -
- {{#each this.model.attrs as |attr|}} - {{! These attrs render above, outside of the "More options" toggle }} - {{#if (not (includes attr.name (array "publicKey" "validPrincipals")))}} - - {{/if}} - {{/each}} -
- {{/if}} - {{/if}} -
- - - - -
-{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/ui/tests/acceptance/secrets/backend/ssh/roles-test.js b/ui/tests/acceptance/secrets/backend/ssh/roles-test.js index bb2e857748..368a156b91 100644 --- a/ui/tests/acceptance/secrets/backend/ssh/roles-test.js +++ b/ui/tests/acceptance/secrets/backend/ssh/roles-test.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2025 + * Copyright IBM Corp. 2016, 2026 * SPDX-License-Identifier: BUSL-1.1 */ @@ -52,8 +52,8 @@ module('Acceptance | ssh | roles', function (hooks) { await click(GENERAL.inputByAttr('allow_empty_principals')); }, async fillInGenerate() { - await fillIn(GENERAL.inputByAttr('publicKey'), PUB_KEY); - await click('[data-test-toggle-button]'); + await fillIn(GENERAL.inputByAttr('public_key'), PUB_KEY); + await click(GENERAL.button('More options')); await click(GENERAL.ttl.toggle('TTL')); await fillIn(GENERAL.selectByAttr('ttl-unit'), 'm');