UI: display CSR after generation (#19114)

* add show page for generated CSR

* fix typo, make key-id copyable

* add tests

* move pki tests to designated folder

* list keys when in between state after CSR generation

* update tests
This commit is contained in:
claire bontempo
2023-02-10 10:05:57 -08:00
committed by GitHub
parent d9c8a8f629
commit 96889735f1
15 changed files with 159 additions and 39 deletions

View File

@@ -169,8 +169,11 @@ export default class PkiActionModel extends Model {
@attr('string', { readOnly: true }) issuerId; // returned from generate-root action
// For generating and signing a CSR
@attr('string') csr;
@attr('string', { label: 'CSR', masked: true }) csr;
@attr caChain;
@attr('string', { label: 'Key ID' }) keyId;
@attr('string', { masked: true }) privateKey;
@attr('string') privateKeyType;
get backend() {
return this.secretMountPath.currentPath;

View File

@@ -45,6 +45,7 @@
<InfoTableRow
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @key attr.name}}
@addCopyButton={{eq attr.name "keyId"}}
/>
{{/each}}
{{#if @key.privateKey}}

View File

@@ -44,8 +44,8 @@
{{else if (eq @config.actionType "generate-csr")}}
<PkiGenerateCsr
@model={{@config}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
@onCancel={{@onCancel}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
/>
{{else}}
<EmptyState

View File

@@ -1,28 +1,76 @@
<form {{on "submit" (perform this.save)}}>
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
<h2 class="title is-size-5 has-border-bottom-light page-header">
CSR parameters
</h2>
{{#each this.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} />
<div class="field is-grouped box is-fullwidth is-bottomless has-top-margin-l">
<div class="control">
<button type="submit" class="button is-primary" data-test-save>
Done
</button>
<button {{on "click" this.cancel}} type="button" class="button has-left-margin-s" data-test-cancel>
Cancel
</button>
{{#if @model.id}}
{{! Model only has ID once form has been submitted and saved }}
<Toolbar />
<main data-test-generate-csr-result>
<div class="box is-sideless is-fullwidth is-shadowless">
<AlertBanner @title="Next steps" @type="warning">
Copy the CSR below for a parent issuer to sign and then import the signed certificate back into this mount.
{{#if @model.privateKey}}
The
<code>private_key</code>
is only available once. Make sure you copy and save it now.
{{/if}}
</AlertBanner>
{{#each this.showFields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
{{#let (get @model attr.name) as |value|}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{value}}
@addCopyButton={{eq attr.name "keyId"}}
>
{{#if (and attr.options.masked value)}}
<MaskedInput @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
{{else if (eq attr.name "keyId")}}
<LinkTo @route="keys.key.details" @model={{@model.keyId}}>
{{@model.keyId}}
</LinkTo>
{{else}}
{{! this block only ever renders privateKey and privateKeyType }}
<span class="{{unless value 'tag'}}">{{or value "internal"}}</span>
{{/if}}
</InfoTableRow>
{{/let}}
{{/let}}
{{/each}}
</div>
{{#if this.alert}}
</main>
<footer>
<div class="field is-grouped is-fullwidth has-top-margin-l">
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.alert}} @mimicRefresh={{true}} data-test-alert />
<button type="button" class="button is-primary" {{on "click" @onComplete}} data-test-done>
Done
</button>
</div>
{{/if}}
</div>
</form>
</div>
</footer>
{{else}}
<form {{on "submit" (perform this.save)}}>
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
<h2 class="title is-size-5 has-border-bottom-light page-header">
CSR parameters
</h2>
{{#each this.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<PkiGenerateToggleGroups @model={{@model}} />
<div class="field is-grouped box is-fullwidth is-bottomless has-top-margin-l">
<div class="control">
<button type="submit" class="button is-primary" data-test-save>
Generate
</button>
<button {{on "click" this.cancel}} type="button" class="button has-left-margin-s" data-test-cancel>
Cancel
</button>
</div>
{{#if this.alert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.alert}} @mimicRefresh={{true}} data-test-alert />
</div>
{{/if}}
</div>
</form>
{{/if}}

View File

@@ -12,11 +12,11 @@ import errorMessage from 'vault/utils/error-message';
interface Args {
model: PkiActionModel;
useIssuer: boolean;
onSave: CallableFunction;
onComplete: CallableFunction;
onCancel: CallableFunction;
}
export default class PkiGenerateIntermediateComponent extends Component<Args> {
export default class PkiGenerateCsrComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@tracked modelValidations = null;
@@ -24,6 +24,8 @@ export default class PkiGenerateIntermediateComponent extends Component<Args> {
@tracked alert: string | null = null;
formFields;
// fields rendered after CSR generation
showFields = ['csr', 'keyId', 'privateKey', 'privateKeyType'];
constructor(owner: unknown, args: Args) {
super(owner, args);
@@ -57,13 +59,12 @@ export default class PkiGenerateIntermediateComponent extends Component<Args> {
*save(event: Event): Generator<Promise<boolean | PkiActionModel>> {
event.preventDefault();
try {
const { model, onSave } = this.args;
const { model } = this.args;
const { isValid, state, invalidFormMessage } = model.validate();
if (isValid) {
const useIssuer = yield this.getCapability();
yield model.save({ adapterOptions: { actionType: 'generate-csr', useIssuer } });
this.flashMessages.success('Successfully generated CSR.');
onSave();
} else {
this.modelValidations = state;
this.alert = invalidFormMessage;

View File

@@ -11,7 +11,7 @@
{{#each this.showFields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
<InfoTableRow @label={{or attr.options.label (humanize (dasherize attr.name))}} @value={{get @issuer attr.name}}>
<InfoTableRow @label={{or attr.options.label (humanize (dasherize attr.name))}} @value={{get @model attr.name}}>
{{#if (and attr.options.masked (get @model attr.name))}}
<MaskedInput @value={{get @model attr.name}} @displayOnly={{true}} @allowCopy={{true}} />
{{else if (eq attr.name "serialNumber")}}

View File

@@ -12,5 +12,5 @@
<PkiGenerateCsr
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
/>

View File

@@ -8,7 +8,7 @@
}}
@isEngine={{true}}
/>
{{#if this.model.hasConfig}}
{{#if (or this.model.hasConfig this.model.keyModels)}}
<Page::PkiKeyList
@keyModels={{this.model.keyModels}}
@mountPoint={{this.mountPoint}}

View File

@@ -136,10 +136,12 @@ module('Acceptance | pki workflow', function (hooks) {
await fillIn(SELECTORS.configuration.typeField, 'exported');
await fillIn(SELECTORS.configuration.inputByName('commonName'), 'my-common-name');
await click('[data-test-save]');
await assert.dom(SELECTORS.configuration.csrDetails).exists('renders CSR details after save');
await click('[data-test-done]');
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/issuers`,
'Transitions to issuers on save success'
'Transitions to issuers after viewing csr details'
);
});
});

View File

@@ -10,4 +10,6 @@ export const SELECTORS = {
...GENERATE_ROOT,
// pki-ca-cert-import
importForm: '[data-test-pki-ca-cert-import-form]',
// generate-intermediate
csrDetails: '[data-test-generate-csr-result]',
};

View File

@@ -5,13 +5,14 @@ import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Integration | Component | PkiGenerateCsr', function (hooks) {
module('Integration | Component | pki generate csr', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(async function () {
this.owner.lookup('service:secretMountPath').update('pki-test');
this.store = this.owner.lookup('service:store');
this.model = this.owner
.lookup('service:store')
.createRecord('pki/action', { actionType: 'generate-csr' });
@@ -32,9 +33,7 @@ module('Integration | Component | PkiGenerateCsr', function (hooks) {
assert.strictEqual(payload.common_name, 'foo', 'Request made to correct endpoint on save');
});
this.onSave = () => assert.ok(true, 'onSave action fires');
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onSave={{this.onSave}} />`, {
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
@@ -55,6 +54,9 @@ module('Integration | Component | PkiGenerateCsr', function (hooks) {
await fillIn('[data-test-input="type"]', 'exported');
await fillIn('[data-test-input="commonName"]', 'foo');
await click('[data-test-save]');
const savedRecord = this.store.peekAll('pki/action').firstObject;
assert.false(savedRecord.isNew, 'record is saved');
});
test('it should display validation errors', async function (assert) {
@@ -78,4 +80,65 @@ module('Integration | Component | PkiGenerateCsr', function (hooks) {
await click('[data-test-cancel]');
});
test('it should show generated CSR for type=exported', async function (assert) {
assert.expect(6);
this.model.id = '1235-someId';
this.model.csr = '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----';
this.model.keyId = '9179de78-1275-a1cf-ebb0-a4eb2e376636';
this.model.privateKey = '-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----';
this.model.privateKeyType = 'rsa';
this.onComplete = () => assert.ok(true, 'onComplete action fires');
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
assert
.dom('[data-test-alert-banner="alert"]')
.hasText(
'Next steps Copy the CSR below for a parent issuer to sign and then import the signed certificate back into this mount. The private_key is only available once. Make sure you copy and save it now.',
'renders Next steps alert banner'
);
assert
.dom('[data-test-value-div="CSR"] [data-test-masked-input] button')
.hasAttribute('data-clipboard-text', this.model.csr, 'it renders copyable csr');
assert
.dom('[data-test-value-div="Key ID"] button')
.hasAttribute('data-clipboard-text', this.model.keyId, 'it renders copyable key_id');
assert
.dom('[data-test-value-div="Private key"] [data-test-masked-input] button')
.hasAttribute('data-clipboard-text', this.model.privateKey, 'it renders copyable private_key');
assert
.dom('[data-test-value-div="Private key type"]')
.hasText(this.model.privateKeyType, 'renders private_key_type');
await click('[data-test-done]');
});
test('it should show generated CSR for type=internal', async function (assert) {
assert.expect(5);
this.model.id = '1235-someId';
this.model.csr = '-----BEGIN CERTIFICATE REQUEST-----...-----END CERTIFICATE REQUEST-----';
this.model.keyId = '9179de78-1275-a1cf-ebb0-a4eb2e376636';
this.onComplete = () => {};
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onComplete={{this.onComplete}} />`, {
owner: this.engine,
});
assert
.dom('[data-test-alert-banner="alert"]')
.hasText(
'Next steps Copy the CSR below for a parent issuer to sign and then import the signed certificate back into this mount.',
'renders Next steps alert banner'
);
assert
.dom('[data-test-value-div="CSR"] [data-test-masked-input] button')
.hasAttribute('data-clipboard-text', this.model.csr, 'it renders copyable csr');
assert
.dom('[data-test-value-div="Key ID"] button')
.hasAttribute('data-clipboard-text', this.model.keyId, 'it renders copyable key_id');
assert.dom('[data-test-value-div="Private key"]').hasText('internal', 'does not render private key');
assert
.dom('[data-test-value-div="Private key type"]')
.hasText('internal', 'does not render private key type');
});
});