UI: add error-handling and validation to pki cross-signing (#19022)

* return signed ca_chain if request fails, check for existing issuer name

* update docs

* add error border class to input
This commit is contained in:
claire bontempo 2023-02-07 12:09:17 -08:00 committed by GitHub
parent 8fd9c9df0d
commit aef0296472
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 96 additions and 51 deletions

View File

@ -1,49 +1,59 @@
{{yield}} {{yield}}
{{#each this.inputList as |row index|}} {{#each this.inputList as |row index|}}
<div class="field is-grouped"> {{#let (get @validationErrors index) as |rowValidations|}}
{{#each-in row as |inputKey value|}} <div class="field is-grouped">
{{#let (find-by "key" inputKey @objectKeys) as |field|}} {{#each-in row as |inputKey value|}}
<div class="control is-expanded"> {{#let (find-by "key" inputKey @objectKeys) as |field|}}
{{#if (eq index 0)}} <div class="control is-expanded">
<h2 data-test-object-list-label={{inputKey}}>{{field.label}}</h2> {{#if (eq index 0)}}
{{/if}} <h2 data-test-object-list-label={{inputKey}}>{{field.label}}</h2>
<Input {{/if}}
data-test-object-list-input="{{field.key}}-{{index}}" {{#let (get rowValidations field.key) as |inputError|}}
aria-label="{{or field.placeholder field.label}} input for row index {{index}}" <Input
class="input" data-test-object-list-input="{{field.key}}-{{index}}"
placeholder={{or field.placeholder ""}} aria-label="{{or field.placeholder field.label}} input for row index {{index}}"
@type="text" class="input {{if (and (not inputError.isValid) inputError.errors) 'has-error-border'}}"
@value={{value}} placeholder={{or field.placeholder ""}}
name={{field.key}} @type="text"
id="{{field.key}}-{{index}}" @value={{value}}
{{on "input" (fn this.handleInput index)}} name={{field.key}}
/> id="{{field.key}}-{{index}}"
</div> {{on "input" (fn this.handleInput index)}}
{{/let}} />
{{/each-in}} {{#if (and (not inputError.isValid) inputError.errors)}}
<div class="control is-align-end"> <AlertInline @type="danger" @message={{inputError.errors}} @paddingTop={{true}} />
{{#if (eq (inc index) this.inputList.length)}} {{/if}}
<button {{/let}}
data-test-object-list-add-button </div>
type="button" {{/let}}
class="button is-outlined is-primary" {{/each-in}}
disabled={{this.disableAdd}} <div class="control {{if (includes false (map-by 'isValid' rowValidations)) '' 'is-align-end'}}">
aria-label="add" {{#if (eq index 0)}}
{{on "click" this.addRow}} <br />
> {{/if}}
Add {{#if (eq (inc index) this.inputList.length)}}
</button> <button
{{else}} data-test-object-list-add-button
<button type="button"
data-test-object-list-delete-button={{index}} class="button is-outlined is-primary"
type="button" disabled={{this.disableAdd}}
class="button is-icon" aria-label="add"
aria-label="remove" {{on "click" this.addRow}}
{{on "click" (fn this.removeRow index)}} >
> Add
<Icon @name="trash" /> </button>
</button> {{else}}
{{/if}} <button
data-test-object-list-delete-button={{index}}
type="button"
class="button is-icon"
aria-label="remove"
{{on "click" (fn this.removeRow index)}}
>
<Icon @name="trash" />
</button>
{{/if}}
</div>
</div> </div>
</div> {{/let}}
{{/each}} {{/each}}

View File

@ -22,6 +22,9 @@ import { assert } from '@ember/debug';
* @param {array} objectKeys - an array of objects (sample above), the length determines the number of columns the component renders * @param {array} objectKeys - an array of objects (sample above), the length determines the number of columns the component renders
* @callback onChange - callback triggered when any input changes or when a row is deleted, called with array of objects containing each input's key and value ex: [ { attrKey: 'some input value' } ] * @callback onChange - callback triggered when any input changes or when a row is deleted, called with array of objects containing each input's key and value ex: [ { attrKey: 'some input value' } ]
* @param {string} [inputValue] - an array of objects to pre-fill the component inputs, key name must match objectKey[key] * @param {string} [inputValue] - an array of objects to pre-fill the component inputs, key name must match objectKey[key]
* @param {array} [validationErrors] - an array of validation objects, the index of each object corresponds to the row with an invalid input. each object has a key that matches a key in objectKeys
* ex: (the nested object with 'errors' and 'isValid' matches the structure returned by the model validations decorator)
* { "attrKey": { "errors": ["Name is required."], "isValid": false } }
*/ */
export default class ObjectListInput extends Component { export default class ObjectListInput extends Component {

View File

@ -81,7 +81,11 @@
</button> </button>
{{else}} {{else}}
<form {{on "submit" (perform this.submit)}} data-test-cross-sign-form> <form {{on "submit" (perform this.submit)}} data-test-cross-sign-form>
<ObjectListInput @objectKeys={{this.inputFields}} @onChange={{fn (mut this.formData)}} /> <ObjectListInput
@objectKeys={{this.inputFields}}
@onChange={{fn (mut this.formData)}}
@validationErrors={{this.validationErrors}}
/>
<div class="control box is-fullwidth has-only-top-shadow"> <div class="control box is-fullwidth has-only-top-shadow">
<button <button
type="submit" type="submit"

View File

@ -40,6 +40,8 @@ export default class PkiIssuerCrossSign extends Component {
@service store; @service store;
@tracked formData = []; @tracked formData = [];
@tracked signedIssuers = []; @tracked signedIssuers = [];
@tracked intermediateIssuers = {};
@tracked validationErrors = [];
inputFields = [ inputFields = [
{ {
@ -73,6 +75,23 @@ export default class PkiIssuerCrossSign extends Component {
*submit(e) { *submit(e) {
e.preventDefault(); e.preventDefault();
this.signedIssuers = []; this.signedIssuers = [];
this.validationErrors = [];
// Validate name input for new issuer does not already exist in mount
for (let row = 0; row < this.formData.length; row++) {
const { intermediateMount, newCrossSignedIssuer } = this.formData[row];
const issuers = yield this.store
.query('pki/issuer', { backend: intermediateMount })
.then((resp) => resp.map(({ issuerName, issuerId }) => ({ issuerName, issuerId })))
.catch(() => []);
// for cross-signing error handling we want to record the list of issuers before the process starts
this.intermediateIssuers[intermediateMount] = issuers;
this.validationErrors.addObject({
newCrossSignedIssuer: this.nameValidation(newCrossSignedIssuer, issuers),
});
}
if (this.validationErrors.any((row) => !row.newCrossSignedIssuer.isValid)) return;
// iterate through submitted data and cross-sign each certificate // iterate through submitted data and cross-sign each certificate
for (let row = 0; row < this.formData.length; row++) { for (let row = 0; row < this.formData.length; row++) {
@ -99,7 +118,6 @@ export default class PkiIssuerCrossSign extends Component {
@action @action
async crossSignIntermediate(intMount, intName, newCrossSignedIssuer) { async crossSignIntermediate(intMount, intName, newCrossSignedIssuer) {
// 1. Fetch issuer we want to sign // 1. Fetch issuer we want to sign
//
// What/Recovery: any failure is early enough that you can bail safely/normally. // What/Recovery: any failure is early enough that you can bail safely/normally.
const existingIssuer = await this.store.queryRecord('pki/issuer', { const existingIssuer = await this.store.queryRecord('pki/issuer', {
backend: intMount, backend: intMount,
@ -117,7 +135,6 @@ export default class PkiIssuerCrossSign extends Component {
} }
// 2. Create the new CSR // 2. Create the new CSR
//
// What/Recovery: any failure is early enough that you can bail safely/normally. // What/Recovery: any failure is early enough that you can bail safely/normally.
const newCsr = await this.store const newCsr = await this.store
.createRecord('pki/action', { .createRecord('pki/action', {
@ -133,7 +150,6 @@ export default class PkiIssuerCrossSign extends Component {
// 3. Sign newCSR with correct parent to create cross-signed cert, "issuing" // 3. Sign newCSR with correct parent to create cross-signed cert, "issuing"
// an intermediate certificate. // an intermediate certificate.
//
// What/Recovery: any failure is early enough that you can bail safely/normally. // What/Recovery: any failure is early enough that you can bail safely/normally.
const signedCaChain = await this.store const signedCaChain = await this.store
.createRecord('pki/action', { .createRecord('pki/action', {
@ -151,9 +167,7 @@ export default class PkiIssuerCrossSign extends Component {
.then(({ caChain }) => caChain.join('\n')); .then(({ caChain }) => caChain.join('\n'));
// 4. Import the newly cross-signed cert to become an issuer // 4. Import the newly cross-signed cert to become an issuer
//
// What/Recovery: // What/Recovery:
//
// 1. Permission issue -> give the cert (`signedCaChain`) to the user, // 1. Permission issue -> give the cert (`signedCaChain`) to the user,
// let them import & name. (Issue you have is that you already issued // let them import & name. (Issue you have is that you already issued
// it (step 3) and so "undo" would mean revoking the cert, which // it (step 3) and so "undo" would mean revoking the cert, which
@ -188,6 +202,10 @@ export default class PkiIssuerCrossSign extends Component {
// matching key is the issuer_id // matching key is the issuer_id
(key) => importedIssuer.mapping[key] === existingIssuer.keyId (key) => importedIssuer.mapping[key] === existingIssuer.keyId
); );
})
.catch((e) => {
console.debug('CA_CHAIN \n', signedCaChain); // eslint-disable-line
throw new Error(`${errorMessage(e)} See console for signed ca_chain data.`);
}); });
// 5. Fetch issuer imported above by issuer_id, name and save // 5. Fetch issuer imported above by issuer_id, name and save
@ -210,6 +228,16 @@ export default class PkiIssuerCrossSign extends Component {
@action @action
reset() { reset() {
this.signedIssuers = []; this.signedIssuers = [];
this.validationErrors = [];
this.formData = []; this.formData = [];
} }
nameValidation(nameInput, existing) {
if (existing.any((i) => i.issuerName === nameInput || i.issuerId === nameInput))
return {
errors: [`Issuer reference '${nameInput}' already exists in this mount.`],
isValid: false,
};
return { errors: [], isValid: true };
}
} }