mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-28 04:10:44 -04:00
* VAULT-44225 - edm secrets totp views * fixed review comments and updated validations to match original * fixed review comments Co-authored-by: mohit-hashicorp <mohit.ojha@hashicorp.com>
This commit is contained in:
parent
19071d59a6
commit
3f2f491f1d
22 changed files with 582 additions and 622 deletions
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from './application';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import { isEmpty } from '@ember/utils';
|
||||
|
||||
export default class TotpKeyAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
// TOTP keys can only be created, so no need for an update method
|
||||
createRecord(store, type, snapshot) {
|
||||
const { name, backend } = snapshot.record;
|
||||
const serializer = store.serializerFor(type.modelName);
|
||||
const data = serializer.serialize(snapshot);
|
||||
const url = this.urlForKey(backend, name);
|
||||
|
||||
return this.ajax(url, 'POST', { data }).then((resp) => {
|
||||
// Ember data doesn't like 204 responses except for DELETE method
|
||||
const response = resp || { data: {} };
|
||||
response.data.id = name;
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
deleteRecord(store, type, snapshot) {
|
||||
const { id } = snapshot;
|
||||
return this.ajax(this.urlForKey(snapshot.record.backend, id), 'DELETE');
|
||||
}
|
||||
|
||||
urlForKey(backend, id) {
|
||||
let url = `${this.buildURL()}/${encodePath(backend)}/keys`;
|
||||
|
||||
if (!isEmpty(id)) {
|
||||
url = `${url}/${encodePath(id)}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
query(store, type, query) {
|
||||
const { backend } = query;
|
||||
return this.ajax(this.urlForKey(backend), 'GET', { data: { list: true } }).then((resp) => {
|
||||
resp.backend = backend;
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { id, backend } = query;
|
||||
return this.ajax(this.urlForKey(backend, id), 'GET').then((resp) => {
|
||||
resp.id = id;
|
||||
resp.backend = backend;
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
generateCode(backend, id) {
|
||||
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/code/${id}`, 'GET').then((res) => {
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
|||
export default class GenerateCredentialsTotp extends Component {
|
||||
@tracked elapsedTime = 0;
|
||||
@tracked totpCode = null;
|
||||
@service store;
|
||||
@service api;
|
||||
@service router;
|
||||
|
||||
title = 'Generate TOTP code';
|
||||
|
|
@ -61,8 +61,8 @@ export default class GenerateCredentialsTotp extends Component {
|
|||
async generateTotpCode(backend, keyName) {
|
||||
// refreshing will generate a new code if the period has expired.
|
||||
try {
|
||||
const totpCode = await this.store.adapterFor('totp-key').generateCode(backend, keyName);
|
||||
this.totpCode = totpCode.code;
|
||||
const resp = await this.api.secrets.totpGenerateCode(keyName, backend);
|
||||
this.totpCode = resp?.data?.code ?? null;
|
||||
} catch (e) {
|
||||
// swallow error, non-essential data
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
|
||||
<:breadcrumbs>
|
||||
<KeyValueHeader
|
||||
@baseKey={{@model}}
|
||||
@baseKey={{@form}}
|
||||
@path="vault.cluster.secrets.backend.list"
|
||||
@mode={{@mode}}
|
||||
@root={{this.breadcrumbs}}
|
||||
|
|
@ -16,16 +16,24 @@
|
|||
</Page::Header>
|
||||
|
||||
{{#if (eq @mode "show")}}
|
||||
<Totp::KeyDetails @model={{@model}} @onDelete={{this.deleteKey}} />
|
||||
<Totp::KeyDetails
|
||||
@key={{this.key}}
|
||||
@capabilities={{@capabilities}}
|
||||
@onDelete={{this.deleteKey}}
|
||||
@displayFields={{this.displayFields}}
|
||||
/>
|
||||
{{else if (eq @mode "create")}}
|
||||
{{#if this.hasGenerated}}
|
||||
<Totp::KeyQrCode @model={{@model}} @onReset={{this.reset}} />
|
||||
<Totp::KeyQrCode
|
||||
@qrSize={{@form.data.qr_size}}
|
||||
@key={{this.key}}
|
||||
@onReset={{this.reset}}
|
||||
@generatedFields={{this.generatedFields}}
|
||||
/>
|
||||
{{else}}
|
||||
<Totp::KeyCreate
|
||||
@onSubmit={{this.createKey}}
|
||||
@model={{@model}}
|
||||
@defaultKeyFormFields={{this.defaultKeyFormFields}}
|
||||
@groups={{this.groups}}
|
||||
@form={{@form}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
@invalidFormAlert={{this.invalidFormAlert}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import { tracked } from '@glimmer/tracking';
|
|||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
/**
|
||||
* @module TotpEdit
|
||||
* `TotpEdit` is a component that allows you to create, view or delete a TOTP key.
|
||||
* When creating a key if `generate` and `exported` are true then after a successful save the UI renders a QR code for the generated key.
|
||||
* @example
|
||||
* <TotpEdit @model={{this.model}} @mode={{this.mode}} />
|
||||
* <TotpEdit @form={{this.form}} @mode={{this.mode}} @capabilities={{this.capabilities}} />
|
||||
*
|
||||
* @param {object} model - The totp key ember data model.
|
||||
* @param {object} form - The TotpKeyForm instance.
|
||||
* @param {string} mode - The mode to render. Either 'create' or 'show'.
|
||||
* @param {object} capabilities - Capabilities object with canDelete, canRead flags.
|
||||
*/
|
||||
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
|
||||
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
||||
|
|
@ -27,12 +27,31 @@ const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
|||
export default class TotpEdit extends Component {
|
||||
@service router;
|
||||
@service flashMessages;
|
||||
@service api;
|
||||
|
||||
@tracked hasGenerated = false;
|
||||
@tracked invalidFormAlert = '';
|
||||
@tracked modelValidations;
|
||||
@tracked key;
|
||||
|
||||
successCallback;
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
// In show mode, the route fetches the key and passes it via @form
|
||||
// This stores it in our tracked property for consistent data source
|
||||
if (args.mode === 'show') {
|
||||
this.key = args.form.data;
|
||||
}
|
||||
}
|
||||
|
||||
displayFields = [
|
||||
{ field: 'account_name', label: 'Account name' },
|
||||
{ field: 'algorithm', label: 'Algorithm' },
|
||||
{ field: 'digits', label: 'Digits' },
|
||||
{ field: 'issuer', label: 'Issuer' },
|
||||
{ field: 'period', label: 'Period' },
|
||||
];
|
||||
|
||||
generatedFields = [{ field: 'url', label: 'URL' }];
|
||||
|
||||
breadcrumbs = [
|
||||
{ label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' },
|
||||
|
|
@ -50,29 +69,7 @@ export default class TotpEdit extends Component {
|
|||
|
||||
get subtitle() {
|
||||
if (this.args.mode === 'create') return '';
|
||||
|
||||
return this.args.model.id;
|
||||
}
|
||||
|
||||
get defaultKeyFormFields() {
|
||||
const shared = ['name', 'generate', 'issuer', 'accountName'];
|
||||
const generated = [...shared, 'exported'];
|
||||
const nonGenerated = [...shared, 'url', 'key'];
|
||||
return this.args.model.generate ? generated : nonGenerated;
|
||||
}
|
||||
|
||||
get groups() {
|
||||
const { generate } = this.args.model;
|
||||
|
||||
const groups = {
|
||||
'TOTP Code Options': ['algorithm', 'digits', 'period'],
|
||||
};
|
||||
|
||||
if (generate) {
|
||||
groups['Provider Options'] = ['keySize', 'skew', 'qrSize'];
|
||||
}
|
||||
|
||||
return groups;
|
||||
return this.args.form.data.name;
|
||||
}
|
||||
|
||||
transitionToRoute() {
|
||||
|
|
@ -81,52 +78,49 @@ export default class TotpEdit extends Component {
|
|||
|
||||
@action
|
||||
reset() {
|
||||
const { name } = this.args.model;
|
||||
this.args.model.unloadRecord();
|
||||
const { name } = this.args.form.data;
|
||||
this.transitionToRoute(SHOW_ROUTE, name);
|
||||
}
|
||||
|
||||
@action
|
||||
async deleteKey() {
|
||||
try {
|
||||
const { id } = this.args.model;
|
||||
await this.args.model.destroyRecord();
|
||||
const { name, backend } = this.args.form.data;
|
||||
await this.api.secrets.totpDeleteKey(name, backend);
|
||||
this.transitionToRoute(LIST_ROOT_ROUTE);
|
||||
this.flashMessages.success(`${id} was successfully deleted.`);
|
||||
this.flashMessages.success(`${name} was successfully deleted.`);
|
||||
} catch (err) {
|
||||
this.flashMessages.danger(errorMessage(err));
|
||||
const { message } = await this.api.parseError(err);
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
|
||||
createKey = task(
|
||||
waitFor(async (event) => {
|
||||
event.preventDefault();
|
||||
const { isValid, state, invalidFormMessage } = this.args.model.validate();
|
||||
const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormAlert = invalidFormMessage;
|
||||
|
||||
if (!isValid) return;
|
||||
try {
|
||||
const allFields = [...this.defaultKeyFormFields, ...Object.values(this.groups).flat()];
|
||||
await this.args.model.save({
|
||||
adapterOptions: {
|
||||
keyFormFields: allFields,
|
||||
},
|
||||
});
|
||||
const { generate, exported } = this.args.model;
|
||||
const { name, backend, generate, exported } = this.args.form.data;
|
||||
const resp = await this.api.secrets.totpCreateKey(name, backend, data);
|
||||
|
||||
if (generate && exported) {
|
||||
// stay in this template and show QR code returned from response
|
||||
if (resp?.data) {
|
||||
this.key = resp.data;
|
||||
}
|
||||
this.hasGenerated = true;
|
||||
} else {
|
||||
// nothing is returned from response, transition to key details route
|
||||
this.transitionToRoute(SHOW_ROUTE, this.args.model.name);
|
||||
this.transitionToRoute(SHOW_ROUTE, name);
|
||||
}
|
||||
this.flashMessages.success('Successfully created key.');
|
||||
} catch (err) {
|
||||
// err will display via model state
|
||||
return;
|
||||
const { message } = await this.api.parseError(err);
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
this.flashMessages.success('Successfully created key.');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
{{!
|
||||
Copyright IBM Corp. 2016, 2025
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#each-in @groups as |group fields|}}
|
||||
<ToggleButton
|
||||
@isOpen={{eq this.showGroup group}}
|
||||
@openLabel={{concat "Hide " group}}
|
||||
@closedLabel={{group}}
|
||||
@onClick={{fn this.toggleGroup group}}
|
||||
class="is-block"
|
||||
data-test-button={{group}}
|
||||
/>
|
||||
{{#if (eq this.showGroup group)}}
|
||||
<div class="box is-marginless" data-test-group={{group}}>
|
||||
{{#each fields as |fieldName|}}
|
||||
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
|
||||
<FormField
|
||||
data-test-field
|
||||
@attr={{attr}}
|
||||
@model={{@model}}
|
||||
@showHelpText={{false}}
|
||||
@modelValidations={{@modelValidations}}
|
||||
/>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each-in}}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class KeyCreateToggleGroupsComponent extends Component {
|
||||
@tracked showGroup = null;
|
||||
|
||||
@action
|
||||
toggleGroup(group, isOpen) {
|
||||
this.showGroup = isOpen ? group : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,8 @@
|
|||
|
||||
<form {{on "submit" (perform @onSubmit)}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @model={{@model}} />
|
||||
<NamespaceReminder @mode={{"create"}} @noun="TOTP key" />
|
||||
{{#each @defaultKeyFormFields as |field|}}
|
||||
{{#let (find-by "name" field @model.allFields) as |attr|}}
|
||||
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{@modelValidations}} />
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
|
||||
<Totp::KeyCreateToggleGroups @model={{@model}} @modelValidations={{@modelValidations}} @groups={{@groups}} />
|
||||
<FormFieldGroups @model={{@form}} @groupName="formFieldGroups" @modelValidations={{@modelValidations}} />
|
||||
</div>
|
||||
<Hds::ButtonSet class="has-top-bottom-margin-12">
|
||||
<Hds::Button @text="Create key" type="submit" data-test-submit />
|
||||
|
|
@ -21,7 +14,7 @@
|
|||
@text="Cancel"
|
||||
@color="secondary"
|
||||
@route="vault.cluster.secrets.backend.list-root"
|
||||
@model={{@model.backend}}
|
||||
@model={{@form.data.backend}}
|
||||
@query={{hash tab="key"}}
|
||||
/>
|
||||
</Hds::ButtonSet>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
<ToolbarLink @route="vault.cluster.secrets.backend.credentials" @model={{@model.id}}>
|
||||
<ToolbarLink @route="vault.cluster.secrets.backend.credentials" @model={{@key.name}}>
|
||||
Generate code
|
||||
</ToolbarLink>
|
||||
{{#if @model.canDelete}}
|
||||
{{#if @capabilities.canDelete}}
|
||||
<ConfirmAction
|
||||
@buttonText="Delete key"
|
||||
class="toolbar-button"
|
||||
|
|
@ -19,10 +19,7 @@
|
|||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-totp-key-details>
|
||||
{{#each @model.attrs as |attr|}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get @model attr.name}}
|
||||
/>
|
||||
{{#each @displayFields as |item|}}
|
||||
<InfoTableRow @label={{item.label}} @value={{get @key item.field}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
|
|
@ -4,36 +4,29 @@
|
|||
}}
|
||||
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
<MessageError @model={{@model}} />
|
||||
{{#unless @model.isError}}
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description>
|
||||
You will not be able to access this information later, so please copy the information below.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/unless}}
|
||||
{{#each @model.generatedAttrs as |attr|}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get @model attr.name}}
|
||||
/>
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description>
|
||||
You will not be able to access this information later, so please copy the information below.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{#each @generatedFields as |item|}}
|
||||
<InfoTableRow @label={{item.label}} @value={{get @key item.field}} />
|
||||
{{/each}}
|
||||
{{#if (gt @model.qrSize 0)}}
|
||||
{{#if (gt @qrSize 0)}}
|
||||
<div class="list-item-row">
|
||||
<div class="center-display column">
|
||||
<QrCode
|
||||
@text={{@model.url}}
|
||||
@text={{@key.url}}
|
||||
@colorLight="#F7F7F7"
|
||||
@width={{@model.qrSize}}
|
||||
@height={{@model.qrSize}}
|
||||
@width={{@qrSize}}
|
||||
@height={{@qrSize}}
|
||||
@correctLevel="L"
|
||||
data-test-qrcode
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ export default Controller.extend(ListController, BackendCrumbMixin, {
|
|||
const { message } = await this.api.parseError(e);
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
} else if (this.backendType === 'totp') {
|
||||
try {
|
||||
await this.api.secrets.totpDeleteKey(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
|
||||
|
|
|
|||
173
ui/app/forms/totp/key.ts
Normal file
173
ui/app/forms/totp/key.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* 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 type { Validations } from 'vault/app-types';
|
||||
import type { TotpCreateKeyRequest } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
type TotpKeyData = TotpCreateKeyRequest & {
|
||||
name?: string;
|
||||
backend?: string;
|
||||
barcode?: string;
|
||||
};
|
||||
|
||||
export default class TotpKeyForm extends Form<TotpKeyData> {
|
||||
get generateString(): string {
|
||||
return this.data.generate !== false ? 'Vault' : 'Other service';
|
||||
}
|
||||
|
||||
set generateString(value: string) {
|
||||
this.data.generate = value === 'Vault';
|
||||
}
|
||||
|
||||
get formFieldGroups() {
|
||||
const isVaultGenerated = this.data.generate !== false;
|
||||
|
||||
const conditionalFields = isVaultGenerated
|
||||
? [
|
||||
new FormField('exported', 'boolean', {
|
||||
editType: 'toggleButton',
|
||||
defaultValue: true,
|
||||
helperTextEnabled: 'QR code and URL will be returned upon generating a key.',
|
||||
helperTextDisabled: 'Vault will not return QR code and url upon key creation.',
|
||||
}),
|
||||
]
|
||||
: [
|
||||
new FormField('url', 'string', {
|
||||
label: 'URL',
|
||||
helpText:
|
||||
'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=<your_secret>&issuer=Vault',
|
||||
subText: 'The TOTP key url string that can be used to configure a key.',
|
||||
}),
|
||||
new FormField('key', 'string', {
|
||||
subText: 'The root key used to generate a TOTP code.',
|
||||
}),
|
||||
];
|
||||
|
||||
const defaultFields = [
|
||||
new FormField('name', 'string', {
|
||||
subText: 'Specifies the name for this key.',
|
||||
editDisabled: !this.isNew,
|
||||
}),
|
||||
new FormField('generate', 'boolean', {
|
||||
label: 'Key Provider',
|
||||
editType: 'radio',
|
||||
possibleValues: ['Vault', 'Other service'],
|
||||
defaultValue: true,
|
||||
fieldValue: 'generateString',
|
||||
subText: 'Specifies if the key should be generated by Vault or passed from another service.',
|
||||
}),
|
||||
new FormField('issuer', 'string', {
|
||||
subText: "The name of the key's issuing organization. Required for keys generated by Vault.",
|
||||
}),
|
||||
new FormField('account_name', 'string', {
|
||||
label: 'Account name',
|
||||
subText: 'The name of the account associated with the key. Required for keys generated by Vault.',
|
||||
}),
|
||||
...conditionalFields,
|
||||
];
|
||||
|
||||
const codeOptionsGroup = new FormFieldGroup('TOTP Code Options', [
|
||||
new FormField('algorithm', 'string', {
|
||||
possibleValues: ['SHA1', 'SHA256', 'SHA512'],
|
||||
defaultValue: 'SHA1',
|
||||
}),
|
||||
new FormField('digits', 'number', {
|
||||
possibleValues: [6, 8],
|
||||
defaultValue: 6,
|
||||
}),
|
||||
new FormField('period', 'string', {
|
||||
editType: 'ttl',
|
||||
helperTextEnabled: 'How long each generated TOTP is valid.',
|
||||
defaultValue: 30,
|
||||
}),
|
||||
]);
|
||||
|
||||
const groups: FormFieldGroup[] = [new FormFieldGroup('default', defaultFields), codeOptionsGroup];
|
||||
|
||||
if (isVaultGenerated) {
|
||||
groups.push(
|
||||
new FormFieldGroup('Provider Options', [
|
||||
new FormField('key_size', 'number', {
|
||||
label: 'Key size',
|
||||
defaultValue: 20,
|
||||
}),
|
||||
new FormField('skew', 'number', {
|
||||
possibleValues: [0, 1],
|
||||
defaultValue: 1,
|
||||
}),
|
||||
new FormField('qr_size', 'number', {
|
||||
label: 'QR size',
|
||||
defaultValue: 200,
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
validations: Validations = {
|
||||
account_name: [
|
||||
{
|
||||
validator: (data: TotpKeyData) => {
|
||||
return data.generate === false || !!data.account_name;
|
||||
},
|
||||
message: "Account name can't be blank when the key is generated by Vault.",
|
||||
},
|
||||
{
|
||||
type: 'containsWhiteSpace',
|
||||
message:
|
||||
"Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
issuer: [
|
||||
{
|
||||
validator: (data: TotpKeyData) => data.generate === false || !!data.issuer,
|
||||
message: "Issuer can't be blank when the key is generated by Vault.",
|
||||
},
|
||||
],
|
||||
key: [
|
||||
{
|
||||
validator: (data: TotpKeyData) => data.generate !== false || !!data.url || !!data.key,
|
||||
message: "Key can't be blank if key is being passed from another service and the URL is empty.",
|
||||
},
|
||||
],
|
||||
key_size: [{ type: 'number', message: 'Key size must be a number.' }],
|
||||
name: [
|
||||
{ type: 'presence', message: "Name can't be blank." },
|
||||
{
|
||||
type: 'containsWhiteSpace',
|
||||
message:
|
||||
"Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
qr_size: [{ type: 'number', message: 'QR size must be a number.' }],
|
||||
};
|
||||
|
||||
toJSON() {
|
||||
const { isValid, state, invalidFormMessage } = super.toJSON();
|
||||
const data = { ...this.data } as Record<string, unknown>;
|
||||
delete data['name'];
|
||||
delete data['backend'];
|
||||
delete data['barcode'];
|
||||
|
||||
if (data['generate'] !== false) {
|
||||
delete data['url'];
|
||||
delete data['key'];
|
||||
} else {
|
||||
delete data['key_size'];
|
||||
delete data['skew'];
|
||||
delete data['exported'];
|
||||
delete data['qr_size'];
|
||||
}
|
||||
|
||||
return { isValid, state, invalidFormMessage, data };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import { isPresent } from '@ember/utils';
|
||||
|
||||
const validations = {
|
||||
accountName: [
|
||||
{
|
||||
validator(model) {
|
||||
const { generate, accountName } = model;
|
||||
// this is required when generate is true
|
||||
return generate && !isPresent(accountName) ? false : true;
|
||||
},
|
||||
message: "Account name can't be blank when the key is generated by Vault.",
|
||||
},
|
||||
{
|
||||
type: 'containsWhiteSpace',
|
||||
message:
|
||||
"Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
issuer: [
|
||||
{
|
||||
validator(model) {
|
||||
const { generate, issuer } = model;
|
||||
// this is required when generate is true
|
||||
return generate && !isPresent(issuer) ? false : true;
|
||||
},
|
||||
message: "Issuer can't be blank when when the key is generated by Vault.",
|
||||
},
|
||||
],
|
||||
key: [
|
||||
{
|
||||
validator(model) {
|
||||
const { generate, key, url } = model;
|
||||
// this is required when generate is false and url is blank
|
||||
return !generate && !isPresent(url) && !isPresent(key) ? false : true;
|
||||
},
|
||||
message: "Key can't be blank if key is being passed from another service and the URL is empty.",
|
||||
},
|
||||
],
|
||||
keySize: [{ type: 'number', message: 'Key size must be a number.' }],
|
||||
name: [
|
||||
{ type: 'presence', message: "Name can't be blank." },
|
||||
{
|
||||
type: 'containsWhiteSpace',
|
||||
message:
|
||||
"Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
qrSize: [{ type: 'number', message: 'QR size must be a number' }],
|
||||
};
|
||||
|
||||
@withModelValidations(validations)
|
||||
@withExpandedAttributes()
|
||||
@withFormFields()
|
||||
export default class TotpKeyModel extends Model {
|
||||
@attr('string', {
|
||||
readOnly: true,
|
||||
})
|
||||
backend;
|
||||
|
||||
@attr('string', {
|
||||
subText: 'Specifies the name for this key.',
|
||||
})
|
||||
name;
|
||||
|
||||
@attr('string', {
|
||||
subText: 'The name of the account associated with the key. Required for keys generated by Vault.',
|
||||
})
|
||||
accountName;
|
||||
|
||||
@attr('string', {
|
||||
possibleValues: ['SHA1', 'SHA256', 'SHA512'],
|
||||
defaultValue: 'SHA1',
|
||||
})
|
||||
algorithm;
|
||||
|
||||
@attr('number', {
|
||||
possibleValues: [6, 8],
|
||||
defaultValue: 6,
|
||||
})
|
||||
digits;
|
||||
|
||||
@attr('string', {
|
||||
subText: `The name of the key's issuing organization. Required for keys generated by Vault.`,
|
||||
})
|
||||
issuer;
|
||||
|
||||
@attr({
|
||||
editType: 'ttl',
|
||||
helperTextEnabled: 'How long each generated TOTP is valid.',
|
||||
defaultValue: 30, // API accepts both an integer as seconds and string with unit e.g 30 || '30s'
|
||||
})
|
||||
period;
|
||||
|
||||
// The generate attr is a boolean. The generateString getter and setter is used only in forms to get and set the boolean via
|
||||
// strings values. The payload params expect the attr to be a boolean value.
|
||||
@attr({
|
||||
label: 'Key Provider',
|
||||
defaultValue: true,
|
||||
editType: 'radio',
|
||||
possibleValues: ['Vault', 'Other service'],
|
||||
fieldValue: 'generateString',
|
||||
subText: 'Specifies if the key should be generated by Vault or passed from another service.',
|
||||
})
|
||||
generate;
|
||||
|
||||
// Used when generate is true
|
||||
@attr('number', {
|
||||
defaultValue: 20,
|
||||
})
|
||||
keySize;
|
||||
|
||||
@attr('number', {
|
||||
possibleValues: [0, 1],
|
||||
defaultValue: 1,
|
||||
})
|
||||
skew;
|
||||
|
||||
@attr('boolean', {
|
||||
editType: 'toggleButton',
|
||||
defaultValue: true,
|
||||
helperTextDisabled: 'Vault will not return QR code and url upon key creation.',
|
||||
helperTextEnabled: 'QR code and URL will be returned upon generating a key.',
|
||||
})
|
||||
exported;
|
||||
|
||||
@attr('number', {
|
||||
label: 'QR size',
|
||||
defaultValue: 200,
|
||||
})
|
||||
qrSize;
|
||||
|
||||
// Used when generate is false
|
||||
@attr('string', {
|
||||
label: 'URL',
|
||||
helpText:
|
||||
'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=<your_secret>&issuer=Vault',
|
||||
subText: 'The TOTP key url string that can be used to configure a key.',
|
||||
})
|
||||
url;
|
||||
|
||||
@attr('string', {
|
||||
subText: 'The root key used to generate a TOTP code.',
|
||||
})
|
||||
key;
|
||||
|
||||
// Returned when a key is created as provider
|
||||
@attr('string', {
|
||||
readOnly: true,
|
||||
})
|
||||
barcode;
|
||||
|
||||
get attrs() {
|
||||
const keys = ['accountName', 'name', 'algorithm', 'digits', 'issuer', 'period'];
|
||||
return keys.map((k) => this.allByKey[k]);
|
||||
}
|
||||
|
||||
get generatedAttrs() {
|
||||
const keys = ['url'];
|
||||
return keys.map((k) => this.allByKey[k]);
|
||||
}
|
||||
|
||||
get generateString() {
|
||||
return this.generate ? 'Vault' : 'Other service';
|
||||
}
|
||||
|
||||
set generateString(value) {
|
||||
this.generate = value === 'Vault' ? true : false;
|
||||
}
|
||||
|
||||
@lazyCapabilities(apiPath`${'backend'}/keys/${'id'}`, 'backend', 'id') keyPath;
|
||||
|
||||
get canDelete() {
|
||||
return this.keyPath.get('canDelete');
|
||||
}
|
||||
|
||||
get canRead() {
|
||||
return this.keyPath.get('canRead');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { service } from '@ember/service';
|
|||
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 { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
const secretModel = (store, backend, key) => {
|
||||
|
|
@ -49,6 +50,23 @@ export default EditBase.extend({
|
|||
return new KeymgmtProviderForm(defaultValues, { isNew: true });
|
||||
}
|
||||
|
||||
if (modelType === 'totp-key') {
|
||||
return new TotpKeyForm(
|
||||
{
|
||||
backend,
|
||||
generate: true,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
exported: true,
|
||||
key_size: 20,
|
||||
skew: 1,
|
||||
qr_size: 200,
|
||||
},
|
||||
{ isNew: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (modelType === 'role-ssh') {
|
||||
return this.store.createRecord(modelType, { keyType: 'ca' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default Route.extend({
|
|||
pathHelp: service('path-help'),
|
||||
router: service(),
|
||||
store: service(),
|
||||
api: service(),
|
||||
|
||||
beforeModel(transition) {
|
||||
const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend');
|
||||
|
|
@ -56,11 +57,11 @@ export default Route.extend({
|
|||
|
||||
async getTotpKey(backend, keyName) {
|
||||
try {
|
||||
const key = await this.store.queryRecord('totp-key', { id: keyName, backend });
|
||||
return key;
|
||||
const resp = await this.api.secrets.totpReadKey(keyName, backend);
|
||||
return resp.data || {};
|
||||
} catch (e) {
|
||||
// swallow error, non-essential data
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { resolve } from 'rsvp';
|
|||
import {
|
||||
SecretsApiKeyManagementListKeysListEnum,
|
||||
SecretsApiKeyManagementListKmsProvidersListEnum,
|
||||
SecretsApiTotpListKeysListEnum,
|
||||
} from '@hashicorp/vault-client-typescript';
|
||||
|
||||
const SUPPORTED_BACKENDS = supportedSecretBackends();
|
||||
|
|
@ -97,8 +98,8 @@ export default Route.extend({
|
|||
return this.router.transitionTo('vault.cluster.secrets.backend.kv.list', backend);
|
||||
}
|
||||
const modelType = this.getModelType(effectiveType, tab);
|
||||
// Keymgmt routes use API-backed forms instead of Ember Data models, so skip model hydration.
|
||||
if (effectiveType === 'keymgmt') {
|
||||
// Keymgmt and TOTP routes use API-backed forms instead of Ember Data models, so skip model hydration.
|
||||
if (effectiveType === 'keymgmt' || effectiveType === 'totp') {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +112,35 @@ export default Route.extend({
|
|||
return getModelTypeForEngine(type, { tab });
|
||||
},
|
||||
|
||||
async fetchTotpKeys(backend, page, pageFilter) {
|
||||
try {
|
||||
const resp = await this.api.secrets.totpListKeys(backend, SecretsApiTotpListKeysListEnum.TRUE);
|
||||
const keys = resp.keys || [];
|
||||
|
||||
const pathsToFetch = keys.map((name) => this.capabilitiesService.pathFor('totpKey', { backend, name }));
|
||||
const capabilities = pathsToFetch.length ? await this.capabilitiesService.fetch(pathsToFetch) : {};
|
||||
|
||||
const items = keys.map((name) => {
|
||||
const keyPath = this.capabilitiesService.pathFor('totpKey', { backend, name });
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
backend,
|
||||
canRead: capabilities[keyPath]?.canRead || false,
|
||||
canDelete: capabilities[keyPath]?.canDelete || false,
|
||||
};
|
||||
});
|
||||
|
||||
return paginate(items, { page, filter: pageFilter });
|
||||
} catch (error) {
|
||||
const { status } = await this.api.parseError(error);
|
||||
if (status === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchKeysWithCapabilities(backend) {
|
||||
const { keys } = await this.api.secrets.keyManagementListKeys(
|
||||
backend,
|
||||
|
|
@ -217,9 +247,13 @@ export default Route.extend({
|
|||
const effectiveType = getEffectiveEngineType(backendModel.engineType);
|
||||
const modelType = this.getModelType(effectiveType, params.tab);
|
||||
|
||||
// Handle keymgmt keys with API service
|
||||
// Handle keymgmt and TOTP resources with API service
|
||||
let secrets;
|
||||
if (effectiveType === 'keymgmt') {
|
||||
if (effectiveType === 'totp') {
|
||||
const page = getValidPage(params.page);
|
||||
secrets = await this.fetchTotpKeys(backend, page, params.pageFilter);
|
||||
this.set('has404', false);
|
||||
} else if (effectiveType === 'keymgmt') {
|
||||
const page = getValidPage(params.page);
|
||||
const filter = params.pageFilter;
|
||||
secrets =
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { getBackendEffectiveType, getEnginePathParam } from 'vault/utils/backend
|
|||
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 {
|
||||
SecretsApiKeyManagementListKmsProvidersForKeyListEnum,
|
||||
SecretsApiTransformListRolesListEnum,
|
||||
|
|
@ -126,8 +127,8 @@ export default Route.extend({
|
|||
buildModel(secret, queryParams) {
|
||||
const backend = getEnginePathParam(this);
|
||||
const modelType = this.modelType(backend, secret, { queryParams });
|
||||
// Keymgmt resources are loaded through API-backed forms, so Ember Data hydration is unnecessary.
|
||||
if (modelType === 'secret' || modelType.startsWith('keymgmt/')) {
|
||||
// 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') {
|
||||
return resolve();
|
||||
}
|
||||
return this.pathHelp.hydrateModel(modelType, backend);
|
||||
|
|
@ -221,6 +222,32 @@ export default Route.extend({
|
|||
return { created, last_rotated, versions: versionsArray };
|
||||
},
|
||||
|
||||
async fetchTotpKey(backend, name) {
|
||||
const resp = await this.api.secrets.totpReadKey(name, backend);
|
||||
const data = resp.data || {};
|
||||
return new TotpKeyForm(
|
||||
{
|
||||
...data,
|
||||
name,
|
||||
backend,
|
||||
},
|
||||
{ isNew: false }
|
||||
);
|
||||
},
|
||||
|
||||
async fetchTotpKeyCapabilities(backend, name) {
|
||||
const keyPath = this.capabilitiesService.pathFor('totpKey', { backend, name });
|
||||
const keysPath = this.capabilitiesService.pathFor('totpKeys', { backend });
|
||||
|
||||
const capabilities = await this.capabilitiesService.fetch([keyPath, keysPath]);
|
||||
|
||||
return {
|
||||
canDelete: capabilities[keyPath]?.canDelete,
|
||||
canRead: capabilities[keyPath]?.canRead,
|
||||
canList: capabilities[keysPath]?.canList,
|
||||
};
|
||||
},
|
||||
|
||||
async fetchKeymgmtKey(backend, name) {
|
||||
const { data } = await this.api.secrets.keyManagementReadKey(name, backend);
|
||||
|
||||
|
|
@ -344,8 +371,11 @@ export default Route.extend({
|
|||
let transformRoles;
|
||||
let capabilities;
|
||||
|
||||
// Handle keymgmt resources with API service
|
||||
if (modelType === 'keymgmt/key') {
|
||||
// Handle TOTP resources with API service
|
||||
if (modelType === 'totp-key') {
|
||||
secretModel = await this.fetchTotpKey(backend, secret);
|
||||
capabilities = await this.fetchTotpKeyCapabilities(backend, secret);
|
||||
} else if (modelType === 'keymgmt/key') {
|
||||
secretModel = await this.fetchKeymgmtKey(backend, secret);
|
||||
capabilities = await this.fetchKeymgmtKeyCapabilities(backend, secret);
|
||||
} else if (modelType === 'keymgmt/provider') {
|
||||
|
|
@ -389,15 +419,16 @@ export default Route.extend({
|
|||
// mode will be 'show', 'edit', 'create'
|
||||
const mode = this.routeName.split('.').pop().replace('-root', '');
|
||||
|
||||
// Handle keymgmt forms differently - Resource or Form doesn't have setProperties
|
||||
// Handle keymgmt and TOTP forms differently - Resource or Form doesn't have setProperties
|
||||
const modelType = this.modelType(backend, secret);
|
||||
if (!['keymgmt/key', 'keymgmt/provider'].includes(modelType)) {
|
||||
const formModelTypes = ['keymgmt/key', 'keymgmt/provider', 'totp-key'];
|
||||
if (!formModelTypes.includes(modelType)) {
|
||||
model.secret.setProperties({ backend });
|
||||
}
|
||||
|
||||
controller.setProperties({
|
||||
model: model.secret,
|
||||
form: ['keymgmt/key', 'keymgmt/provider'].includes(modelType) ? model.secret : null,
|
||||
form: formModelTypes.includes(modelType) ? model.secret : null,
|
||||
capabilities: model.capabilities,
|
||||
baseKey: { id: secret },
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ApplicationSerializer from './application';
|
||||
import { camelize } from '@ember/string';
|
||||
|
||||
export default class TotpKeySerializer extends ApplicationSerializer {
|
||||
normalizeItems(payload, requestType) {
|
||||
if (
|
||||
requestType !== 'queryRecord' &&
|
||||
payload.data &&
|
||||
payload.data.keys &&
|
||||
Array.isArray(payload.data.keys)
|
||||
) {
|
||||
// if we have data.keys, it's a list of ids, so we map over that
|
||||
// and create objects with id's
|
||||
return payload.data.keys.map((secret) => ({
|
||||
id: secret,
|
||||
backend: payload.backend,
|
||||
}));
|
||||
}
|
||||
|
||||
Object.assign(payload, payload.data);
|
||||
delete payload.data;
|
||||
return payload;
|
||||
}
|
||||
|
||||
serialize(snapshot) {
|
||||
// remove all fields that are not relevant to specified key provider
|
||||
const { keyFormFields } = snapshot.adapterOptions;
|
||||
const json = super.serialize(...arguments);
|
||||
Object.keys(json).forEach((key) => {
|
||||
if (!keyFormFields.includes(camelize(key))) {
|
||||
delete json[key];
|
||||
}
|
||||
});
|
||||
|
||||
// remove name as it isn't a parameter - it is a part of the request url
|
||||
delete json.name;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
|
@ -84,4 +84,6 @@ export const PATH_MAP = {
|
|||
syncDestination: apiPath`sys/sync/destinations/${'type'}/${'name'}`,
|
||||
syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`,
|
||||
syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`,
|
||||
totpKey: apiPath`${'backend'}/keys/${'name'}`,
|
||||
totpKeys: apiPath`${'backend'}/keys`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ module('Acceptance | totp key backend', function (hooks) {
|
|||
const createVaultKey = async (keyName, issuer, accountName, exported = true, qrSize = 200) => {
|
||||
await fillIn(GENERAL.inputByAttr('name'), keyName);
|
||||
await fillIn(GENERAL.inputByAttr('issuer'), issuer);
|
||||
await fillIn(GENERAL.inputByAttr('accountName'), accountName);
|
||||
await fillIn(GENERAL.inputByAttr('account_name'), accountName);
|
||||
if (!exported) {
|
||||
await click(GENERAL.toggleInput('toggle-exported'));
|
||||
}
|
||||
if (qrSize !== 200) {
|
||||
await click(GENERAL.button('Provider Options'));
|
||||
await fillIn(GENERAL.inputByAttr('qrSize'), qrSize);
|
||||
await fillIn(GENERAL.inputByAttr('qr_size'), qrSize);
|
||||
}
|
||||
await click(GENERAL.submitButton);
|
||||
};
|
||||
|
|
@ -34,7 +34,7 @@ module('Acceptance | totp key backend', function (hooks) {
|
|||
await click(GENERAL.radioByAttr('Other service'));
|
||||
await fillIn(GENERAL.inputByAttr('name'), keyName);
|
||||
await fillIn(GENERAL.inputByAttr('issuer'), issuer);
|
||||
await fillIn(GENERAL.inputByAttr('accountName'), accountName);
|
||||
await fillIn(GENERAL.inputByAttr('account_name'), accountName);
|
||||
if (url) await fillIn(GENERAL.inputByAttr('url'), url);
|
||||
if (key) await fillIn(GENERAL.inputByAttr('key'), key);
|
||||
await click(GENERAL.submitButton);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ import { render, fillIn, click } from '@ember/test-helpers';
|
|||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import TotpKeyForm from 'vault/forms/totp/key';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Integration | Component | totp/key-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.api = this.owner.lookup('service:api');
|
||||
this.root = {
|
||||
label: 'totp',
|
||||
text: 'totp',
|
||||
|
|
@ -24,38 +26,35 @@ module('Integration | Component | totp/key-form', function (hooks) {
|
|||
};
|
||||
});
|
||||
|
||||
test('it should save new key generated by Vault', async function (assert) {
|
||||
assert.expect(7);
|
||||
hooks.afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
this.server.post('/totp/keys/test-key', (schema, req) => {
|
||||
assert.ok(true, 'Request made to save key');
|
||||
const payload = JSON.parse(req.requestBody);
|
||||
const expected = {
|
||||
account_name: 'test-account',
|
||||
test('it should save new key generated by Vault', async function (assert) {
|
||||
assert.expect(15);
|
||||
|
||||
const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} });
|
||||
|
||||
this.form = new TotpKeyForm(
|
||||
{
|
||||
backend: 'totp',
|
||||
generate: true,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
exported: true,
|
||||
generate: true,
|
||||
issuer: 'test-issuer',
|
||||
key_size: 20,
|
||||
period: 30,
|
||||
qr_size: 200,
|
||||
exported: true,
|
||||
key_size: 20,
|
||||
skew: 1,
|
||||
};
|
||||
qr_size: 200,
|
||||
},
|
||||
{ isNew: true }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
payload,
|
||||
expected,
|
||||
'POST request made with correct properties when creating a key generated by Vault'
|
||||
);
|
||||
});
|
||||
|
||||
this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' });
|
||||
await render(hbs`
|
||||
<TotpEdit
|
||||
@mode="create"
|
||||
@root={{this.root}}
|
||||
@model={{this.model}}
|
||||
@form={{this.form}}
|
||||
/>
|
||||
`);
|
||||
|
||||
|
|
@ -70,51 +69,66 @@ module('Integration | Component | totp/key-form', function (hooks) {
|
|||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('issuer'))
|
||||
.hasText(
|
||||
`Issuer can't be blank when when the key is generated by Vault.`,
|
||||
`Issuer can't be blank when the key is generated by Vault.`,
|
||||
'Validation messages are shown for issuer'
|
||||
);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('accountName'))
|
||||
.dom(GENERAL.validationErrorByAttr('account_name'))
|
||||
.hasText(
|
||||
`Account name can't be blank when the key is generated by Vault.`,
|
||||
'Validation messages are shown for account name'
|
||||
);
|
||||
assert.dom(GENERAL.inlineAlert).hasText('There are 3 errors with this form.', 'Renders form error count');
|
||||
|
||||
await fillIn('[data-test-input="name"]', 'test-key');
|
||||
await fillIn('[data-test-input="issuer"]', 'test-issuer');
|
||||
await fillIn('[data-test-input="accountName"]', 'test-account');
|
||||
await fillIn('[data-test-input="account_name"]', 'test-account');
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
|
||||
assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called to save the key');
|
||||
|
||||
const [name, backend, data] = totpCreateKeyStub.firstCall.args;
|
||||
assert.strictEqual(name, 'test-key', 'called with correct name');
|
||||
assert.strictEqual(backend, 'totp', 'called with correct backend');
|
||||
|
||||
// Verify Vault-generated key payload includes required fields
|
||||
assert.strictEqual(data.issuer, 'test-issuer', 'data includes issuer');
|
||||
assert.strictEqual(data.account_name, 'test-account', 'data includes account_name');
|
||||
assert.true(data.generate, 'data includes generate flag');
|
||||
assert.true(data.exported, 'data includes exported');
|
||||
assert.strictEqual(data.key_size, 20, 'data includes key_size');
|
||||
|
||||
// Verify excluded fields for Vault-generated keys
|
||||
assert.false('url' in data, 'data does not include url');
|
||||
assert.false('key' in data, 'data does not include key');
|
||||
assert.false('name' in data, 'data does not include name (passed separately)');
|
||||
});
|
||||
|
||||
test('it should save new key that is not generated by Vault', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(15);
|
||||
|
||||
this.server.post('/totp/keys/test-key', (schema, req) => {
|
||||
assert.ok(true, 'Request made to save key');
|
||||
const payload = JSON.parse(req.requestBody);
|
||||
const expected = {
|
||||
const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} });
|
||||
|
||||
this.form = new TotpKeyForm(
|
||||
{
|
||||
backend: 'totp',
|
||||
generate: true,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
generate: false,
|
||||
key: 'test-root-key',
|
||||
period: 30,
|
||||
};
|
||||
assert.deepEqual(
|
||||
payload,
|
||||
expected,
|
||||
'POST request made with correct properties when creating a key that is not generated by Vault'
|
||||
);
|
||||
});
|
||||
|
||||
this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' });
|
||||
exported: true,
|
||||
key_size: 20,
|
||||
skew: 1,
|
||||
qr_size: 200,
|
||||
},
|
||||
{ isNew: true }
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<TotpEdit
|
||||
@mode="create"
|
||||
@root={{this.root}}
|
||||
@model={{this.model}}
|
||||
@form={{this.form}}
|
||||
/>
|
||||
`);
|
||||
|
||||
|
|
@ -133,28 +147,57 @@ module('Integration | Component | totp/key-form', function (hooks) {
|
|||
.dom(GENERAL.validationErrorByAttr('key'))
|
||||
.hasText(
|
||||
`Key can't be blank if key is being passed from another service and the URL is empty.`,
|
||||
'Validation messages are shown for issuer'
|
||||
'Validation messages are shown for key'
|
||||
);
|
||||
assert.dom(GENERAL.inlineAlert).hasText('There are 2 errors with this form.', 'Renders form error count');
|
||||
|
||||
await fillIn('[data-test-input="name"]', 'test-key');
|
||||
await fillIn('[data-test-input="key"]', 'test-root-key');
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
|
||||
assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called to save the key');
|
||||
|
||||
const [name, backend, data] = totpCreateKeyStub.firstCall.args;
|
||||
assert.strictEqual(name, 'test-key', 'called with correct name');
|
||||
assert.strictEqual(backend, 'totp', 'called with correct backend');
|
||||
|
||||
// Verify non-Vault-generated key payload includes required fields
|
||||
assert.strictEqual(data.key, 'test-root-key', 'data includes key');
|
||||
assert.false(data.generate, 'data includes generate flag set to false');
|
||||
assert.strictEqual(data.algorithm, 'SHA1', 'data includes algorithm');
|
||||
assert.strictEqual(data.digits, 6, 'data includes digits');
|
||||
|
||||
// Verify excluded fields for non-Vault-generated keys
|
||||
assert.false('issuer' in data, 'data does not include issuer');
|
||||
assert.false('account_name' in data, 'data does not include account_name');
|
||||
assert.false('exported' in data, 'data does not include exported');
|
||||
assert.false('key_size' in data, 'data does not include key_size');
|
||||
assert.false('name' in data, 'data does not include name (passed separately)');
|
||||
});
|
||||
|
||||
test('it should toggle groups according to generate', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' });
|
||||
this.onSubmit = () => assert.ok(true, 'onSubmit callback fires on save success');
|
||||
this.form = new TotpKeyForm(
|
||||
{
|
||||
backend: 'totp',
|
||||
generate: true,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
exported: true,
|
||||
key_size: 20,
|
||||
skew: 1,
|
||||
qr_size: 200,
|
||||
},
|
||||
{ isNew: true }
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<TotpEdit
|
||||
@mode="create"
|
||||
@root={{this.root}}
|
||||
@onSubmit={{this.onSubmit}}
|
||||
@model={{this.model}}
|
||||
@form={{this.form}}
|
||||
/>
|
||||
`);
|
||||
|
||||
|
|
@ -169,4 +212,91 @@ module('Integration | Component | totp/key-form', function (hooks) {
|
|||
assert.dom(GENERAL.button('TOTP Code Options')).exists('Common group is shown');
|
||||
assert.dom(GENERAL.button('Provider Options')).doesNotExist('Generated exclusive group is not shown');
|
||||
});
|
||||
|
||||
test('it should show whitespace warnings but allow submission', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} });
|
||||
this.onSubmit = () => {};
|
||||
|
||||
this.form = new TotpKeyForm(
|
||||
{
|
||||
backend: 'totp',
|
||||
generate: true,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
exported: true,
|
||||
key_size: 20,
|
||||
skew: 1,
|
||||
qr_size: 200,
|
||||
},
|
||||
{ isNew: true }
|
||||
);
|
||||
|
||||
this.modelValidations = null;
|
||||
|
||||
await render(hbs`
|
||||
<TotpEdit
|
||||
@mode="create"
|
||||
@root={{this.root}}
|
||||
@form={{this.form}}
|
||||
/>
|
||||
`);
|
||||
|
||||
await fillIn('[data-test-input="name"]', 'test key');
|
||||
await fillIn('[data-test-input="issuer"]', 'test-issuer');
|
||||
await fillIn('[data-test-input="account_name"]', 'test account');
|
||||
|
||||
// Manually trigger validation to check for warnings
|
||||
const { isValid, state } = this.form.toJSON();
|
||||
this.set('modelValidations', state);
|
||||
|
||||
// Re-render to show validations
|
||||
await render(hbs`
|
||||
<Totp::KeyCreate
|
||||
@onSubmit={{this.onSubmit}}
|
||||
@form={{this.form}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
/>
|
||||
`);
|
||||
|
||||
// Verify whitespace warnings are shown
|
||||
assert.true(isValid, 'form is valid despite warnings');
|
||||
assert
|
||||
.dom(GENERAL.validationWarningByAttr('name'))
|
||||
.hasText(
|
||||
"Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
'Warning message shown for name with whitespace'
|
||||
);
|
||||
assert
|
||||
.dom(GENERAL.validationWarningByAttr('account_name'))
|
||||
.hasText(
|
||||
"Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
'Warning message shown for account_name with whitespace'
|
||||
);
|
||||
|
||||
// Re-render with TotpEdit to test actual submission
|
||||
await render(hbs`
|
||||
<TotpEdit
|
||||
@mode="create"
|
||||
@root={{this.root}}
|
||||
@form={{this.form}}
|
||||
/>
|
||||
`);
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
|
||||
// Verify form submission proceeds despite warnings
|
||||
assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called despite whitespace warnings');
|
||||
|
||||
const [name, backend, data] = totpCreateKeyStub.firstCall.args;
|
||||
assert.strictEqual(name, 'test key', 'name with whitespace is passed');
|
||||
assert.strictEqual(backend, 'totp', 'called with correct backend');
|
||||
|
||||
// Verify whitespace values are included in payload
|
||||
assert.strictEqual(data.issuer, 'test-issuer', 'data includes issuer');
|
||||
assert.strictEqual(data.account_name, 'test account', 'account_name with whitespace is included');
|
||||
assert.strictEqual(data.algorithm, 'SHA1', 'data includes algorithm');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,16 +4,17 @@
|
|||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import { run } from '@ember/runloop';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Unit | Decorators | ModelFormFields', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.spy = sinon.spy(console, 'error');
|
||||
this.store = this.owner.lookup('service:store');
|
||||
});
|
||||
hooks.afterEach(function () {
|
||||
this.spy.restore();
|
||||
|
|
@ -27,119 +28,43 @@ module('Unit | Decorators | ModelFormFields', function (hooks) {
|
|||
assert.ok(this.spy.calledWith(message), 'Error is printed to console');
|
||||
});
|
||||
|
||||
test('it returns allFields when arguments not provided', function (assert) {
|
||||
assert.expect(1);
|
||||
// test by instantiating a record that uses this decorator
|
||||
const record = this.store.createRecord('totp-key');
|
||||
assert.deepEqual(
|
||||
record.allFields,
|
||||
[
|
||||
{
|
||||
name: 'backend',
|
||||
options: { readOnly: true },
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
options: {
|
||||
subText: 'Specifies the name for this key.',
|
||||
},
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'accountName',
|
||||
options: {
|
||||
subText: 'The name of the account associated with the key. Required for keys generated by Vault.',
|
||||
},
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'algorithm',
|
||||
options: { possibleValues: ['SHA1', 'SHA256', 'SHA512'], defaultValue: 'SHA1' },
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'digits',
|
||||
options: { possibleValues: [6, 8], defaultValue: 6 },
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'issuer',
|
||||
options: {
|
||||
subText: `The name of the key's issuing organization. Required for keys generated by Vault.`,
|
||||
},
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
options: {
|
||||
editType: 'ttl',
|
||||
helperTextEnabled: 'How long each generated TOTP is valid.',
|
||||
defaultValue: 30,
|
||||
},
|
||||
type: undefined,
|
||||
},
|
||||
{
|
||||
name: 'generate',
|
||||
options: {
|
||||
label: 'Key Provider',
|
||||
defaultValue: true,
|
||||
editType: 'radio',
|
||||
possibleValues: ['Vault', 'Other service'],
|
||||
fieldValue: 'generateString',
|
||||
subText: 'Specifies if the key should be generated by Vault or passed from another service.',
|
||||
},
|
||||
type: undefined,
|
||||
},
|
||||
{
|
||||
name: 'keySize',
|
||||
options: { defaultValue: 20 },
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'skew',
|
||||
options: { possibleValues: [0, 1], defaultValue: 1 },
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'exported',
|
||||
options: {
|
||||
editType: 'toggleButton',
|
||||
defaultValue: true,
|
||||
helperTextDisabled: 'Vault will not return QR code and url upon key creation.',
|
||||
helperTextEnabled: 'QR code and URL will be returned upon generating a key.',
|
||||
},
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'qrSize',
|
||||
options: { label: 'QR size', defaultValue: 200 },
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
options: {
|
||||
label: 'URL',
|
||||
helpText:
|
||||
'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=<your_secret>&issuer=Vault',
|
||||
subText: 'The TOTP key url string that can be used to configure a key.',
|
||||
},
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'key',
|
||||
options: {
|
||||
subText: 'The root key used to generate a TOTP code.',
|
||||
},
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'barcode',
|
||||
options: { readOnly: true },
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
'allFields set on Model class'
|
||||
test('it sets formFields and allFields when applied to a Model subclass', function (assert) {
|
||||
@withFormFields(['name', 'role'])
|
||||
class TestModel extends Model {
|
||||
@attr('string', { label: 'Name' }) name;
|
||||
@attr('string', { label: 'Role' }) role;
|
||||
}
|
||||
this.owner.register('model:test-with-form-fields', TestModel);
|
||||
|
||||
const model = run(() => this.owner.lookup('service:store').createRecord('test-with-form-fields'));
|
||||
|
||||
assert.ok(Array.isArray(model.formFields), 'formFields is set as an array');
|
||||
assert.strictEqual(model.formFields.length, 2, 'formFields contains the specified fields');
|
||||
assert.strictEqual(model.formFields[0].name, 'name', 'first formField is name');
|
||||
assert.strictEqual(model.formFields[1].name, 'role', 'second formField is role');
|
||||
|
||||
assert.ok(Array.isArray(model.allFields), 'allFields is set as an array');
|
||||
assert.strictEqual(model.allFields.length, 2, 'allFields contains all model attributes');
|
||||
});
|
||||
|
||||
test('it sets formFieldGroups when groupPropertyNames are provided', function (assert) {
|
||||
@withFormFields(['name'], [{ default: ['name'] }, { Options: ['role'] }])
|
||||
class GroupedModel extends Model {
|
||||
@attr('string') name;
|
||||
@attr('string') role;
|
||||
}
|
||||
this.owner.register('model:test-with-form-field-groups', GroupedModel);
|
||||
|
||||
const model = run(() => this.owner.lookup('service:store').createRecord('test-with-form-field-groups'));
|
||||
|
||||
assert.ok(Array.isArray(model.formFieldGroups), 'formFieldGroups is set as an array');
|
||||
assert.ok(
|
||||
model.formFieldGroups.some((g) => Object.prototype.hasOwnProperty.call(g, 'default')),
|
||||
'formFieldGroups contains a default group'
|
||||
);
|
||||
assert.ok(
|
||||
model.formFieldGroups.some((g) => Object.prototype.hasOwnProperty.call(g, 'Options')),
|
||||
'formFieldGroups contains the Options group'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ module('Unit | Service | path-help', function (hooks) {
|
|||
assert.notOk(true, 'this method should not be called');
|
||||
return reject();
|
||||
});
|
||||
const modelType = 'totp-key';
|
||||
const modelType = 'cluster';
|
||||
await this.pathHelp.getNewModel(modelType, 'my-kv').then(() => {
|
||||
assert.true(true, 'getNewModel resolves');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue