mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-19 13:41:10 +02:00
Refactor SSH Configuration workflow (#28122)
* initial copy from other #28004 * pr feedback * grr
This commit is contained in:
parent
c99e4f1a3f
commit
ec95f85dc8
@ -87,13 +87,6 @@ export default ApplicationAdapter.extend({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
findRecord(store, type, path, snapshot) {
|
|
||||||
if (snapshot.attr('type') === 'ssh') {
|
|
||||||
return this.ajax(`/v1/${encodePath(path)}/config/ca`, 'GET');
|
|
||||||
}
|
|
||||||
return { data: {} };
|
|
||||||
},
|
|
||||||
|
|
||||||
queryRecord(store, type, query) {
|
queryRecord(store, type, query) {
|
||||||
if (query.type === 'aws') {
|
if (query.type === 'aws') {
|
||||||
return this.ajax(`/v1/${encodePath(query.backend)}/config/lease`, 'GET').then((resp) => {
|
return this.ajax(`/v1/${encodePath(query.backend)}/config/lease`, 'GET').then((resp) => {
|
||||||
|
@ -8,13 +8,39 @@ import { encodePath } from 'vault/utils/path-encoding-helpers';
|
|||||||
|
|
||||||
export default class SshCaConfig extends ApplicationAdapter {
|
export default class SshCaConfig extends ApplicationAdapter {
|
||||||
namespace = 'v1';
|
namespace = 'v1';
|
||||||
// For now this is only being used on the vault.cluster.secrets.backend.configuration route. This is a read-only route.
|
|
||||||
// Eventually, this will be used to create the ca config for the SSH secret backend, replacing the requests located on the secret-engine adapter.
|
|
||||||
queryRecord(store, type, query) {
|
queryRecord(store, type, query) {
|
||||||
const { backend } = query;
|
const { backend } = query;
|
||||||
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/ca`, 'GET').then((resp) => {
|
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/ca`, 'GET').then((resp) => {
|
||||||
resp.id = backend;
|
resp.id = backend;
|
||||||
|
resp.backend = backend;
|
||||||
return resp;
|
return resp;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createOrUpdate(store, type, snapshot) {
|
||||||
|
const serializer = store.serializerFor(type.modelName);
|
||||||
|
const data = serializer.serialize(snapshot);
|
||||||
|
const backend = snapshot.record.backend;
|
||||||
|
return this.ajax(`${this.buildURL()}/${backend}/config/ca`, 'POST', { data }).then((resp) => {
|
||||||
|
// ember data requires an id on the response
|
||||||
|
return {
|
||||||
|
...resp,
|
||||||
|
id: backend,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecord() {
|
||||||
|
return this.createOrUpdate(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecord() {
|
||||||
|
return this.createOrUpdate(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRecord(store, type, snapshot) {
|
||||||
|
const backend = snapshot.record.backend;
|
||||||
|
return this.ajax(`${this.buildURL()}/${backend}/config/ca`, 'DELETE');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,14 @@
|
|||||||
{{#if @configModels.length}}
|
{{#if @configModels.length}}
|
||||||
{{#each @configModels as |configModel|}}
|
{{#each @configModels as |configModel|}}
|
||||||
{{#each configModel.attrs as |attr|}}
|
{{#each configModel.attrs as |attr|}}
|
||||||
{{#if attr.options.sensitive}}
|
{{! public key while not sensitive when editing/creating, should be hidden by default on viewing }}
|
||||||
|
{{#if (or attr.options.sensitive (eq attr.name "publicKey"))}}
|
||||||
<InfoTableRow
|
<InfoTableRow
|
||||||
alwaysRender={{not (is-empty-value (get configModel attr.name))}}
|
alwaysRender={{not (is-empty-value (get configModel attr.name))}}
|
||||||
@label={{or attr.options.label (to-label attr.name)}}
|
@label={{or attr.options.label (to-label attr.name)}}
|
||||||
@value={{get configModel (or attr.options.fieldValue attr.name)}}
|
@value={{get configModel (or attr.options.fieldValue attr.name)}}
|
||||||
>
|
>
|
||||||
{{#if attr.options.sensitive}}
|
{{#if (or attr.options.sensitive (eq attr.name "publicKey"))}}
|
||||||
<MaskedInput
|
<MaskedInput
|
||||||
@value={{get configModel attr.name}}
|
@value={{get configModel attr.name}}
|
||||||
@name={{attr.name}}
|
@name={{attr.name}}
|
||||||
|
@ -3,7 +3,51 @@
|
|||||||
SPDX-License-Identifier: BUSL-1.1
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
~}}
|
~}}
|
||||||
|
|
||||||
{{#if @configured}}
|
<form {{on "submit" (perform this.save)}} aria-label="save ssh creds" data-test-configure-form>
|
||||||
|
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||||
|
<NamespaceReminder @mode="save" @noun="configuration" />
|
||||||
|
<MessageError @errorMessage={{this.errorMessage}} />
|
||||||
|
{{#unless @model.isNew}}
|
||||||
|
<p class="has-text-grey-dark">
|
||||||
|
NOTE: You must delete your existing certificate and key before saving new values.
|
||||||
|
</p>
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
||||||
|
{{#if @model.isNew}}
|
||||||
|
<div class="box is-fullwidth is-sideless">
|
||||||
|
{{#each @model.formFields as |attr|}}
|
||||||
|
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
<div class="box is-fullwidth is-bottomless">
|
||||||
|
<div class="control">
|
||||||
|
<Hds::Button
|
||||||
|
@text="Save"
|
||||||
|
@icon={{if this.save.isRunning "loading"}}
|
||||||
|
type="submit"
|
||||||
|
disabled={{this.save.isRunning}}
|
||||||
|
data-test-configure-save-button
|
||||||
|
/>
|
||||||
|
<Hds::Button
|
||||||
|
@text="Cancel"
|
||||||
|
@color="secondary"
|
||||||
|
class="has-left-margin-s"
|
||||||
|
disabled={{this.save.isRunning}}
|
||||||
|
{{on "click" this.onCancel}}
|
||||||
|
data-test-cancel-button
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{#if this.invalidFormAlert}}
|
||||||
|
<AlertInline
|
||||||
|
data-test-invalid-form-alert
|
||||||
|
class="has-top-padding-s"
|
||||||
|
@type="danger"
|
||||||
|
@message={{this.invalidFormAlert}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
{{! Model is not new and keys have already been created. Require user deletes the keys before creating new ones }}
|
||||||
<div class="box is-fullwidth is-sideless is-marginless" data-test-edit-config-section>
|
<div class="box is-fullwidth is-sideless is-marginless" data-test-edit-config-section>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="publicKey" class="is-label">
|
<label for="publicKey" class="is-label">
|
||||||
@ -26,65 +70,17 @@
|
|||||||
<Hds::Copy::Button
|
<Hds::Copy::Button
|
||||||
@text="Copy"
|
@text="Copy"
|
||||||
@textToCopy={{@model.publicKey}}
|
@textToCopy={{@model.publicKey}}
|
||||||
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
|
@onError={{fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")}}
|
||||||
class="primary"
|
class="primary"
|
||||||
/>
|
/>
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
@buttonText="Delete"
|
@buttonText="Delete"
|
||||||
@buttonColor="secondary"
|
@buttonColor="secondary"
|
||||||
@confirmMessage="This will remove the CA certificate information."
|
@confirmMessage="Confirming will remove the CA certificate information."
|
||||||
@onConfirmAction={{this.delete}}
|
@onConfirmAction={{this.deleteCaConfig}}
|
||||||
data-test-delete-public-key
|
data-test-delete-public-key
|
||||||
/>
|
/>
|
||||||
</Hds::ButtonSet>
|
</Hds::ButtonSet>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
|
||||||
<form {{on "submit" this.saveConfig}} data-test-configure-form>
|
|
||||||
<div class="box is-fullwidth is-sideless is-marginless">
|
|
||||||
<NamespaceReminder @mode="save" @noun="configuration" />
|
|
||||||
<div class="field">
|
|
||||||
<label for="privateKey" class="is-label">
|
|
||||||
Private key
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<MaskedInput @name="privateKey" id="privateKey" @value={{@model.privateKey}} @onChange={{mut @model.privateKey}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="publicKey" class="is-label">
|
|
||||||
Public key
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<Textarea name="publicKey" id="publicKey" class="input" @value={{@model.publicKey}} data-test-input="publicKey" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="b-checkbox">
|
|
||||||
<Input
|
|
||||||
@type="checkbox"
|
|
||||||
id="generateSigningKey"
|
|
||||||
class="styled"
|
|
||||||
@checked={{@model.generateSigningKey}}
|
|
||||||
{{on "change" (fn (mut @model.generateSigningKey) (not @model.generateSigningKey))}}
|
|
||||||
data-test-input="generate-signing-key-checkbox"
|
|
||||||
/>
|
|
||||||
<label for="generateSigningKey" class="is-label">
|
|
||||||
Generate signing key
|
|
||||||
<InfoTooltip>
|
|
||||||
Specifies if Vault should generate the signing key pair internally
|
|
||||||
</InfoTooltip>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field box is-fullwidth is-bottomless is-marginless">
|
|
||||||
<div class="control">
|
|
||||||
<Hds::Button
|
|
||||||
@text="Save"
|
|
||||||
@icon={{if @loading "loading"}}
|
|
||||||
type="submit"
|
|
||||||
disabled={{@loading}}
|
|
||||||
data-test-configure-save-button
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
</form>
|
@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Component from '@glimmer/component';
|
|
||||||
import { action } from '@ember/object';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @module ConfigureSshSComponent
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```js
|
|
||||||
* <SecretEngine::ConfigureSsh
|
|
||||||
* @model={{this.model}}
|
|
||||||
* @configured={{this.configured}}
|
|
||||||
* @saveConfig={{action "saveConfig"}}
|
|
||||||
* @loading={{this.loading}}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param {string} model - ssh secret engine model
|
|
||||||
* @param {Function} saveConfig - parent action which updates the configuration
|
|
||||||
* @param {boolean} loading - property in parent that updates depending on status of parent's action
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export default class ConfigureSshComponent extends Component {
|
|
||||||
@action
|
|
||||||
delete() {
|
|
||||||
this.args.saveConfig({ delete: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
saveConfig(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.args.saveConfig({ delete: false });
|
|
||||||
}
|
|
||||||
}
|
|
118
ui/app/components/secret-engine/configure-ssh.ts
Normal file
118
ui/app/components/secret-engine/configure-ssh.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { waitFor } from '@ember/test-waiters';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { ValidationMap } from 'vault/vault/app-types';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
import type CaConfigModel from 'vault/models/ssh/ca-config';
|
||||||
|
import type Router from '@ember/routing/router';
|
||||||
|
import type Store from '@ember-data/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module ConfigureSshComponent is used to configure the SSH secret engine.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* <SecretEngine::ConfigureSsh
|
||||||
|
* @model={{this.model.ssh-ca-config}}
|
||||||
|
* @id={{this.model.id}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param {string} model - SSH ca-config model
|
||||||
|
* @param {string} id - name of the SSH secret engine, ex: 'ssh-123'
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
model: CaConfigModel;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ConfigureSshComponent extends Component<Args> {
|
||||||
|
@service declare readonly router: Router;
|
||||||
|
@service declare readonly store: Store;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
@tracked errorMessage: string | null = null;
|
||||||
|
@tracked invalidFormAlert: string | null = null;
|
||||||
|
@tracked modelValidations: ValidationMap | null = null;
|
||||||
|
|
||||||
|
@task
|
||||||
|
@waitFor
|
||||||
|
*save(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.resetErrors();
|
||||||
|
const { id, model } = this.args;
|
||||||
|
const isValid = this.validate(model);
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
// Check if any of the model's attributes have changed.
|
||||||
|
// If no changes to the model, transition and notify user.
|
||||||
|
// Otherwise, save the model.
|
||||||
|
const attributesChanged = Object.keys(model.changedAttributes()).length > 0;
|
||||||
|
if (!attributesChanged) {
|
||||||
|
this.flashMessages.info('No changes detected.');
|
||||||
|
this.transition();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
yield model.save();
|
||||||
|
this.transition();
|
||||||
|
this.flashMessages.success(`Successfully saved ${id}'s root configuration.`);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage = errorMessage(error);
|
||||||
|
this.invalidFormAlert = 'There was an error submitting this form.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(model: CaConfigModel) {
|
||||||
|
const { isValid, state, invalidFormMessage } = model.validate();
|
||||||
|
this.modelValidations = isValid ? null : state;
|
||||||
|
this.invalidFormAlert = isValid ? '' : invalidFormMessage;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetErrors() {
|
||||||
|
this.flashMessages.clearMessages();
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.invalidFormAlert = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
transition(isDelete = false) {
|
||||||
|
// deleting a key is the only case in which we want to stay on the create/edit page.
|
||||||
|
if (isDelete) {
|
||||||
|
this.router.transitionTo('vault.cluster.secrets.backend.configuration.edit', this.args.id);
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onCancel() {
|
||||||
|
// clear errors because they're canceling out of the workflow.
|
||||||
|
this.resetErrors();
|
||||||
|
this.transition();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async deleteCaConfig() {
|
||||||
|
const { model } = this.args;
|
||||||
|
try {
|
||||||
|
await model.destroyRecord();
|
||||||
|
this.transition(true);
|
||||||
|
this.flashMessages.success('CA information deleted successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
model.rollbackAttributes();
|
||||||
|
this.flashMessages.danger(errorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -29,31 +29,6 @@ export default Controller.extend(CONFIG_ATTRS, {
|
|||||||
this.setProperties(CONFIG_ATTRS);
|
this.setProperties(CONFIG_ATTRS);
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
saveConfig(options = { delete: false }) {
|
|
||||||
const isDelete = options.delete;
|
|
||||||
if (this.model.type === 'ssh') {
|
|
||||||
this.set('loading', true);
|
|
||||||
this.model
|
|
||||||
.saveCA({ isDelete })
|
|
||||||
.then(() => {
|
|
||||||
this.send('refreshRoute');
|
|
||||||
this.set('configured', !isDelete);
|
|
||||||
if (isDelete) {
|
|
||||||
this.flashMessages.success('SSH Certificate Authority Configuration deleted!');
|
|
||||||
} else {
|
|
||||||
this.flashMessages.success('SSH Certificate Authority Configuration saved!');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage = error.errors ? error.errors.join('. ') : error;
|
|
||||||
this.flashMessages.danger(errorMessage);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.set('loading', false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
save(method, data) {
|
save(method, data) {
|
||||||
this.set('loading', true);
|
this.set('loading', true);
|
||||||
const hasData = Object.keys(data).some((key) => {
|
const hasData = Object.keys(data).some((key) => {
|
||||||
|
@ -68,14 +68,6 @@ export default class SecretEngineModel extends Model {
|
|||||||
})
|
})
|
||||||
version;
|
version;
|
||||||
|
|
||||||
// SSH specific attributes
|
|
||||||
@attr('string') privateKey;
|
|
||||||
@attr('string') publicKey;
|
|
||||||
@attr('boolean', {
|
|
||||||
defaultValue: true,
|
|
||||||
})
|
|
||||||
generateSigningKey;
|
|
||||||
|
|
||||||
// AWS specific attributes
|
// AWS specific attributes
|
||||||
@attr('string') lease;
|
@attr('string') lease;
|
||||||
@attr('string') leaseMax;
|
@attr('string') leaseMax;
|
||||||
@ -257,24 +249,6 @@ export default class SecretEngineModel extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ACTIONS */
|
/* ACTIONS */
|
||||||
saveCA(options) {
|
|
||||||
if (this.type !== 'ssh') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.isDelete) {
|
|
||||||
this.privateKey = null;
|
|
||||||
this.publicKey = null;
|
|
||||||
this.generateSigningKey = false;
|
|
||||||
}
|
|
||||||
return this.save({
|
|
||||||
adapterOptions: {
|
|
||||||
options: options,
|
|
||||||
apiPath: 'config/ca',
|
|
||||||
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveZeroAddressConfig() {
|
saveZeroAddressConfig() {
|
||||||
return this.save({
|
return this.save({
|
||||||
adapterOptions: {
|
adapterOptions: {
|
||||||
|
@ -5,16 +5,50 @@
|
|||||||
|
|
||||||
import Model, { attr } from '@ember-data/model';
|
import Model, { attr } from '@ember-data/model';
|
||||||
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
||||||
|
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||||
|
|
||||||
|
const validations = {
|
||||||
|
generateSigningKey: [
|
||||||
|
{
|
||||||
|
validator(model) {
|
||||||
|
const { publicKey, privateKey, generateSigningKey } = model;
|
||||||
|
// if generateSigningKey is false, both public and private keys are required
|
||||||
|
if (!generateSigningKey && (!publicKey || !privateKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
message: 'Provide a Public and Private key or set "Generate Signing Key" to true.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
publicKey: [
|
||||||
|
{
|
||||||
|
validator(model) {
|
||||||
|
const { publicKey, privateKey } = model;
|
||||||
|
// regardless of generateSigningKey, if one key is set they both need to be set.
|
||||||
|
return publicKey || privateKey ? publicKey && privateKey : true;
|
||||||
|
},
|
||||||
|
message: 'You must provide a Public and Private keys or leave both unset.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// there are more options available on the API, but the UI does not support them yet.
|
||||||
|
@withModelValidations(validations)
|
||||||
export default class SshCaConfig extends Model {
|
export default class SshCaConfig extends Model {
|
||||||
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
|
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
|
||||||
@attr('string', { sensitive: true }) privateKey; // obfuscated, never returned by API
|
@attr('string', { sensitive: true }) privateKey; // obfuscated, never returned by API
|
||||||
@attr('string', { sensitive: true }) publicKey;
|
@attr('string') publicKey;
|
||||||
@attr('boolean', { defaultValue: true })
|
@attr('boolean', { defaultValue: true })
|
||||||
generateSigningKey;
|
generateSigningKey;
|
||||||
// there are more options available on the API, but the UI does not support them yet.
|
|
||||||
|
// do not return private key for configuration.index view
|
||||||
get attrs() {
|
get attrs() {
|
||||||
const keys = ['publicKey', 'generateSigningKey'];
|
const keys = ['publicKey', 'generateSigningKey'];
|
||||||
return expandAttributeMeta(this, keys);
|
return expandAttributeMeta(this, keys);
|
||||||
}
|
}
|
||||||
|
// return private key for edit/create view
|
||||||
|
get formFields() {
|
||||||
|
const keys = ['privateKey', 'publicKey', 'generateSigningKey'];
|
||||||
|
return expandAttributeMeta(this, keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import AdapterError from '@ember-data/adapter/error';
|
|
||||||
import { set } from '@ember/object';
|
|
||||||
import Route from '@ember/routing/route';
|
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines';
|
|
||||||
|
|
||||||
export default Route.extend({
|
|
||||||
store: service(),
|
|
||||||
|
|
||||||
model() {
|
|
||||||
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
|
|
||||||
return this.store.query('secret-engine', { path: backend }).then((modelList) => {
|
|
||||||
const model = modelList && modelList[0];
|
|
||||||
if (!model || !CONFIGURABLE_SECRET_ENGINES.includes(model.type)) {
|
|
||||||
const error = new AdapterError();
|
|
||||||
set(error, 'httpStatus', 404);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return this.store.findRecord('secret-engine', backend).then(
|
|
||||||
() => {
|
|
||||||
return model;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
afterModel(model) {
|
|
||||||
const type = model.type;
|
|
||||||
|
|
||||||
if (type === 'aws') {
|
|
||||||
return this.store
|
|
||||||
.queryRecord('secret-engine', {
|
|
||||||
backend: model.id,
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
() => model,
|
|
||||||
() => model
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
},
|
|
||||||
|
|
||||||
setupController(controller, model) {
|
|
||||||
if (model.publicKey) {
|
|
||||||
controller.set('configured', true);
|
|
||||||
}
|
|
||||||
return this._super(...arguments);
|
|
||||||
},
|
|
||||||
|
|
||||||
resetController(controller, isExiting) {
|
|
||||||
if (isExiting) {
|
|
||||||
controller.reset();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
refreshRoute() {
|
|
||||||
this.refresh();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import AdapterError from '@ember-data/adapter/error';
|
||||||
|
import { set } from '@ember/object';
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
|
import type Store from '@ember-data/store';
|
||||||
|
import type SecretEngineModel from 'vault/models/secret-engine';
|
||||||
|
|
||||||
|
// This route file is reused for all configurable secret engines.
|
||||||
|
// It generates config models based on the engine type.
|
||||||
|
// Saving and updating of those models are done within the engine specific components.
|
||||||
|
|
||||||
|
const CONFIG_ADAPTERS_PATHS: Record<string, string[]> = {
|
||||||
|
// aws: ['aws/lease-config', 'aws/root-config'], TODO will be uncommented when AWS refactor occurs
|
||||||
|
ssh: ['ssh/ca-config'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class SecretsBackendConfigurationEdit extends Route {
|
||||||
|
@service declare readonly store: Store;
|
||||||
|
|
||||||
|
async model() {
|
||||||
|
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
|
||||||
|
const secretEngineRecord = this.modelFor('vault.cluster.secrets.backend') as { type: SecretEngineModel };
|
||||||
|
const type = secretEngineRecord.type as string;
|
||||||
|
|
||||||
|
// if the engine type is not configurable, return a 404.
|
||||||
|
if (!secretEngineRecord || !CONFIGURABLE_SECRET_ENGINES.includes(type)) {
|
||||||
|
const error = new AdapterError();
|
||||||
|
set(error, 'httpStatus', 404);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// TODO this conditional will be removed when we handle AWS
|
||||||
|
if (type !== 'aws') {
|
||||||
|
// generate the model based on the engine type.
|
||||||
|
// and pre-set with the type and backend (e.g. type: ssh, id: ssh-123)
|
||||||
|
const model: Record<string, unknown> = { type, id: backend };
|
||||||
|
for (const adapterPath of CONFIG_ADAPTERS_PATHS[type] as string[]) {
|
||||||
|
// convert the adapterPath with a name that can be passed to the components
|
||||||
|
// ex: adapterPath = ssh/ca-config, convert to: ssh-ca-config so that you can pass to component @model={{this.model.ssh-ca-config}}
|
||||||
|
const standardizedKey = adapterPath.replace(/\//g, '-');
|
||||||
|
try {
|
||||||
|
model[standardizedKey] = await this.store.queryRecord(adapterPath, {
|
||||||
|
backend,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
} catch (e: AdapterError) {
|
||||||
|
// For most models if the adapter returns a 404, we want to create a new record.
|
||||||
|
// The ssh secret engine however returns a 400 if the CA is not configured.
|
||||||
|
// For ssh's 400 error, we want to create the CA config model.
|
||||||
|
if (
|
||||||
|
e.httpStatus === 404 ||
|
||||||
|
(type === 'ssh' && e.httpStatus === 400 && errorMessage(e) === `keys haven't been configured yet`)
|
||||||
|
) {
|
||||||
|
model[standardizedKey] = await this.store.createRecord(adapterPath, {
|
||||||
|
backend,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
} else {
|
||||||
|
// TODO for now AWS configs rely on the secret-engine model and adapter. This will be refactored.
|
||||||
|
return await this.store.findRecord('secret-engine', backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO everything below line will be removed once we handle AWS. This is the old code wrapped in AWS conditionals when appropriate.
|
||||||
|
afterModel(model: Record<string, unknown>) {
|
||||||
|
const type = model.type;
|
||||||
|
|
||||||
|
if (type === 'aws') {
|
||||||
|
return this.store
|
||||||
|
.queryRecord('secret-engine', {
|
||||||
|
backend: model.id,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => model,
|
||||||
|
() => model
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetController(controller, isExiting) {
|
||||||
|
if (controller.model.type === 'aws') {
|
||||||
|
if (isExiting) {
|
||||||
|
controller.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
willTransition() {
|
||||||
|
// catch the transition and refresh model so the route shows the most recent model data.
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
@ -41,12 +41,5 @@
|
|||||||
@saveAWSLease={{action "save" "saveAWSLease"}}
|
@saveAWSLease={{action "save" "saveAWSLease"}}
|
||||||
/>
|
/>
|
||||||
{{else if (eq this.model.type "ssh")}}
|
{{else if (eq this.model.type "ssh")}}
|
||||||
<SecretEngine::ConfigureSsh
|
<SecretEngine::ConfigureSsh @model={{this.model.ssh-ca-config}} @id={{this.model.id}} />
|
||||||
@model={{this.model}}
|
|
||||||
@configured={{this.configured}}
|
|
||||||
@saveConfig={{action "saveConfig"}}
|
|
||||||
@loading={{this.loading}}
|
|
||||||
/>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{outlet}}
|
|
@ -3,11 +3,10 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { click, fillIn, currentURL, waitFor, visit } from '@ember/test-helpers';
|
import { click, fillIn, currentURL, visit, waitFor } from '@ember/test-helpers';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupApplicationTest } from 'ember-qunit';
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { spy } from 'sinon';
|
|
||||||
|
|
||||||
import authPage from 'vault/tests/pages/auth';
|
import authPage from 'vault/tests/pages/auth';
|
||||||
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
|
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
|
||||||
@ -23,10 +22,7 @@ module('Acceptance | ssh | configuration', function (hooks) {
|
|||||||
setupMirage(hooks);
|
setupMirage(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
const flash = this.owner.lookup('service:flash-messages');
|
|
||||||
this.flashDangerSpy = spy(flash, 'danger');
|
|
||||||
this.store = this.owner.lookup('service:store');
|
this.store = this.owner.lookup('service:store');
|
||||||
|
|
||||||
this.uid = uuidv4();
|
this.uid = uuidv4();
|
||||||
return authPage.login();
|
return authPage.login();
|
||||||
});
|
});
|
||||||
@ -70,18 +66,20 @@ module('Acceptance | ssh | configuration', function (hooks) {
|
|||||||
await click(SES.ssh.save);
|
await click(SES.ssh.save);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
currentURL(),
|
currentURL(),
|
||||||
`/vault/secrets/${sshPath}/configuration/edit`,
|
`/vault/secrets/${sshPath}/configuration`,
|
||||||
'stays on configuration form page.'
|
'navigates to the details page.'
|
||||||
);
|
);
|
||||||
// There is a delay in the backend for the public key to be generated, wait for it to complete by checking that the public key is displayed
|
// There is a delay in the backend for the public key to be generated, wait for it to complete by checking that the public key is displayed
|
||||||
await waitFor(GENERAL.inputByAttr('public-key'));
|
await waitFor(GENERAL.infoRowLabel('Public key'));
|
||||||
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
|
assert.dom(GENERAL.infoRowLabel('Public key')).exists('public key shown on the details screen');
|
||||||
|
|
||||||
|
await click(SES.configure);
|
||||||
assert
|
assert
|
||||||
.dom(SES.ssh.editConfigSection)
|
.dom(SES.ssh.editConfigSection)
|
||||||
.exists('renders the edit configuration section of the form and not the create part');
|
.exists('renders the edit configuration section of the form and not the create part');
|
||||||
// delete Public key
|
// delete Public key
|
||||||
await click(SES.ssh.deletePublicKey);
|
await click(SES.ssh.delete);
|
||||||
assert.dom(GENERAL.confirmMessage).hasText('This will remove the CA certificate information.');
|
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
|
||||||
await click(GENERAL.confirmButton);
|
await click(GENERAL.confirmButton);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
currentURL(),
|
currentURL(),
|
||||||
@ -90,9 +88,7 @@ module('Acceptance | ssh | configuration', function (hooks) {
|
|||||||
);
|
);
|
||||||
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
|
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
|
||||||
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
|
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
|
||||||
assert
|
assert.dom(GENERAL.inputByAttr('generateSigningKey')).isChecked('Generate signing key is checked');
|
||||||
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
|
|
||||||
.isNotChecked('Generate signing key is unchecked');
|
|
||||||
await click(SES.viewBackend);
|
await click(SES.viewBackend);
|
||||||
await click(SES.configTab);
|
await click(SES.configTab);
|
||||||
assert
|
assert
|
||||||
@ -107,15 +103,15 @@ module('Acceptance | ssh | configuration', function (hooks) {
|
|||||||
await enablePage.enable('ssh', path);
|
await enablePage.enable('ssh', path);
|
||||||
await click(SES.configTab);
|
await click(SES.configTab);
|
||||||
await click(SES.configure);
|
await click(SES.configure);
|
||||||
assert
|
assert.dom(GENERAL.inputByAttr('generateSigningKey')).isChecked('generate_signing_key defaults to true');
|
||||||
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
|
await click(GENERAL.inputByAttr('generateSigningKey'));
|
||||||
.isChecked('generate_signing_key defaults to true');
|
|
||||||
await click(GENERAL.inputByAttr('generate-signing-key-checkbox'));
|
|
||||||
await click(SES.ssh.save);
|
await click(SES.ssh.save);
|
||||||
assert.true(this.flashDangerSpy.calledWith('missing public_key'), 'Danger flash message is displayed');
|
assert
|
||||||
|
.dom(GENERAL.inlineError)
|
||||||
|
.hasText('Provide a Public and Private key or set "Generate Signing Key" to true.');
|
||||||
// visit the details page and confirm the public key is not shown
|
// visit the details page and confirm the public key is not shown
|
||||||
await visit(`/vault/secrets/${path}/configuration`);
|
await visit(`/vault/secrets/${path}/configuration`);
|
||||||
assert.dom(GENERAL.infoRowLabel('Public key')).doesNotExist('Public Key label does not exist');
|
assert.dom(GENERAL.infoRowLabel('Public key')).doesNotExist('Public key label does not exist');
|
||||||
assert.dom(GENERAL.emptyStateTitle).hasText('SSH not configured', 'SSH not configured');
|
assert.dom(GENERAL.emptyStateTitle).hasText('SSH not configured', 'SSH not configured');
|
||||||
// cleanup
|
// cleanup
|
||||||
await runCmd(`delete sys/mounts/${path}`);
|
await runCmd(`delete sys/mounts/${path}`);
|
||||||
|
@ -3,7 +3,16 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { click, fillIn, currentURL, find, settled, waitUntil, currentRouteName } from '@ember/test-helpers';
|
import {
|
||||||
|
click,
|
||||||
|
fillIn,
|
||||||
|
currentURL,
|
||||||
|
find,
|
||||||
|
settled,
|
||||||
|
waitUntil,
|
||||||
|
currentRouteName,
|
||||||
|
waitFor,
|
||||||
|
} from '@ember/test-helpers';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupApplicationTest } from 'ember-qunit';
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -102,8 +111,9 @@ module('Acceptance | ssh | roles', function (hooks) {
|
|||||||
await click(SES.configure);
|
await click(SES.configure);
|
||||||
// default has generate CA checked so we just submit the form
|
// default has generate CA checked so we just submit the form
|
||||||
await click(SES.ssh.save);
|
await click(SES.ssh.save);
|
||||||
await click(SES.viewBackend);
|
// There is a delay in the backend for the public key to be generated, wait for it to complete by checking that the public key is displayed
|
||||||
|
await waitFor(GENERAL.infoRowLabel('Public key'));
|
||||||
|
await click(GENERAL.tab(sshPath));
|
||||||
for (const role of ROLES) {
|
for (const role of ROLES) {
|
||||||
// create a role
|
// create a role
|
||||||
await click(SES.createSecret);
|
await click(SES.createSecret);
|
||||||
|
@ -48,11 +48,13 @@ const createAwsLeaseConfig = (store, backend) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createSshCaConfig = (store, backend) => {
|
const createSshCaConfig = (store, backend) => {
|
||||||
|
// consider this model a placeholder for the actual ssh/ca-config model that has been generated with data. isNew is false.
|
||||||
store.pushPayload('ssh/ca-config', {
|
store.pushPayload('ssh/ca-config', {
|
||||||
id: backend,
|
id: backend,
|
||||||
modelName: 'ssh/ca-config',
|
modelName: 'ssh/ca-config',
|
||||||
data: {
|
data: {
|
||||||
backend,
|
backend,
|
||||||
|
public_key: '123456',
|
||||||
generate_signing_key: true,
|
generate_signing_key: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -32,8 +32,9 @@ export const SECRET_ENGINE_SELECTORS = {
|
|||||||
ssh: {
|
ssh: {
|
||||||
configureForm: '[data-test-configure-form]',
|
configureForm: '[data-test-configure-form]',
|
||||||
editConfigSection: '[data-test-edit-config-section]',
|
editConfigSection: '[data-test-edit-config-section]',
|
||||||
deletePublicKey: '[data-test-delete-public-key]',
|
|
||||||
save: '[data-test-configure-save-button]',
|
save: '[data-test-configure-save-button]',
|
||||||
|
cancel: '[data-test-cancel-button]',
|
||||||
|
delete: '[data-test-delete-public-key]',
|
||||||
createRole: '[data-test-role-ssh-create]',
|
createRole: '[data-test-role-ssh-create]',
|
||||||
deleteRole: '[data-test-ssh-role-delete]',
|
deleteRole: '[data-test-ssh-role-delete]',
|
||||||
},
|
},
|
||||||
|
@ -33,7 +33,7 @@ module('Integration | Component | SecretEngine/configuration-details', function
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it shows config details if configModel(s) are passed in', async function (assert) {
|
test('it shows config details if configModel(s) are passed in', async function (assert) {
|
||||||
assert.expect(14);
|
assert.expect(21);
|
||||||
for (const type of CONFIGURABLE_SECRET_ENGINES) {
|
for (const type of CONFIGURABLE_SECRET_ENGINES) {
|
||||||
const backend = `test-${type}`;
|
const backend = `test-${type}`;
|
||||||
this.configModels = createConfig(this.store, backend, type);
|
this.configModels = createConfig(this.store, backend, type);
|
||||||
@ -45,6 +45,12 @@ module('Integration | Component | SecretEngine/configuration-details', function
|
|||||||
assert
|
assert
|
||||||
.dom(GENERAL.infoRowValue(key))
|
.dom(GENERAL.infoRowValue(key))
|
||||||
.hasText(responseKeyAndValue, `${key} value for the ${type} config details exists.`);
|
.hasText(responseKeyAndValue, `${key} value for the ${type} config details exists.`);
|
||||||
|
// make sure the ones that should be masked are masked, and others are not.
|
||||||
|
if (key === 'private_key' || key === 'public_key') {
|
||||||
|
assert.dom(GENERAL.infoRowValue(key)).hasClass('masked-input', `${key} is masked`);
|
||||||
|
} else {
|
||||||
|
assert.dom(GENERAL.infoRowValue(key)).doesNotHaveClass('masked-input', `${key} is not masked`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -5,66 +5,114 @@
|
|||||||
|
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||||
import { render, click } from '@ember/test-helpers';
|
import { render, click, fillIn } from '@ember/test-helpers';
|
||||||
import { hbs } from 'ember-cli-htmlbars';
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||||
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
|
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
|
||||||
import { createConfig } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
|
import { createConfig } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|
||||||
module('Integration | Component | SecretEngine/configure-ssh', function (hooks) {
|
module('Integration | Component | SecretEngine/configure-ssh', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
this.store = this.owner.lookup('service:store');
|
this.store = this.owner.lookup('service:store');
|
||||||
this.model = createConfig(this.store, 'ssh-test', 'ssh');
|
const router = this.owner.lookup('service:router');
|
||||||
this.saveConfig = sinon.stub();
|
this.id = 'ssh-test';
|
||||||
|
this.model = this.store.createRecord('ssh/ca-config', { backend: this.id });
|
||||||
|
this.transitionStub = sinon.stub(router, 'transitionTo');
|
||||||
|
this.refreshStub = sinon.stub(router, 'refresh');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows create fields if not configured', async function (assert) {
|
test('it shows create fields if not configured', async function (assert) {
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<SecretEngine::ConfigureSsh
|
<SecretEngine::ConfigureSsh
|
||||||
@model={{this.model}}
|
@model={{this.model}}
|
||||||
@configured={{false}}
|
@id={{this.id}}
|
||||||
@saveConfig={{this.saveConfig}}
|
|
||||||
@loading={{false}}
|
|
||||||
/>
|
/>
|
||||||
`);
|
`);
|
||||||
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
|
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
|
||||||
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
|
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
|
||||||
assert
|
assert
|
||||||
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
|
.dom(GENERAL.inputByAttr('generateSigningKey'))
|
||||||
.isChecked('Generate signing key is checked by default');
|
.isChecked('Generate signing key is checked by default');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it calls save with correct arg', async function (assert) {
|
test('it should go back to parent route on cancel', async function (assert) {
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<SecretEngine::ConfigureSsh
|
<SecretEngine::ConfigureSsh
|
||||||
@model={{this.model}}
|
@model={{this.model}}
|
||||||
@configured={{false}}
|
@id={{this.id}}
|
||||||
@saveConfig={{this.saveConfig}}
|
|
||||||
@loading={{false}}
|
|
||||||
/>
|
/>
|
||||||
`);
|
`);
|
||||||
await click(SES.ssh.save);
|
|
||||||
assert.ok(
|
await click(SES.ssh.cancel);
|
||||||
this.saveConfig.withArgs({ delete: false }).calledOnce,
|
|
||||||
'calls the saveConfig action with args delete:false'
|
assert.true(
|
||||||
|
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', 'ssh-test'),
|
||||||
|
'On cancel the router transitions to the parent configuration index route.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it shows masked key if model is not new', async function (assert) {
|
test('it should validate form fields', async function (assert) {
|
||||||
// replace model with model that has public_key
|
|
||||||
this.model = {
|
|
||||||
publicKey:
|
|
||||||
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3lCZ7W2eJZ9W9qzv7K9GJ5qJYQ2cY6C+5Kv8Jtjz8h6wqZJ9U9K1lJ9Z6zq4sX0f7Q5X2l8L4gTt2+2ZKpVv6g1KQ6JG5H4QbVrQq2r4FzZQ2B0Y8q5c7q3Y5X6q4Q6',
|
|
||||||
};
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<SecretEngine::ConfigureSsh
|
<SecretEngine::ConfigureSsh
|
||||||
@model={{this.model}}
|
@model={{this.model}}
|
||||||
@configured={{true}}
|
@id={{this.id}}
|
||||||
@saveConfig={{this.saveConfig}}
|
/>
|
||||||
@loading={{false}}
|
`);
|
||||||
|
await fillIn(GENERAL.inputByAttr('publicKey'), 'hello');
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inlineError)
|
||||||
|
.hasText(
|
||||||
|
'You must provide a Public and Private keys or leave both unset.',
|
||||||
|
'Public key validation error renders.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await click(GENERAL.inputByAttr('generateSigningKey'));
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inlineError)
|
||||||
|
.hasText(
|
||||||
|
'You must provide a Public and Private keys or leave both unset.',
|
||||||
|
'Generate signing key validation message shows.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should generate signing key', async function (assert) {
|
||||||
|
assert.expect(2);
|
||||||
|
this.server.post('/ssh-test/config/ca', (schema, req) => {
|
||||||
|
const data = JSON.parse(req.requestBody);
|
||||||
|
const expected = {
|
||||||
|
backend: this.id,
|
||||||
|
generate_signing_key: true,
|
||||||
|
};
|
||||||
|
assert.deepEqual(expected, data, 'POST request made to save ca-config with correct properties');
|
||||||
|
});
|
||||||
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.model}}
|
||||||
|
@id={{this.id}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert.dom(SES.ssh.editConfigSection).exists('renders the edit configuration section of the form');
|
||||||
|
});
|
||||||
|
|
||||||
|
module('editing', function (hooks) {
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.editId = 'ssh-edit-me';
|
||||||
|
this.editModel = createConfig(this.store, 'ssh-edit-me', 'ssh');
|
||||||
|
});
|
||||||
|
test('it populates fields when editing', async function (assert) {
|
||||||
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.editModel}}
|
||||||
|
@id={{this.editId}}
|
||||||
/>
|
/>
|
||||||
`);
|
`);
|
||||||
assert
|
assert
|
||||||
@ -74,25 +122,28 @@ module('Integration | Component | SecretEngine/configure-ssh', function (hooks)
|
|||||||
await click('[data-test-button="toggle-masked"]');
|
await click('[data-test-button="toggle-masked"]');
|
||||||
assert
|
assert
|
||||||
.dom(GENERAL.inputByAttr('public-key'))
|
.dom(GENERAL.inputByAttr('public-key'))
|
||||||
.hasText(this.model.publicKey, 'public key is unmasked and shows the actual value');
|
.hasText(this.editModel.publicKey, 'public key is unmasked and shows the actual value');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it calls delete correctly', async function (assert) {
|
test('it allows you to delete a public key', async function (assert) {
|
||||||
|
assert.expect(3);
|
||||||
|
this.server.delete('/ssh-edit-me/config/ca', () => {
|
||||||
|
assert.true(true, 'DELETE request made to ca-config with correct properties');
|
||||||
|
});
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<SecretEngine::ConfigureSsh
|
<SecretEngine::ConfigureSsh
|
||||||
@model={{this.model}}
|
@model={{this.editModel}}
|
||||||
@configured={{true}}
|
@id={{this.editId}}
|
||||||
@saveConfig={{this.saveConfig}}
|
|
||||||
@loading={{false}}
|
|
||||||
/>
|
/>
|
||||||
`);
|
`);
|
||||||
// delete Public key
|
// delete Public key
|
||||||
await click(SES.ssh.deletePublicKey);
|
await click(SES.ssh.delete);
|
||||||
assert.dom(GENERAL.confirmMessage).hasText('This will remove the CA certificate information.');
|
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
|
||||||
await click(GENERAL.confirmButton);
|
await click(GENERAL.confirmButton);
|
||||||
assert.ok(
|
assert.true(
|
||||||
this.saveConfig.withArgs({ delete: true }).calledOnce,
|
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration.edit', 'ssh-edit-me'),
|
||||||
'calls the saveConfig action with args delete:true'
|
'On delete the router transitions to the current route.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
@ -4,62 +4,146 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'ember-qunit';
|
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||||
|
import { render, click, fillIn } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||||
|
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
|
||||||
|
import { createConfig } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
|
||||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
module('Unit | Adapter | secret engine', function (hooks) {
|
module('Integration | Component | SecretEngine/configure-ssh', function (hooks) {
|
||||||
setupTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
setupMirage(hooks);
|
setupMirage(hooks);
|
||||||
|
|
||||||
const storeStub = {
|
hooks.beforeEach(function () {
|
||||||
serializerFor() {
|
this.store = this.owner.lookup('service:store');
|
||||||
return {
|
const router = this.owner.lookup('service:router');
|
||||||
serializeIntoHash() {},
|
this.id = 'ssh-test';
|
||||||
};
|
this.model = this.store.createRecord('ssh/ca-config', { backend: this.id });
|
||||||
},
|
this.transitionStub = sinon.stub(router, 'transitionTo');
|
||||||
};
|
this.refreshStub = sinon.stub(router, 'refresh');
|
||||||
const type = {
|
|
||||||
modelName: 'secret-engine',
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Empty query', function (assert) {
|
|
||||||
assert.expect(1);
|
|
||||||
this.server.get('/sys/internal/ui/mounts', () => {
|
|
||||||
assert.ok('query calls the correct url');
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
const adapter = this.owner.lookup('adapter:secret-engine');
|
|
||||||
adapter['query'](storeStub, type, {});
|
|
||||||
});
|
|
||||||
test('Query with a path', function (assert) {
|
|
||||||
assert.expect(1);
|
|
||||||
this.server.get('/sys/internal/ui/mounts/foo', () => {
|
|
||||||
assert.ok('query calls the correct url');
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
const adapter = this.owner.lookup('adapter:secret-engine');
|
|
||||||
adapter['query'](storeStub, type, { path: 'foo' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Query with nested path', function (assert) {
|
test('it shows create fields if not configured', async function (assert) {
|
||||||
assert.expect(1);
|
await render(hbs`
|
||||||
this.server.get('/sys/internal/ui/mounts/foo/bar/baz', () => {
|
<SecretEngine::ConfigureSsh
|
||||||
assert.ok('query calls the correct url');
|
@model={{this.model}}
|
||||||
return {};
|
@id={{this.id}}
|
||||||
});
|
/>
|
||||||
const adapter = this.owner.lookup('adapter:secret-engine');
|
`);
|
||||||
adapter['query'](storeStub, type, { path: 'foo/bar/baz' });
|
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
|
||||||
|
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inputByAttr('generateSigningKey'))
|
||||||
|
.isChecked('Generate signing key is checked by default');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Fails gracefully finding records for non ssh engines', function (assert) {
|
test('it should go back to parent route on cancel', async function (assert) {
|
||||||
assert.expect(1);
|
await render(hbs`
|
||||||
const snapshot = {
|
<SecretEngine::ConfigureSsh
|
||||||
attr() {
|
@model={{this.model}}
|
||||||
return { type: 'aws', path: 'aws/' };
|
@id={{this.id}}
|
||||||
},
|
/>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click(SES.ssh.cancel);
|
||||||
|
|
||||||
|
assert.true(
|
||||||
|
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', 'ssh-test'),
|
||||||
|
'On cancel the router transitions to the parent configuration index route.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should validate form fields', async function (assert) {
|
||||||
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.model}}
|
||||||
|
@id={{this.id}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
await fillIn(GENERAL.inputByAttr('publicKey'), 'hello');
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inlineError)
|
||||||
|
.hasText(
|
||||||
|
'You must provide a Public and Private keys or leave both unset.',
|
||||||
|
'Public key validation error renders.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await click(GENERAL.inputByAttr('generateSigningKey'));
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inlineError)
|
||||||
|
.hasText(
|
||||||
|
'You must provide a Public and Private keys or leave both unset.',
|
||||||
|
'Generate signing key validation message shows.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should generate signing key', async function (assert) {
|
||||||
|
assert.expect(2);
|
||||||
|
this.server.post('/ssh-test/config/ca', (schema, req) => {
|
||||||
|
const data = JSON.parse(req.requestBody);
|
||||||
|
const expected = {
|
||||||
|
backend: this.id,
|
||||||
|
generate_signing_key: true,
|
||||||
};
|
};
|
||||||
const adapter = this.owner.lookup('adapter:secret-engine');
|
assert.deepEqual(expected, data, 'POST request made to save ca-config with correct properties');
|
||||||
const response = adapter.findRecord(storeStub, 'aws', { path: 'aws' }, snapshot);
|
});
|
||||||
assert.propEqual(response, { data: {} }, 'returns empty data object');
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.model}}
|
||||||
|
@id={{this.id}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await click(SES.ssh.save);
|
||||||
|
assert.dom(SES.ssh.editConfigSection).exists('renders the edit configuration section of the form');
|
||||||
|
});
|
||||||
|
|
||||||
|
module('editing', function (hooks) {
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.editId = 'ssh-edit-me';
|
||||||
|
this.editModel = createConfig(this.store, 'ssh-edit-me', 'ssh');
|
||||||
|
});
|
||||||
|
test('it populates fields when editing', async function (assert) {
|
||||||
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.editModel}}
|
||||||
|
@id={{this.editId}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
assert
|
||||||
|
.dom(SES.ssh.editConfigSection)
|
||||||
|
.exists('renders the edit configuration section of the form and not the create part');
|
||||||
|
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
|
||||||
|
await click('[data-test-button="toggle-masked"]');
|
||||||
|
assert
|
||||||
|
.dom(GENERAL.inputByAttr('public-key'))
|
||||||
|
.hasText(this.editModel.publicKey, 'public key is unmasked and shows the actual value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it allows you to delete a public key', async function (assert) {
|
||||||
|
assert.expect(3);
|
||||||
|
this.server.delete('/ssh-edit-me/config/ca', () => {
|
||||||
|
assert.true(true, 'DELETE request made to ca-config with correct properties');
|
||||||
|
});
|
||||||
|
await render(hbs`
|
||||||
|
<SecretEngine::ConfigureSsh
|
||||||
|
@model={{this.editModel}}
|
||||||
|
@id={{this.editId}}
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
// delete Public key
|
||||||
|
await click(SES.ssh.delete);
|
||||||
|
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
|
||||||
|
await click(GENERAL.confirmButton);
|
||||||
|
assert.true(
|
||||||
|
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration.edit', 'ssh-edit-me'),
|
||||||
|
'On delete the router transitions to the current route.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -346,80 +346,6 @@ module('Unit | Model | secret-engine', function (hooks) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module('saveCA', function () {
|
|
||||||
test('does not call endpoint if type != ssh', async function (assert) {
|
|
||||||
assert.expect(1);
|
|
||||||
const model = this.store.createRecord('secret-engine', {
|
|
||||||
type: 'not-ssh',
|
|
||||||
});
|
|
||||||
const saveSpy = sinon.spy(model, 'save');
|
|
||||||
await model.saveCA({});
|
|
||||||
assert.ok(saveSpy.notCalled, 'save not called');
|
|
||||||
});
|
|
||||||
test('calls save with correct params', async function (assert) {
|
|
||||||
assert.expect(4);
|
|
||||||
const model = this.store.createRecord('secret-engine', {
|
|
||||||
type: 'ssh',
|
|
||||||
privateKey: 'private-key',
|
|
||||||
publicKey: 'public-key',
|
|
||||||
generateSigningKey: true,
|
|
||||||
});
|
|
||||||
const saveStub = sinon.stub(model, 'save').callsFake((params) => {
|
|
||||||
assert.deepEqual(
|
|
||||||
params,
|
|
||||||
{
|
|
||||||
adapterOptions: {
|
|
||||||
options: {},
|
|
||||||
apiPath: 'config/ca',
|
|
||||||
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'send correct params to save'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
|
|
||||||
await model.saveCA({});
|
|
||||||
assert.strictEqual(model.privateKey, 'private-key', 'value exists before save');
|
|
||||||
assert.strictEqual(model.publicKey, 'public-key', 'value exists before save');
|
|
||||||
assert.true(model.generateSigningKey, 'value true before save');
|
|
||||||
|
|
||||||
saveStub.restore();
|
|
||||||
});
|
|
||||||
test('sets properties when isDelete', async function (assert) {
|
|
||||||
assert.expect(7);
|
|
||||||
const model = this.store.createRecord('secret-engine', {
|
|
||||||
type: 'ssh',
|
|
||||||
privateKey: 'private-key',
|
|
||||||
publicKey: 'public-key',
|
|
||||||
generateSigningKey: true,
|
|
||||||
});
|
|
||||||
const saveStub = sinon.stub(model, 'save').callsFake((params) => {
|
|
||||||
assert.deepEqual(
|
|
||||||
params,
|
|
||||||
{
|
|
||||||
adapterOptions: {
|
|
||||||
options: { isDelete: true },
|
|
||||||
apiPath: 'config/ca',
|
|
||||||
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'send correct params to save'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
assert.strictEqual(model.privateKey, 'private-key', 'value exists before save');
|
|
||||||
assert.strictEqual(model.publicKey, 'public-key', 'value exists before save');
|
|
||||||
assert.true(model.generateSigningKey, 'value true before save');
|
|
||||||
|
|
||||||
await model.saveCA({ isDelete: true });
|
|
||||||
assert.strictEqual(model.privateKey, null, 'value null after save');
|
|
||||||
assert.strictEqual(model.publicKey, null, 'value null after save');
|
|
||||||
assert.false(model.generateSigningKey, 'value false after save');
|
|
||||||
saveStub.restore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('saveZeroAddressConfig', function () {
|
module('saveZeroAddressConfig', function () {
|
||||||
test('calls save with correct params', async function (assert) {
|
test('calls save with correct params', async function (assert) {
|
||||||
assert.expect(1);
|
assert.expect(1);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user