[UI] Ember Data Migration - SSH Views | VAULT-44224 (#15012) (#15084)

* 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

---------

Co-authored-by: mohit-hashicorp <mohit.ojha@hashicorp.com>
Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-05-29 12:42:57 -06:00 committed by GitHub
parent e36537aef3
commit c39f093f1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 471 additions and 120 deletions

View file

@ -6,7 +6,7 @@
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
<:breadcrumbs>
<KeyValueHeader
@baseKey={{this.model}}
@baseKey={{@form}}
@path="vault.cluster.secrets.backend.list"
@mode={{this.mode}}
@root={{this.breadcrumbs}}
@ -18,7 +18,7 @@
{{#if (eq this.mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if this.model.canDelete}}
{{#if @capabilities.canDelete}}
<ConfirmAction
@buttonText="Delete role"
class="toolbar-button"
@ -27,17 +27,17 @@
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if (eq this.model.keyType "otp")}}
<ToolbarSecretLink @secret={{this.model.id}} @mode="credentials" data-test-backend-credentials @replace={{true}}>
{{#if (eq @form.data.key_type "otp")}}
<ToolbarSecretLink @secret={{@form.data.name}} @mode="credentials" data-test-backend-credentials @replace={{true}}>
Generate Credential
</ToolbarSecretLink>
{{else}}
<ToolbarSecretLink @secret={{this.model.id}} @mode="sign" data-test-backend-credentials @replace={{true}}>
<ToolbarSecretLink @secret={{@form.data.name}} @mode="sign" data-test-backend-credentials @replace={{true}}>
Sign Keys
</ToolbarSecretLink>
{{/if}}
{{#if (or this.model.canUpdate this.model.canDelete)}}
<ToolbarSecretLink @secret={{this.model.id}} @mode="edit" data-test-edit-link={{true}} @replace={{true}}>
{{#if (or @capabilities.canUpdate @capabilities.canDelete)}}
<ToolbarSecretLink @secret={{@form.data.name}} @mode="edit" data-test-edit-link={{true}} @replace={{true}}>
Edit role
</ToolbarSecretLink>
{{/if}}
@ -48,9 +48,8 @@
{{#if (or (eq this.mode "edit") (eq this.mode "create"))}}
<form onsubmit={{action "createOrUpdate" "create"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} />
<NamespaceReminder @mode={{this.mode}} @noun="SSH role" />
<FormFieldGroupsLoop @model={{this.model}} @mode={{this.mode}} />
<FormFieldGroupsLoop @model={{@form}} @mode={{this.mode}} />
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
@ -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}}
</Hds::ButtonSet>
@ -76,18 +75,14 @@
</form>
{{else}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.showFields as |attr|}}
{{#if (eq attr.type "object")}}
{{#each this.displayFields as |field|}}
{{#let (get @form field.name) as |value|}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{stringify (get this.model attr.name)}}
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
@value={{if (eq field.type "object") (stringify value) value}}
@formatTtl={{eq field.options.editType "ttl"}}
/>
{{else}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
/>
{{/if}}
{{/let}}
{{/each}}
</div>
{{/if}}

View file

@ -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;
},
},
});

View file

@ -5,7 +5,7 @@
<LinkedBlock
@params={{array
(concat "vault.cluster.secrets.backend." (if (eq @item.keyType "ca") "sign" "credentials") (unless @item.id "-root"))
(concat "vault.cluster.secrets.backend." (if (eq @item.key_type "ca") "sign" "credentials") (unless @item.id "-root"))
@item.id
}}
class="list-item-row"
@ -17,7 +17,7 @@
<LinkTo
@route={{concat
"vault.cluster.secrets.backend."
(if (eq @item.keyType "ca") "sign" "credentials")
(if (eq @item.key_type "ca") "sign" "credentials")
(unless @item.id "-root")
}}
@model={{@item.id}}
@ -27,8 +27,8 @@
<div class="role-item-details">
<span class="is-underline">{{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}}</span>
<br />
<Hds::Badge @text={{@item.keyType}} />
{{#if @item.zeroAddress}}
<Hds::Badge @text={{@item.key_type}} />
{{#if @item.zero_address}}
<span class="has-text-grey is-size-7">Zero-Address</span>
{{/if}}
</div>
@ -43,30 +43,18 @@
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#if (eq @item.keyType "otp")}}
{{#if @item.generatePath.isPending}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else if @item.canGenerate}}
<dd.Interactive
@route="vault.cluster.secrets.backend.credentials"
@model={{@item.id}}
data-test-role-ssh-link="generate"
>Generate credentials</dd.Interactive>
{{/if}}
{{else if (eq @item.keyType "ca")}}
{{#if @item.signPath.isPending}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else if @item.canGenerate}}
<dd.Interactive
@route="vault.cluster.secrets.backend.sign"
@model={{@item.id}}
data-test-role-ssh-link="generate"
>Sign Keys</dd.Interactive>
{{/if}}
{{#if (and (eq @item.key_type "otp") @item.canGenerate)}}
<dd.Interactive
@route="vault.cluster.secrets.backend.credentials"
@model={{@item.id}}
data-test-role-ssh-link="generate"
>Generate credentials</dd.Interactive>
{{else if (and (eq @item.key_type "ca") @item.canSign)}}
<dd.Interactive
@route="vault.cluster.secrets.backend.sign"
@model={{@item.id}}
data-test-role-ssh-link="generate"
>Sign Keys</dd.Interactive>
{{/if}}
{{#if @loadingToggleZeroAddress}}
<dd.Generic class="has-text-center">
@ -74,35 +62,29 @@
</dd.Generic>
{{else if @item.canEditZeroAddress}}
<dd.Interactive {{on "click" @toggleZeroAddress}}>
{{if @item.zeroAddress "Disable Zero Address" "Enable Zero Address"}}
{{if @item.zero_address "Disable Zero Address" "Enable Zero Address"}}
</dd.Interactive>
{{/if}}
{{#if @item.updatePath.isPending}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else}}
{{#if @item.canRead}}
<dd.Interactive
@route="vault.cluster.secrets.backend.show"
@model={{@item.id}}
data-test-role-ssh-link="show"
>Details</dd.Interactive>
{{/if}}
{{#if @item.canEdit}}
<dd.Interactive
@route="vault.cluster.secrets.backend.edit"
@model={{@item.id}}
data-test-role-ssh-link="edit"
>Edit</dd.Interactive>
{{/if}}
{{#if @item.canDelete}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.showConfirmModal) true)}}
data-test-ssh-role-delete
>Delete</dd.Interactive>
{{/if}}
{{#if @item.canRead}}
<dd.Interactive
@route="vault.cluster.secrets.backend.show"
@model={{@item.id}}
data-test-role-ssh-link="show"
>Details</dd.Interactive>
{{/if}}
{{#if @item.canEdit}}
<dd.Interactive
@route="vault.cluster.secrets.backend.edit"
@model={{@item.id}}
data-test-role-ssh-link="edit"
>Edit</dd.Interactive>
{{/if}}
{{#if @item.canDelete}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.showConfirmModal) true)}}
data-test-ssh-role-delete
>Delete</dd.Interactive>
{{/if}}
</Hds::Dropdown>
{{/if}}

View file

@ -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

190
ui/app/forms/ssh/role.ts Normal file
View file

@ -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<string, FormField> = {
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<SshRoleData> {
/*
* 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);
}
}

View file

@ -9,6 +9,7 @@ import EditBase from './secret-edit';
import KeymgmtKeyForm from 'vault/forms/keymgmt/key';
import KeymgmtProviderForm from 'vault/forms/keymgmt/provider';
import TotpKeyForm from 'vault/forms/totp/key';
import 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);

View file

@ -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 {

View file

@ -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 });
}

View file

@ -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`,

View file

@ -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) {