mirror of
https://github.com/hashicorp/vault.git
synced 2026-06-08 16:24:51 -04:00
* 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:
parent
e36537aef3
commit
c39f093f1e
10 changed files with 471 additions and 120 deletions
|
|
@ -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}}
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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
190
ui/app/forms/ssh/role.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue