UI: pki rotate root cert (#19739)
* add rotate root route * add page component * add modal * fix modal image styling * add radio buttons * add jsonToCert function to pki parser * add verify function * add verify to details route * nest rotate-root under issuer/ * copy values from old root ca * pull detail info rows into a separate component * add type declaration files * add parsing error warning to rotate root component file * add comments * add capabilities to controller * update icon * revert issuer details * refactor pki info table rows * add parsedparameters to pki helper * add alert banner * update attrs, fix info rows * add endpoint to action router * update alert banner * hide toolbar from generate root display * add download buttons to toolbar * add banner getter * fix typo in issuer details * fix assertion * move alert banner after generating root to parent * rename issuer index route file * refactor routing so model can be passed from route * add confirmLeave and done button to use existin settings done form * rename serial number to differentiate between two types * fix links, update ids to issuerId not response id * update ts declaration * change variable names add comments * update existing tests * fix comment typo * add download button test * update serializer to change subject_serial_number to serial_number for backend * remove pageTitle getter * remove old arg * round 1 of testing complete.. * finish endpoint tests * finish component tests * move toolbars to parent route * add acceptance test for rotate route * add const to hold radio button string values * remove action, fix link
This commit is contained in:
parent
0eac17a91f
commit
069b00b031
|
@ -30,6 +30,8 @@ export default class PkiActionAdapter extends ApplicationAdapter {
|
|||
: `${baseUrl}/intermediate/generate/${type}`;
|
||||
case 'sign-intermediate':
|
||||
return `${baseUrl}/issuer/${encodePath(issuerRef)}/sign-intermediate`;
|
||||
case 'rotate-root':
|
||||
return `${baseUrl}/root/rotate/${type}`;
|
||||
default:
|
||||
assert('actionType must be one of import, generate-root, generate-csr or sign-intermediate');
|
||||
}
|
||||
|
|
|
@ -16,10 +16,14 @@ const validations = {
|
|||
issuerName: [
|
||||
{
|
||||
validator(model) {
|
||||
if (model.actionType === 'generate-root' && model.issuerName === 'default') return false;
|
||||
if (
|
||||
(model.actionType === 'generate-root' || model.actionType === 'rotate-root') &&
|
||||
model.issuerName === 'default'
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
message: 'Issuer name must be unique across all issuers and not be the reserved value default.',
|
||||
message: `Issuer name must be unique across all issuers and not be the reserved value 'default'.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -47,6 +51,13 @@ export default class PkiActionModel extends Model {
|
|||
@attr('string', { readOnly: true, masked: true }) certificate;
|
||||
|
||||
/* actionType generate-root */
|
||||
|
||||
// readonly attrs returned right after root generation
|
||||
@attr serialNumber;
|
||||
@attr('string', { label: 'Issuing CA', readOnly: true, masked: true }) issuingCa;
|
||||
@attr keyName;
|
||||
// end of readonly
|
||||
|
||||
@attr('string', {
|
||||
possibleValues: ['exported', 'internal', 'existing', 'kms'],
|
||||
noDefault: true,
|
||||
|
@ -149,7 +160,9 @@ export default class PkiActionModel extends Model {
|
|||
subText:
|
||||
"Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.",
|
||||
})
|
||||
serialNumber;
|
||||
subjectSerialNumber;
|
||||
// this is different from the number (16:5e:a0...) randomly generated by Vault
|
||||
// https://developer.hashicorp.com/vault/api-docs/secret/pki#serial_number
|
||||
|
||||
@attr('boolean', {
|
||||
subText: 'Whether to add a Basic Constraints extension with CA: true.',
|
||||
|
|
|
@ -25,7 +25,7 @@ const displayFields = [
|
|||
'commonName',
|
||||
'issuerName',
|
||||
'issuerId',
|
||||
'serialNumber',
|
||||
'subjectSerialNumber',
|
||||
'keyId',
|
||||
'altNames',
|
||||
'uriSans',
|
||||
|
@ -53,8 +53,8 @@ export default class PkiIssuerModel extends Model {
|
|||
|
||||
// READ ONLY
|
||||
@attr isDefault;
|
||||
@attr('string', { label: 'Issuer ID' }) issuerId;
|
||||
@attr('string', { label: 'Default key ID' }) keyId;
|
||||
@attr('string', { label: 'Issuer ID', detailLinkTo: 'issuers.issuer.details' }) issuerId;
|
||||
@attr('string', { label: 'Default key ID', detailLinkTo: 'keys.key.details' }) keyId;
|
||||
@attr({ label: 'CA Chain', masked: true }) caChain;
|
||||
@attr({ masked: true }) certificate;
|
||||
|
||||
|
@ -62,7 +62,7 @@ export default class PkiIssuerModel extends Model {
|
|||
@attr commonName;
|
||||
@attr('number', { formatDate: true }) notValidAfter;
|
||||
@attr('number', { formatDate: true }) notValidBefore;
|
||||
@attr serialNumber; // this is not the UUID serial number field randomly generated by Vault for leaf certificates
|
||||
@attr subjectSerialNumber; // this is not the UUID serial number field randomly generated by Vault for leaf certificates
|
||||
@attr({ label: 'Subject Alternative Names (SANs)' }) altNames;
|
||||
@attr({ label: 'IP SANs' }) ipSans;
|
||||
@attr({ label: 'URI SANs' }) uriSans;
|
||||
|
|
|
@ -94,5 +94,5 @@ export default class PkiSignIntermediateModel extends PkiCertificateBaseModel {
|
|||
subText:
|
||||
"Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.",
|
||||
})
|
||||
serialNumber;
|
||||
subjectSerialNumber;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export default class PkiActionSerializer extends ApplicationSerializer {
|
|||
attrs = {
|
||||
customTtl: { serialize: false },
|
||||
type: { serialize: false },
|
||||
subjectSerialNumber: { serialize: false },
|
||||
};
|
||||
|
||||
serialize(snapshot, requestType) {
|
||||
|
@ -18,6 +19,9 @@ export default class PkiActionSerializer extends ApplicationSerializer {
|
|||
// requestType is a custom value specified from the pki/action adapter
|
||||
const allowedPayloadAttributes = this._allowedParamsByType(requestType, snapshot.record.type);
|
||||
if (!allowedPayloadAttributes) return data;
|
||||
// the backend expects the subject's serial number param to be 'serial_number'
|
||||
// we label it as subject_serial_number to differentiate from the vault generated UUID
|
||||
data.serial_number = data.subject_serial_number;
|
||||
|
||||
const payload = {};
|
||||
allowedPayloadAttributes.forEach((key) => {
|
||||
|
@ -63,6 +67,17 @@ export default class PkiActionSerializer extends ApplicationSerializer {
|
|||
'private_key_format',
|
||||
'ttl',
|
||||
];
|
||||
case 'rotate-root':
|
||||
return [
|
||||
...commonProps,
|
||||
'issuer_name',
|
||||
'max_path_length',
|
||||
'not_after',
|
||||
'not_before_duration',
|
||||
'permitted_dns_domains',
|
||||
'private_key_format',
|
||||
'ttl',
|
||||
];
|
||||
case 'generate-csr':
|
||||
return [...commonProps, 'add_basic_constraints'];
|
||||
case 'sign-intermediate':
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { parseCertificate } from 'vault/utils/parse-pki-cert';
|
||||
import { parsedParameters } from 'vault/utils/parse-pki-cert-oids';
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class PkiIssuerSerializer extends ApplicationSerializer {
|
||||
|
@ -12,21 +13,7 @@ export default class PkiIssuerSerializer extends ApplicationSerializer {
|
|||
constructor() {
|
||||
super(...arguments);
|
||||
// remove following attrs from serialization
|
||||
const attrs = [
|
||||
'altNames',
|
||||
'caChain',
|
||||
'certificate',
|
||||
'commonName',
|
||||
'ipSans',
|
||||
'issuerId',
|
||||
'keyId',
|
||||
'otherSans',
|
||||
'notValidAfter',
|
||||
'notValidBefore',
|
||||
'serialNumber',
|
||||
'signatureBits',
|
||||
'uriSans',
|
||||
];
|
||||
const attrs = ['caChain', 'certificate', 'issuerId', 'keyId', ...parsedParameters];
|
||||
this.attrs = attrs.reduce((attrObj, attr) => {
|
||||
attrObj[attr] = { serialize: false };
|
||||
return attrObj;
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
import { camelize } from '@ember/string';
|
||||
|
||||
export default function camelizeKeys(object) {
|
||||
const newObject = {};
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
newObject[camelize(key)] = value;
|
||||
});
|
||||
return newObject;
|
||||
}
|
|
@ -2,12 +2,13 @@
|
|||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
import camelizeKeys from 'vault/utils/camelize-object-keys';
|
||||
|
||||
//* lookup OIDs: http://oid-info.com/basic-search.htm
|
||||
|
||||
export const SUBJECT_OIDs = {
|
||||
common_name: '2.5.4.3',
|
||||
serial_number: '2.5.4.5',
|
||||
subject_serial_number: '2.5.4.5',
|
||||
ou: '2.5.4.11',
|
||||
organization: '2.5.4.10',
|
||||
country: '2.5.4.6',
|
||||
|
@ -75,3 +76,14 @@ export const SIGNATURE_ALGORITHM_OIDs = {
|
|||
'1.2.840.10045.4.3.4': '512', // ECDSA-SHA512
|
||||
'1.3.101.112': '0', // Ed25519
|
||||
};
|
||||
|
||||
// returns array of strings that correspond to model attributes
|
||||
// can be passed to display views in details pages containing certificates
|
||||
export const parsedParameters = [
|
||||
...Object.keys(camelizeKeys(SUBJECT_OIDs)),
|
||||
...Object.keys(camelizeKeys(EXTENSION_OIDs)),
|
||||
...Object.keys(camelizeKeys(SAN_TYPES)),
|
||||
'usePss',
|
||||
'notValidBefore',
|
||||
'notValidAfter',
|
||||
];
|
||||
|
|
|
@ -29,13 +29,17 @@ import {
|
|||
This means we cannot cross-sign in the UI and prompt the user to do so manually using the CLI.
|
||||
*/
|
||||
|
||||
export function jsonToCertObject(jsonString) {
|
||||
const cert_base64 = jsonString.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
|
||||
const cert_der = fromBase64(cert_base64);
|
||||
const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der));
|
||||
return new Certificate({ schema: cert_asn1.result });
|
||||
}
|
||||
|
||||
export function parseCertificate(certificateContent) {
|
||||
let cert;
|
||||
try {
|
||||
const cert_base64 = certificateContent.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
|
||||
const cert_der = fromBase64(cert_base64);
|
||||
const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der));
|
||||
cert = new Certificate({ schema: cert_asn1.result });
|
||||
cert = jsonToCertObject(certificateContent);
|
||||
} catch (error) {
|
||||
console.debug('DEBUG: Converting Certificate', error); // eslint-disable-line
|
||||
return { can_parse: false };
|
||||
|
@ -101,6 +105,38 @@ export function formatValues(subject, extension) {
|
|||
};
|
||||
}
|
||||
|
||||
/*
|
||||
How to use the verify function for cross-signing:
|
||||
(See setup script here: https://github.com/hashicorp/vault-tools/blob/main/vault-ui/pki/pki-cross-sign-config.sh)
|
||||
1. A trust chain exists between "old-parent-issuer-name" -> "old-intermediate"
|
||||
2. Cross-sign "old-intermediate" against "my-parent-issuer-name" creating a new certificate: "newly-cross-signed-int-name"
|
||||
3. Generate a leaf certificate from "newly-cross-signed-int-name", let's call it "baby-leaf"
|
||||
4. Verify that "baby-leaf" validates against both chains:
|
||||
"old-parent-issuer-name" -> "old-intermediate" -> "baby-leaf"
|
||||
"my-parent-issuer-name" -> "newly-cross-signed-int-name" -> "baby-leaf"
|
||||
|
||||
A valid cross-signing would mean BOTH of the following return true:
|
||||
verifyCertificates(oldParentCert, oldIntCert, leaf)
|
||||
verifyCertificates(newParentCert, crossSignedCert, leaf)
|
||||
|
||||
each arg is the JSON string certificate value
|
||||
*/
|
||||
export async function verifyCertificates(certA, certB, leaf) {
|
||||
const parsedCertA = jsonToCertObject(certA);
|
||||
const parsedCertB = jsonToCertObject(certB);
|
||||
if (leaf) {
|
||||
const parsedLeaf = jsonToCertObject(leaf);
|
||||
const chainA = await parsedLeaf.verify(parsedCertA);
|
||||
const chainB = await parsedLeaf.verify(parsedCertB);
|
||||
// the leaf's issuer should be equal to the subject data of the intermediate certs
|
||||
const isEqualA = parsedLeaf.issuer.isEqual(parsedCertA.subject);
|
||||
const isEqualB = parsedLeaf.issuer.isEqual(parsedCertB.subject);
|
||||
return chainA && chainB && isEqualA && isEqualB;
|
||||
}
|
||||
// can be used to validate if a certificate is self-signed, by passing it as both certA and B (i.e. a root cert)
|
||||
return (await parsedCertA.verify(parsedCertB)) && parsedCertA.issuer.isEqual(parsedCertB.subject);
|
||||
}
|
||||
|
||||
//* PARSING HELPERS
|
||||
/*
|
||||
We wish to get each SUBJECT_OIDs (see utils/parse-pki-cert-oids.js) out of this certificate's subject.
|
||||
|
|
|
@ -8,6 +8,8 @@ import { action } from '@ember/object';
|
|||
import { inject as service } from '@ember/service';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import timestamp from 'vault/utils/timestamp';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { assert } from '@ember/debug';
|
||||
/**
|
||||
* @module DownloadButton
|
||||
* DownloadButton components are an action button used to download data. Both the action text and icon are yielded.
|
||||
|
@ -29,6 +31,7 @@ import timestamp from 'vault/utils/timestamp';
|
|||
* ```
|
||||
* @param {string} [filename] - name of file that prefixes the ISO timestamp generated at download
|
||||
* @param {string} [data] - data to download
|
||||
* @param {function} [fetchData] - function that fetches data and returns download content
|
||||
* @param {string} [extension='txt'] - file extension, the download service uses this to determine the mimetype
|
||||
* @param {boolean} [stringify=false] - argument to stringify the data before passing to the File constructor
|
||||
*/
|
||||
|
@ -36,7 +39,16 @@ import timestamp from 'vault/utils/timestamp';
|
|||
export default class DownloadButton extends Component {
|
||||
@service download;
|
||||
@service flashMessages;
|
||||
@tracked fetchedData;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
const hasConflictingArgs = this.args.data && this.args.fetchData;
|
||||
assert(
|
||||
'Only pass either @data or @fetchData, passing both means @data will be overwritten by the return value of @fetchData',
|
||||
!hasConflictingArgs
|
||||
);
|
||||
}
|
||||
get filename() {
|
||||
const ts = timestamp.now().toISOString();
|
||||
return this.args.filename ? this.args.filename + '-' + ts : ts;
|
||||
|
@ -46,7 +58,7 @@ export default class DownloadButton extends Component {
|
|||
if (this.args.stringify) {
|
||||
return JSON.stringify(this.args.data, null, 2);
|
||||
}
|
||||
return this.args.data;
|
||||
return this.fetchedData || this.args.data;
|
||||
}
|
||||
|
||||
get extension() {
|
||||
|
@ -54,7 +66,10 @@ export default class DownloadButton extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
handleDownload() {
|
||||
async handleDownload() {
|
||||
if (this.args.fetchData) {
|
||||
this.fetchedData = await this.args.fetchData();
|
||||
}
|
||||
try {
|
||||
this.download.miscExtension(this.filename, this.content, this.extension);
|
||||
this.flashMessages.info(`Downloading ${this.filename}`);
|
||||
|
|
|
@ -37,6 +37,11 @@ export const MESSAGE_TYPES = {
|
|||
glyph: 'loading',
|
||||
text: 'Loading',
|
||||
},
|
||||
rotation: {
|
||||
class: 'is-info',
|
||||
glyphClass: 'has-text-grey',
|
||||
glyph: 'rotate-cw',
|
||||
},
|
||||
};
|
||||
|
||||
export function messageTypes([type]) {
|
||||
|
|
|
@ -9,7 +9,9 @@
|
|||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#unless @config.id}}
|
||||
{{#if @config.id}}
|
||||
<Toolbar />
|
||||
{{else}}
|
||||
<div class="box is-bottomless is-fullwidth is-marginless">
|
||||
<div class="columns">
|
||||
{{#each this.configTypes as |option|}}
|
||||
|
@ -40,7 +42,7 @@
|
|||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{#if (eq @config.actionType "import")}}
|
||||
<PkiImportPemBundle
|
||||
@model={{@config}}
|
||||
|
@ -50,6 +52,15 @@
|
|||
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}}
|
||||
/>
|
||||
{{else if (eq @config.actionType "generate-root")}}
|
||||
{{#if @config.privateKey}}
|
||||
<div class="has-top-margin-m">
|
||||
<AlertBanner
|
||||
@title="Next steps"
|
||||
@type="warning"
|
||||
@message="The private_key is only available once. Make sure you copy and save it now."
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<PkiGenerateRoot
|
||||
@model={{@config}}
|
||||
@urls={{@urls}}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{!-- {{#if @canRotate}}
|
||||
<ToolbarLink @route="issuers.generate-root" @type="rotate-cw" @issuer={{@issuer.id}} data-test-pki-issuer-rotate-root>
|
||||
Rotate this root
|
||||
</ToolbarLink>
|
||||
{{/if}} --}}
|
||||
{{#if @canCrossSign}}
|
||||
<ToolbarLink
|
||||
@route="issuers.issuer.cross-sign"
|
||||
@type="pen-tool"
|
||||
@issuer={{@issuer.id}}
|
||||
data-test-pki-issuer-cross-sign
|
||||
{{#if (and @isRotatable @canRotate)}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on "click" (fn (mut this.showRotationModal) true)}}
|
||||
data-test-pki-issuer-rotate-root
|
||||
>
|
||||
Cross-sign Issuer
|
||||
Rotate this root
|
||||
<Icon @name="rotate-cw" />
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if @canCrossSign}}
|
||||
<ToolbarLink @route="issuers.issuer.cross-sign" @type="pen-tool" @model={{@issuer.id}} data-test-pki-issuer-cross-sign>
|
||||
Cross-sign issuers
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
{{#if @canSignIntermediate}}
|
||||
|
@ -67,7 +68,7 @@
|
|||
</BasicDropdown>
|
||||
|
||||
{{#if @canConfigure}}
|
||||
<ToolbarLink @route="issuers.issuer.edit" @issuer={{@issuer.id}} data-test-pki-issuer-configure>
|
||||
<ToolbarLink @route="issuers.issuer.edit" @model={{@issuer.id}} data-test-pki-issuer-configure>
|
||||
Configure
|
||||
</ToolbarLink>
|
||||
{{/if}}
|
||||
|
@ -83,7 +84,6 @@
|
|||
You may also want to configure its usage and other behaviors.
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<main data-test-issuer-details>
|
||||
{{#each @issuer.formFieldGroups as |fieldGroup|}}
|
||||
{{#each-in fieldGroup as |group fields|}}
|
||||
|
@ -124,5 +124,48 @@
|
|||
</div>
|
||||
{{/each-in}}
|
||||
{{/each}}
|
||||
|
||||
</main>
|
||||
|
||||
{{! ROOT ROTATION MODAL }}
|
||||
<Modal
|
||||
@type="rotation"
|
||||
@title="Rotate this root"
|
||||
@onClose={{fn (mut this.showRotationModal) false}}
|
||||
@isActive={{this.showRotationModal}}
|
||||
@showCloseButton={{true}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<h3 class="title is-5">Root rotation</h3>
|
||||
<p class="has-text-grey has-bottom-padding-s">
|
||||
Root rotation is an impactful process. Please be ready to ensure that the new root is properly distributed to
|
||||
end-users’ trust stores. You can also do this manually by
|
||||
<DocLink @path="/vault/docs/secrets/pki/rotation-primitives#suggested-root-rotation-procedure">
|
||||
following our documentation.
|
||||
</DocLink>
|
||||
</p>
|
||||
<h3 class="title is-5 has-top-bottom-margin">How root rotation will work</h3>
|
||||
<p class="has-text-grey">
|
||||
<ol class="has-left-margin-m has-bottom-margin-s">
|
||||
<li>The new root will be generated using defaults from the old one that you can customize.</li>
|
||||
<li>You will identify intermediates, which Vault will then cross-sign.</li>
|
||||
</ol>
|
||||
Then, you can begin re-issuing leaf certs and phase out the old root.
|
||||
</p>
|
||||
<div class="has-top-margin-l has-tall-padding">
|
||||
<img src={{img-path "~/pki-rotate-root.png"}} alt="pki root rotation diagram" />
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
{{on "click" (transition-to "vault.cluster.secrets.backend.pki.issuers.issuer.rotate-root")}}
|
||||
data-test-root-rotate-step-one
|
||||
>
|
||||
Generate new root
|
||||
</button>
|
||||
<button type="button" class="button is-secondary" {{on "click" (fn (mut this.showRotationModal) false)}}>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class PkiIssuerDetailsComponent extends Component {
|
||||
@tracked showRotationModal = false;
|
||||
}
|
|
@ -9,6 +9,9 @@
|
|||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if @model.id}}
|
||||
<Toolbar />
|
||||
{{/if}}
|
||||
<PkiGenerateCsr
|
||||
@model={{@model}}
|
||||
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
|
||||
|
|
|
@ -9,6 +9,18 @@
|
|||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if @model.id}}
|
||||
<Toolbar />
|
||||
{{/if}}
|
||||
{{#if @model.privateKey}}
|
||||
<div class="has-top-margin-m">
|
||||
<AlertBanner
|
||||
@title="Next steps"
|
||||
@type="warning"
|
||||
@message="The private_key is only available once. Make sure you copy and save it now."
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<PkiGenerateRoot
|
||||
@model={{@model}}
|
||||
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if @model.id}}
|
||||
<Toolbar />
|
||||
{{/if}}
|
||||
<PkiImportPemBundle
|
||||
@model={{@model}}
|
||||
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-pki-page-title>
|
||||
{{if @newRootModel.id "View issuer certificate" "Generate new root"}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if @newRootModel.id}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
<ToolbarLink
|
||||
@route="issuers.issuer.cross-sign"
|
||||
@type="pen-tool"
|
||||
@model={{@newRootModel.issuerId}}
|
||||
data-test-pki-issuer-cross-sign
|
||||
>
|
||||
Cross-sign issuers
|
||||
</ToolbarLink>
|
||||
<ToolbarLink
|
||||
@route="issuers.issuer.sign"
|
||||
@type="pen-tool"
|
||||
@model={{@newRootModel.issuerId}}
|
||||
data-test-pki-issuer-sign-int
|
||||
>
|
||||
Sign Intermediate
|
||||
</ToolbarLink>
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
|
||||
<D.Trigger
|
||||
data-test-popup-menu-trigger
|
||||
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
|
||||
@htmlTag="button"
|
||||
data-test-issuer-download
|
||||
>
|
||||
Download
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="popup-menu-content">
|
||||
<nav class="box menu" aria-label="snapshots actions">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
<DownloadButton
|
||||
class="link"
|
||||
@filename={{@newRootModel.issuerId}}
|
||||
@extension="pem"
|
||||
@fetchData={{fn this.fetchDataForDownload "pem"}}
|
||||
data-test-issuer-download-type="pem"
|
||||
>
|
||||
PEM format
|
||||
</DownloadButton>
|
||||
</li>
|
||||
<li class="action">
|
||||
<DownloadButton
|
||||
class="link"
|
||||
@filename={{@newRootModel.issuerId}}
|
||||
@extension="der"
|
||||
@fetchData={{fn this.fetchDataForDownload "der"}}
|
||||
data-test-issuer-download-type="der"
|
||||
>
|
||||
DER format
|
||||
</DownloadButton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
<ToolbarLink @route="issuers.issuer.edit" @model={{@newRootModel.issuerId}} data-test-pki-issuer-configure>
|
||||
Configure
|
||||
</ToolbarLink>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if @newRootModel.id}}
|
||||
<div class="has-top-margin-m">
|
||||
<AlertBanner @title="Next steps" @type="warning">
|
||||
Your new root has been generated.
|
||||
{{#if @newRootModel.privateKey}}
|
||||
Make sure to copy and save the
|
||||
<strong>private_key</strong>
|
||||
as it is only available once.
|
||||
{{/if}}
|
||||
If you’re ready, you can begin cross-signing issuers now. If not, the option to cross-sign is available when you use
|
||||
this certificate.
|
||||
<br />
|
||||
<LinkTo class="is-marginless" @route="issuers.issuer.cross-sign" @model={{@newRootModel.issuerId}}>
|
||||
Cross-sign issuers
|
||||
</LinkTo>
|
||||
</AlertBanner>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="box is-bottomless is-marginless is-flex-start">
|
||||
{{#each this.generateOptions as |option|}}
|
||||
<RadioCard
|
||||
class="has-fixed-width"
|
||||
@title={{option.label}}
|
||||
@description={{option.description}}
|
||||
@icon={{option.icon}}
|
||||
@value={{option.key}}
|
||||
@groupValue={{this.displayedForm}}
|
||||
@onChange={{fn (mut this.displayedForm) option.key}}
|
||||
data-test-radio={{option.key}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.displayedForm "use-old-settings")}}
|
||||
{{#if @newRootModel.id}}
|
||||
<PkiInfoTableRows @model={{@newRootModel}} @displayFields={{this.displayFields}} />
|
||||
<div class="field is-grouped is-fullwidth has-top-margin-l has-bottom-margin-s">
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary" {{on "click" @onComplete}} data-test-done>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{! USE OLD SETTINGS FORM INPUTS }}
|
||||
<h2 class="title is-size-5 has-border-bottom-light page-header">
|
||||
Root parameters
|
||||
</h2>
|
||||
<form {{on "submit" (perform this.save)}} data-test-pki-rotate-old-settings-form>
|
||||
{{#if this.alertBanner}}
|
||||
<AlertBanner @title={{this.bannerType.title}} @type={{this.bannerType.type}} @message={{this.alertBanner}} />
|
||||
{{/if}}
|
||||
{{#let (find-by "name" "commonName" @newRootModel.allFields) as |attr|}}
|
||||
<FormField @attr={{attr}} @model={{@newRootModel}} @modelValidations={{this.modelValidations}} />
|
||||
{{/let}}
|
||||
{{#let (find-by "name" "issuerName" @newRootModel.allFields) as |attr|}}
|
||||
<FormField @attr={{attr}} @model={{@newRootModel}} @modelValidations={{this.modelValidations}} />
|
||||
{{/let}}
|
||||
<div class="box has-slim-padding is-shadowless">
|
||||
<ToggleButton
|
||||
data-test-details-toggle
|
||||
@closedLabel="Old root settings"
|
||||
@openLabel="Hide old root settings"
|
||||
@isOpen={{this.showOldSettings}}
|
||||
@onClick={{fn (mut this.showOldSettings)}}
|
||||
/>
|
||||
{{#if this.showOldSettings}}
|
||||
<PkiInfoTableRows @model={{@oldRoot}} @displayFields={{this.displayFields}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" data-test-pki-rotate-root-save>
|
||||
Done
|
||||
</button>
|
||||
<button {{on "click" @onCancel}} type="button" class="button has-left-margin-s" data-test-pki-rotate-root-cancel>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{{#if this.invalidFormAlert}}
|
||||
<div class="control">
|
||||
<AlertInline
|
||||
@type="danger"
|
||||
@paddingTop={{true}}
|
||||
@message={{this.invalidFormAlert}}
|
||||
@mimicRefresh={{true}}
|
||||
data-test-pki-rotate-root-validation-error
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<PkiGenerateRoot
|
||||
@model={{@newRootModel}}
|
||||
@onCancel={{@onCancel}}
|
||||
@onComplete={{@onComplete}}
|
||||
@adapterOptions={{hash actionType="rotate-root"}}
|
||||
/>
|
||||
{{/if}}
|
|
@ -0,0 +1,131 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { task } from 'ember-concurrency';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
// TYPES
|
||||
import Store from '@ember-data/store';
|
||||
import Router from '@ember/routing/router';
|
||||
import FlashMessageService from 'vault/services/flash-messages';
|
||||
import SecretMountPath from 'vault/services/secret-mount-path';
|
||||
import PkiIssuerModel from 'vault/models/pki/issuer';
|
||||
import PkiActionModel from 'vault/vault/models/pki/action';
|
||||
import { Breadcrumb } from 'vault/vault/app-types';
|
||||
import { parsedParameters } from 'vault/utils/parse-pki-cert-oids';
|
||||
|
||||
interface Args {
|
||||
oldRoot: PkiIssuerModel;
|
||||
newRootModel: PkiActionModel;
|
||||
breadcrumbs: Breadcrumb;
|
||||
parsingErrors: string;
|
||||
}
|
||||
|
||||
const RADIO_BUTTON_KEY = {
|
||||
oldSettings: 'use-old-settings',
|
||||
customizeNew: 'customize',
|
||||
};
|
||||
|
||||
export default class PagePkiIssuerRotateRootComponent extends Component<Args> {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly router: Router;
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly secretMountPath: SecretMountPath;
|
||||
|
||||
@tracked displayedForm = RADIO_BUTTON_KEY.oldSettings;
|
||||
@tracked showOldSettings = false;
|
||||
// form alerts below are only for "use old settings" option
|
||||
// validations/errors for "customize new root" are handled by <PkiGenerateRoot> component
|
||||
@tracked alertBanner = '';
|
||||
@tracked invalidFormAlert = '';
|
||||
@tracked modelValidations = null;
|
||||
|
||||
get bannerType() {
|
||||
if (this.args.parsingErrors && !this.invalidFormAlert) {
|
||||
return {
|
||||
title: 'Not all of the certificate values could be parsed and transfered to new root',
|
||||
type: 'warning',
|
||||
};
|
||||
}
|
||||
return { type: 'danger' };
|
||||
}
|
||||
|
||||
get generateOptions() {
|
||||
return [
|
||||
{
|
||||
key: RADIO_BUTTON_KEY.oldSettings,
|
||||
icon: 'certificate',
|
||||
label: 'Use old root settings',
|
||||
description: `Provide only a new common name and issuer name, using the old root’s settings. Selecting this option generates a root with Vault-internal key material.`,
|
||||
},
|
||||
{
|
||||
key: RADIO_BUTTON_KEY.customizeNew,
|
||||
icon: 'award',
|
||||
label: 'Customize new root certificate',
|
||||
description:
|
||||
'Generates a new self-signed CA certificate and private key. This generated root will sign its own CRL.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// for displaying old root details, and generated root details
|
||||
get displayFields() {
|
||||
const addKeyFields = ['privateKey', 'privateKeyType'];
|
||||
const defaultFields = [
|
||||
'certificate',
|
||||
'caChain',
|
||||
'issuerId',
|
||||
'issuerName',
|
||||
'issuingCa',
|
||||
'keyName',
|
||||
'keyId',
|
||||
'serialNumber',
|
||||
...parsedParameters,
|
||||
];
|
||||
return this.args.newRootModel.id ? [...defaultFields, ...addKeyFields] : defaultFields;
|
||||
}
|
||||
|
||||
checkFormValidity() {
|
||||
if (this.args.newRootModel.validate) {
|
||||
const { isValid, state, invalidFormMessage } = this.args.newRootModel.validate();
|
||||
this.modelValidations = state;
|
||||
this.invalidFormAlert = invalidFormMessage;
|
||||
return isValid;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*save(event: Event) {
|
||||
event.preventDefault();
|
||||
const continueSave = this.checkFormValidity();
|
||||
if (!continueSave) return;
|
||||
try {
|
||||
yield this.args.newRootModel.save({ adapterOptions: { actionType: 'rotate-root' } });
|
||||
this.flashMessages.success('Successfully generated root.');
|
||||
} catch (e) {
|
||||
this.alertBanner = errorMessage(e);
|
||||
this.invalidFormAlert = 'There was a problem generating root.';
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchDataForDownload(format: string) {
|
||||
const endpoint = `/v1/${this.secretMountPath.currentPath}/issuer/${this.args.newRootModel.issuerId}/${format}`;
|
||||
const adapter = this.store.adapterFor('application');
|
||||
try {
|
||||
return adapter
|
||||
.rawRequest(endpoint, 'GET', { unauthenticated: true })
|
||||
.then(function (response: Response) {
|
||||
if (format === 'der') {
|
||||
return response.blob();
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
{{#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">
|
||||
|
|
|
@ -59,7 +59,7 @@ export default class PkiGenerateCsrComponent extends Component<Args> {
|
|||
'commonName',
|
||||
'excludeCnFromSans',
|
||||
'format',
|
||||
'serialNumber',
|
||||
'subjectSerialNumber',
|
||||
'addBasicConstraints',
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
{{! Show results if model has an ID, which is only generated after save }}
|
||||
{{#if @model.id}}
|
||||
<Toolbar />
|
||||
{{#if @model.privateKey}}
|
||||
<div class="has-top-margin-m">
|
||||
<AlertBanner
|
||||
@title="Next steps"
|
||||
@type="warning"
|
||||
@message="The private_key is only available once. Make sure you copy and save it now."
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<main class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each this.returnedFields as |field|}}
|
||||
{{#let (find-by "name" field @model.allFields) as |attr|}}
|
||||
{{#if attr.options.detailLinkTo}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get @model attr.name}}
|
||||
@addCopyButton={{or (eq attr.name "issuerId") (eq attr.name "keyId")}}
|
||||
>
|
||||
<LinkTo @route={{attr.options.detailLinkTo}} @model={{get @model attr.name}}>{{get @model attr.name}}</LinkTo>
|
||||
</InfoTableRow>
|
||||
|
|
|
@ -14,6 +14,7 @@ import PkiActionModel from 'vault/models/pki/action';
|
|||
import PkiUrlsModel from 'vault/models/pki/urls';
|
||||
import FlashMessageService from 'ember-cli-flash/services/flash-messages';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import { parsedParameters } from 'vault/utils/parse-pki-cert-oids';
|
||||
|
||||
interface AdapterOptions {
|
||||
actionType: string;
|
||||
|
@ -26,6 +27,7 @@ interface Args {
|
|||
onComplete: CallableFunction;
|
||||
onSave?: CallableFunction;
|
||||
adapterOptions: AdapterOptions;
|
||||
hideAlertBanner: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -71,13 +73,13 @@ export default class PkiGenerateRootComponent extends Component<Args> {
|
|||
get returnedFields() {
|
||||
return [
|
||||
'certificate',
|
||||
'expiration',
|
||||
'issuerId',
|
||||
'issuerName',
|
||||
'issuingCa',
|
||||
'keyId',
|
||||
'keyName',
|
||||
'keyId',
|
||||
'serialNumber',
|
||||
...parsedParameters,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ export default class PkiGenerateToggleGroupsComponent extends Component<Args> {
|
|||
};
|
||||
// excludeCnFromSans and serialNumber are present in default fields for generate-csr -- only include for other types
|
||||
if (this.args.model.actionType !== 'generate-csr') {
|
||||
groups['Subject Alternative Name (SAN) Options'].unshift('excludeCnFromSans', 'serialNumber');
|
||||
groups['Subject Alternative Name (SAN) Options'].unshift('excludeCnFromSans', 'subjectSerialNumber');
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
{{#if this.importedResponse}}
|
||||
<Toolbar />
|
||||
<div class="is-flex-start has-top-margin-xs">
|
||||
<div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs">
|
||||
<h2>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
{{#each @displayFields as |field|}}
|
||||
{{#let (find-by "name" field @model.allFields) as |attr|}}
|
||||
{{#let (get @model attr.name) as |value|}}
|
||||
{{! only render if there's a value, unless it's the commonName or privateKey/Type }}
|
||||
{{#if (or value (or (eq attr.name "commonName") (eq attr.name "privateKey") (eq attr.name "privateKeyType")))}}
|
||||
<InfoTableRow
|
||||
@label={{or attr.options.label (humanize (dasherize attr.name))}}
|
||||
@value={{value}}
|
||||
@addCopyButton={{or (eq attr.name "issuerId") (eq attr.name "keyId")}}
|
||||
>
|
||||
{{#if (and attr.options.masked value)}}
|
||||
<MaskedInput @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
|
||||
{{else if attr.options.detailLinkTo}}
|
||||
<LinkTo @route={{attr.options.detailLinkTo}} @model={{value}}>{{value}}</LinkTo>
|
||||
{{else if (or (eq attr.name "privateKey") (eq attr.name "privateKeyType"))}}
|
||||
<span class="{{unless value 'tag'}}">{{or value "internal"}}</span>
|
||||
{{else if attr.options.formatDate}}
|
||||
{{date-format value "MMM d yyyy HH:mm:ss a zzzz"}}
|
||||
{{else}}
|
||||
{{value}}
|
||||
{{/if}}
|
||||
</InfoTableRow>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/let}}
|
||||
{{/each}}
|
|
@ -57,7 +57,7 @@ export default class PkiSignIntermediateFormComponent extends Component<Args> {
|
|||
'province',
|
||||
'streetAddress',
|
||||
'postalCode',
|
||||
'serialNumber', // this is different from the UUID serial number generated by vault (in show fields below)
|
||||
'subjectSerialNumber', // this is different from the UUID serial number generated by vault (in show fields below)
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export default buildRoutes(function () {
|
|||
this.route('edit');
|
||||
this.route('sign');
|
||||
this.route('cross-sign');
|
||||
this.route('rotate-root');
|
||||
});
|
||||
});
|
||||
this.route('certificates', function () {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import PkiIssuersListRoute from '../index';
|
||||
import PkiIssuersListRoute from '.';
|
||||
|
||||
// Single issuer index route extends issuers list route
|
||||
export default class PkiIssuerIndexRoute extends PkiIssuersListRoute {
|
|
@ -3,11 +3,11 @@
|
|||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import PkiIssuerIndexRoute from './index';
|
||||
import PkiIssuerRoute from '../issuer';
|
||||
import { withConfirmLeave } from 'core/decorators/confirm-leave';
|
||||
|
||||
@withConfirmLeave()
|
||||
export default class PkiIssuerCrossSignRoute extends PkiIssuerIndexRoute {
|
||||
export default class PkiIssuerCrossSignRoute extends PkiIssuerRoute {
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
controller.breadcrumbs.push(
|
||||
|
|
|
@ -3,17 +3,23 @@
|
|||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import PkiIssuerIndexRoute from './index';
|
||||
import PkiIssuerRoute from '../issuer';
|
||||
import { verifyCertificates } from 'vault/utils/parse-pki-cert';
|
||||
import { hash } from 'rsvp';
|
||||
export default class PkiIssuerDetailsRoute extends PkiIssuerRoute {
|
||||
model() {
|
||||
const issuer = this.modelFor('issuers.issuer');
|
||||
return hash({
|
||||
issuer,
|
||||
pem: this.fetchCertByFormat(issuer.id, 'pem'),
|
||||
der: this.fetchCertByFormat(issuer.id, 'der'),
|
||||
isRotatable: this.isRoot(issuer),
|
||||
});
|
||||
}
|
||||
|
||||
export default class PkiIssuerDetailsRoute extends PkiIssuerIndexRoute {
|
||||
// Details route gets issuer data from PkiIssuerIndexRoute
|
||||
async setupController(controller, resolvedModel) {
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
controller.breadcrumbs.push({ label: resolvedModel.id });
|
||||
const pem = await this.fetchCertByFormat(resolvedModel.id, 'pem');
|
||||
const der = await this.fetchCertByFormat(resolvedModel.id, 'der');
|
||||
controller.pem = pem;
|
||||
controller.der = der;
|
||||
controller.breadcrumbs.push({ label: resolvedModel.issuer.id });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -33,4 +39,9 @@ export default class PkiIssuerDetailsRoute extends PkiIssuerIndexRoute {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async isRoot({ certificate, keyId }) {
|
||||
const isSelfSigned = await verifyCertificates(certificate, certificate);
|
||||
return isSelfSigned && !!keyId;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { inject as service } from '@ember/service';
|
|||
import { withConfirmLeave } from 'core/decorators/confirm-leave';
|
||||
|
||||
@withConfirmLeave()
|
||||
export default class PkiIssuerDetailRoute extends Route {
|
||||
export default class PkiIssuerEditRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
@service pathHelp;
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import PkiIssuerRoute from '../issuer';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { parseCertificate } from 'vault/utils/parse-pki-cert';
|
||||
import camelizeKeys from 'vault/utils/camelize-object-keys';
|
||||
import { withConfirmLeave } from 'core/decorators/confirm-leave';
|
||||
|
||||
@withConfirmLeave('model.newRootModel')
|
||||
export default class PkiIssuerRotateRootRoute extends PkiIssuerRoute {
|
||||
@service secretMountPath;
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
const oldRoot = this.modelFor('issuers.issuer');
|
||||
const certData = parseCertificate(oldRoot.certificate);
|
||||
let parsingErrors;
|
||||
if (certData.parsing_errors && certData.parsing_errors.length > 0) {
|
||||
const errorMessage = certData.parsing_errors.map((e) => e.message).join(', ');
|
||||
parsingErrors = errorMessage;
|
||||
}
|
||||
const newRootModel = this.store.createRecord('pki/action', {
|
||||
actionType: 'rotate-root',
|
||||
type: 'internal',
|
||||
...camelizeKeys(certData), // copy old root settings over to new one
|
||||
});
|
||||
return hash({
|
||||
oldRoot,
|
||||
newRootModel,
|
||||
parsingErrors,
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.secretMountPath.currentPath, route: 'overview' },
|
||||
{ label: 'issuers', route: 'issuers.index' },
|
||||
{ label: resolvedModel.oldRoot.id, route: 'issuers.issuer.details' },
|
||||
{ label: 'rotate root' },
|
||||
];
|
||||
}
|
||||
}
|
|
@ -74,7 +74,7 @@
|
|||
<PopupMenu>
|
||||
<nav class="menu" aria-label="issuer config options">
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<li data-test-popup-menu-details>
|
||||
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
|
||||
Details
|
||||
</LinkTo>
|
||||
|
|
|
@ -11,11 +11,12 @@
|
|||
</PageHeader>
|
||||
|
||||
<Page::PkiIssuerDetails
|
||||
@issuer={{this.model}}
|
||||
@pem={{this.pem}}
|
||||
@der={{this.der}}
|
||||
@canRotate={{this.model.canRotateIssuer}}
|
||||
@canCrossSign={{this.model.canCrossSign}}
|
||||
@canSignIntermediate={{this.model.canSignIntermediate}}
|
||||
@canConfigure={{this.model.canConfigure}}
|
||||
@issuer={{this.model.issuer}}
|
||||
@pem={{this.model.pem}}
|
||||
@der={{this.model.der}}
|
||||
@isRotatable={{this.model.isRotatable}}
|
||||
@canRotate={{this.model.issuer.canRotateIssuer}}
|
||||
@canCrossSign={{this.model.issuer.canCrossSign}}
|
||||
@canSignIntermediate={{this.model.issuer.canSignIntermediate}}
|
||||
@canConfigure={{this.model.issuer.canConfigure}}
|
||||
/>
|
|
@ -0,0 +1,8 @@
|
|||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.model.oldRoot}}
|
||||
@newRootModel={{this.model.newRootModel}}
|
||||
@parsingErrors={{this.model.parsingErrors}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.issuer.details"}}
|
||||
@onComplete={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
|
||||
/>
|
Binary file not shown.
|
@ -363,5 +363,40 @@ module('Acceptance | pki workflow', function (hooks) {
|
|||
.exists({ count: 3 }, 'Renders 3 info table items under URLs group');
|
||||
assert.dom(SELECTORS.issuerDetails.groupTitle).exists({ count: 1 }, 'only 1 group title rendered');
|
||||
});
|
||||
|
||||
test('toolbar links navigate to expected routes', async function (assert) {
|
||||
await authPage.login(this.pkiAdminToken);
|
||||
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
|
||||
await click(SELECTORS.issuersTab);
|
||||
await click(SELECTORS.issuerPopupMenu);
|
||||
await click(SELECTORS.issuerPopupDetails);
|
||||
|
||||
const issuerId = find(SELECTORS.issuerDetails.valueByName('Issuer ID')).innerText;
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/details`,
|
||||
'it navigates to details route'
|
||||
);
|
||||
assert
|
||||
.dom(SELECTORS.issuerDetails.crossSign)
|
||||
.hasAttribute('href', `/ui/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/cross-sign`);
|
||||
assert
|
||||
.dom(SELECTORS.issuerDetails.signIntermediate)
|
||||
.hasAttribute('href', `/ui/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/sign`);
|
||||
assert
|
||||
.dom(SELECTORS.issuerDetails.configure)
|
||||
.hasAttribute('href', `/ui/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/edit`);
|
||||
await click(SELECTORS.issuerDetails.rotateRoot);
|
||||
assert.dom(find(SELECTORS.issuerDetails.rotateModal).parentElement).hasClass('is-active');
|
||||
await click(SELECTORS.issuerDetails.rotateModalGenerate);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/issuers/${issuerId}/rotate-root`,
|
||||
'it navigates to root rotate form'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-input="commonName"]')
|
||||
.hasValue('Hashicorp Test', 'form prefilled with parent issuer cn');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,5 +13,7 @@ export const SELECTORS = {
|
|||
signIntermediate: '[data-test-pki-issuer-sign-int]',
|
||||
download: '[data-test-issuer-download]',
|
||||
configure: '[data-test-pki-issuer-configure]',
|
||||
rotateModal: '[data-test-modal-background="Rotate this root"]',
|
||||
rotateModalGenerate: '[data-test-root-rotate-step-one]',
|
||||
valueByName: (name) => `[data-test-value-div="${name}"]`,
|
||||
};
|
||||
|
|
|
@ -50,6 +50,8 @@ export const SELECTORS = {
|
|||
generateIssuerDropdown: '[data-test-issuer-generate-dropdown]',
|
||||
generateIssuerRoot: '[data-test-generate-issuer="root"]',
|
||||
generateIssuerIntermediate: '[data-test-generate-issuer="intermediate"]',
|
||||
issuerPopupMenu: '[data-test-popup-menu-trigger]',
|
||||
issuerPopupDetails: '[data-test-popup-menu-details] a',
|
||||
issuerDetails: {
|
||||
title: '[data-test-pki-issuer-page-title]',
|
||||
...ISSUERDETAILS,
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, render, resetOnerror, setupOnerror } from '@ember/test-helpers';
|
||||
import { isPresent } from 'ember-cli-page-object';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const SELECTORS = {
|
||||
button: '[data-test-download-button]',
|
||||
icon: '[data-test-icon="download"]',
|
||||
};
|
||||
module('Integration | Component | download button', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const downloadService = this.owner.lookup('service:download');
|
||||
this.downloadSpy = sinon.stub(downloadService, 'miscExtension');
|
||||
|
||||
this.data = 'my data to download';
|
||||
this.filename = 'my special file';
|
||||
this.extension = 'csv';
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`
|
||||
<DownloadButton class="button">
|
||||
<Icon @name="download" />
|
||||
Download
|
||||
</DownloadButton>
|
||||
`);
|
||||
assert.dom(SELECTORS.button).hasClass('button');
|
||||
assert.ok(isPresent(SELECTORS.icon), 'renders yielded icon');
|
||||
assert.dom(SELECTORS.button).hasTextContaining('Download', 'renders yielded text');
|
||||
});
|
||||
|
||||
test('it downloads with defaults when only passed @data arg', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await render(hbs`
|
||||
<DownloadButton class="button"
|
||||
@data={{this.data}}
|
||||
>
|
||||
Download
|
||||
</DownloadButton>
|
||||
`);
|
||||
await click(SELECTORS.button);
|
||||
const [filename, content, extension] = this.downloadSpy.getCall(0).args;
|
||||
assert.ok(filename.includes('Z'), 'filename defaults to ISO string');
|
||||
assert.strictEqual(content, this.data, 'called with correct data');
|
||||
assert.strictEqual(extension, 'txt', 'called with default extension');
|
||||
});
|
||||
|
||||
test('it calls download service with passed in args', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await render(hbs`
|
||||
<DownloadButton class="button"
|
||||
@data={{this.data}}
|
||||
@filename={{this.filename}}
|
||||
@mime={{this.mime}}
|
||||
@extension={{this.extension}}
|
||||
>
|
||||
Download
|
||||
</DownloadButton>
|
||||
`);
|
||||
|
||||
await click(SELECTORS.button);
|
||||
const [filename, content, extension] = this.downloadSpy.getCall(0).args;
|
||||
assert.ok(filename.includes(`${this.filename}-`), 'filename added to ISO string');
|
||||
assert.strictEqual(content, this.data, 'called with correct data');
|
||||
assert.strictEqual(extension, this.extension, 'called with passed in extension');
|
||||
});
|
||||
|
||||
test('it sets download content with arg passed to fetchData', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.fetchData = () => 'this is fetched data from a parent function';
|
||||
await render(hbs`
|
||||
<DownloadButton class="button" @fetchData={{this.fetchData}} >
|
||||
Download
|
||||
</DownloadButton>
|
||||
`);
|
||||
|
||||
await click(SELECTORS.button);
|
||||
const [filename, content, extension] = this.downloadSpy.getCall(0).args;
|
||||
assert.ok(filename.includes('Z'), 'filename defaults to ISO string');
|
||||
assert.strictEqual(content, this.fetchData(), 'called with fetched data');
|
||||
assert.strictEqual(extension, 'txt', 'called with default extension');
|
||||
});
|
||||
|
||||
test('it throws error when both data and fetchData are passed as args', async function (assert) {
|
||||
assert.expect(1);
|
||||
setupOnerror((error) => {
|
||||
assert.strictEqual(
|
||||
error.message,
|
||||
'Assertion Failed: Only pass either @data or @fetchData, passing both means @data will be overwritten by the return value of @fetchData',
|
||||
'throws error with incorrect args'
|
||||
);
|
||||
});
|
||||
this.fetchData = () => 'this is fetched data from a parent function';
|
||||
await render(hbs`
|
||||
<DownloadButton class="button" @data={{this.data}} @fetchData={{this.fetchData}} />
|
||||
`);
|
||||
resetOnerror();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, fillIn, findAll, render } from '@ember/test-helpers';
|
||||
import { setupEngine } from 'ember-engines/test-support';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import sinon from 'sinon';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { loadedCert } from 'vault/tests/helpers/pki/values';
|
||||
import camelizeKeys from 'vault/utils/camelize-object-keys';
|
||||
import { parseCertificate } from 'vault/utils/parse-pki-cert';
|
||||
import { SELECTORS as S } from 'vault/tests/helpers/pki/pki-generate-root';
|
||||
|
||||
const SELECTORS = {
|
||||
pageTitle: '[data-test-pki-page-title]',
|
||||
alertBanner: '[data-test-alert-banner="alert"]',
|
||||
toolbarCrossSign: '[data-test-pki-issuer-cross-sign]',
|
||||
toolbarSignInt: '[data-test-pki-issuer-sign-int]',
|
||||
toolbarDownload: '[data-test-issuer-download]',
|
||||
oldRadioSelect: 'input#use-old-root-settings',
|
||||
customRadioSelect: 'input#customize-new-root-certificate',
|
||||
toggle: '[data-test-details-toggle]',
|
||||
input: (attr) => `[data-test-input="${attr}"]`,
|
||||
infoRowValue: (attr) => `[data-test-value-div="${attr}"]`,
|
||||
validationError: '[data-test-pki-rotate-root-validation-error]',
|
||||
rotateRootForm: '[data-test-pki-rotate-old-settings-form]',
|
||||
rotateRootSave: '[data-test-pki-rotate-root-save]',
|
||||
rotateRootCancel: '[data-test-pki-rotate-root-cancel]',
|
||||
doneButton: '[data-test-done]',
|
||||
// root form
|
||||
generateRootForm: '[data-test-pki-config-generate-root-form]',
|
||||
...S,
|
||||
};
|
||||
|
||||
module('Integration | Component | page/pki-issuer-rotate-root', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupEngine(hooks, 'pki');
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.backend = 'test-pki';
|
||||
this.owner.lookup('service:secret-mount-path').update(this.backend);
|
||||
this.onCancel = sinon.spy();
|
||||
this.onComplete = sinon.spy();
|
||||
this.breadcrumbs = [{ label: 'rotate root' }];
|
||||
this.oldRootData = {
|
||||
certificate: loadedCert,
|
||||
issuer_id: 'old-issuer-id',
|
||||
issuer_name: 'old-issuer',
|
||||
};
|
||||
this.parsedRootCert = camelizeKeys(parseCertificate(loadedCert));
|
||||
this.store.pushPayload('pki/issuer', { modelName: 'pki/issuer', data: this.oldRootData });
|
||||
this.oldRoot = this.store.peekRecord('pki/issuer', 'old-issuer-id');
|
||||
this.newRootModel = this.store.createRecord('pki/action', {
|
||||
actionType: 'rotate-root',
|
||||
type: 'internal',
|
||||
...this.parsedRootCert, // copy old root settings over to new one
|
||||
});
|
||||
|
||||
this.returnedData = {
|
||||
id: 'response-id',
|
||||
certificate: loadedCert,
|
||||
expiration: 1682735724,
|
||||
issuer_id: 'some-issuer-id',
|
||||
issuer_name: 'my issuer',
|
||||
issuing_ca: loadedCert,
|
||||
key_id: 'my-key-id',
|
||||
key_name: 'my-key',
|
||||
serial_number: '3a:3c:17:..',
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
assert.expect(17);
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.oldRoot}}
|
||||
@newRootModel={{this.newRootModel}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onComplete={{this.onComplete}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(SELECTORS.pageTitle).hasText('Generate new root');
|
||||
assert.dom(SELECTORS.oldRadioSelect).isChecked('defaults to use-old-settings');
|
||||
assert.dom(SELECTORS.rotateRootForm).exists('it renders old settings form');
|
||||
assert
|
||||
.dom(SELECTORS.input('commonName'))
|
||||
.hasValue(this.parsedRootCert.commonName, 'common name prefilled with root cert cn');
|
||||
assert.dom(SELECTORS.toggle).hasText('Old root settings', 'toggle renders correct text');
|
||||
assert.dom(SELECTORS.input('issuerName')).exists('renders issuer name input');
|
||||
assert.strictEqual(findAll('[data-test-row-label]').length, 0, 'it hides the old root info table rows');
|
||||
await click(SELECTORS.toggle);
|
||||
assert.strictEqual(findAll('[data-test-row-label]').length, 11, 'it shows the old root info table rows');
|
||||
assert
|
||||
.dom(SELECTORS.infoRowValue('Issuer name'))
|
||||
.hasText(this.oldRoot.issuerName, 'renders correct issuer data');
|
||||
await click(SELECTORS.toggle);
|
||||
assert.strictEqual(findAll('[data-test-row-label]').length, 0, 'it hides again');
|
||||
|
||||
// customize form
|
||||
await click(SELECTORS.customRadioSelect);
|
||||
assert.dom(SELECTORS.generateRootForm).exists('it renders generate root form');
|
||||
assert
|
||||
.dom(SELECTORS.input('permittedDnsDomains'))
|
||||
.hasValue(this.parsedRootCert.permittedDnsDomains, 'form is prefilled with values from old root');
|
||||
await click(SELECTORS.generateRootCancel);
|
||||
assert.ok(this.onCancel.calledOnce, 'custom form calls @onCancel passed from parent');
|
||||
await click(SELECTORS.oldRadioSelect);
|
||||
await click(SELECTORS.rotateRootCancel);
|
||||
assert.ok(this.onCancel.calledTwice, 'old root settings form calls @onCancel from parent');
|
||||
|
||||
// validations
|
||||
await fillIn(SELECTORS.input('commonName'), '');
|
||||
await fillIn(SELECTORS.input('issuerName'), 'default');
|
||||
await click(SELECTORS.rotateRootSave);
|
||||
assert.dom(SELECTORS.validationError).hasText('There are 2 errors with this form.');
|
||||
assert.dom(SELECTORS.input('commonName')).hasClass('has-error-border', 'common name has error border');
|
||||
assert.dom(SELECTORS.input('issuerName')).hasClass('has-error-border', 'issuer name has error border');
|
||||
});
|
||||
|
||||
test('it sends request to rotate/internal on save when using old root settings', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.post(`/${this.backend}/root/rotate/internal`, () => {
|
||||
assert.ok('request made to correct default endpoint type=internal');
|
||||
});
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.oldRoot}}
|
||||
@newRootModel={{this.newRootModel}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onComplete={{this.onComplete}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
await click(SELECTORS.rotateRootSave);
|
||||
});
|
||||
|
||||
function testEndpoint(test, type) {
|
||||
test(`it sends request to rotate/${type} endpoint on save with custom root settings`, async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.post(`/${this.backend}/root/rotate/${type}`, () => {
|
||||
assert.ok('request is made to correct endpoint');
|
||||
});
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.oldRoot}}
|
||||
@newRootModel={{this.newRootModel}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onComplete={{this.onComplete}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
await click(SELECTORS.customRadioSelect);
|
||||
await fillIn(SELECTORS.typeField, type);
|
||||
await click(SELECTORS.generateRootSave);
|
||||
});
|
||||
}
|
||||
testEndpoint(test, 'internal');
|
||||
testEndpoint(test, 'exported');
|
||||
testEndpoint(test, 'existing');
|
||||
testEndpoint(test, 'kms');
|
||||
|
||||
test('it renders details after save for exported key type', async function (assert) {
|
||||
assert.expect(10);
|
||||
const keyData = {
|
||||
private_key: `-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAtc9yU`,
|
||||
private_key_type: 'rsa',
|
||||
};
|
||||
this.store.pushPayload('pki/action', {
|
||||
modelName: 'pki/action',
|
||||
data: { ...this.returnedData, ...keyData },
|
||||
});
|
||||
this.newRootModel = this.store.peekRecord('pki/action', 'response-id');
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.oldRoot}}
|
||||
@newRootModel={{this.newRootModel}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onComplete={{this.onComplete}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(SELECTORS.pageTitle).hasText('View issuer certificate');
|
||||
assert
|
||||
.dom(SELECTORS.alertBanner)
|
||||
.hasText(
|
||||
'Next steps Your new root has been generated. Make sure to copy and save the private_key as it is only available once. If you’re ready, you can begin cross-signing issuers now. If not, the option to cross-sign is available when you use this certificate. Cross-sign issuers'
|
||||
);
|
||||
assert.dom(SELECTORS.infoRowValue('Certificate')).exists();
|
||||
assert.dom(SELECTORS.infoRowValue('Issuer name')).exists();
|
||||
assert.dom(SELECTORS.infoRowValue('Issuing CA')).exists();
|
||||
assert.dom(`${SELECTORS.infoRowValue('Private key')} .masked-input`).hasClass('allow-copy');
|
||||
assert.dom(`${SELECTORS.infoRowValue('Private key type')} span`).hasText('rsa');
|
||||
assert.dom(SELECTORS.infoRowValue('Serial number')).hasText(this.returnedData.serial_number);
|
||||
assert.dom(SELECTORS.infoRowValue('Key ID')).hasText(this.returnedData.key_id);
|
||||
|
||||
await click(SELECTORS.doneButton);
|
||||
assert.ok(this.onComplete.calledOnce, 'clicking done fires @onComplete from parent');
|
||||
});
|
||||
|
||||
test('it renders details after save for internal key type', async function (assert) {
|
||||
assert.expect(13);
|
||||
this.store.pushPayload('pki/action', {
|
||||
modelName: 'pki/action',
|
||||
data: this.returnedData,
|
||||
});
|
||||
this.newRootModel = this.store.peekRecord('pki/action', 'response-id');
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerRotateRoot
|
||||
@oldRoot={{this.oldRoot}}
|
||||
@newRootModel={{this.newRootModel}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onComplete={{this.onComplete}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(SELECTORS.pageTitle).hasText('View issuer certificate');
|
||||
assert.dom(SELECTORS.toolbarCrossSign).exists();
|
||||
assert.dom(SELECTORS.toolbarSignInt).exists();
|
||||
assert.dom(SELECTORS.toolbarDownload).exists();
|
||||
assert
|
||||
.dom(SELECTORS.alertBanner)
|
||||
.hasText(
|
||||
'Next steps Your new root has been generated. If you’re ready, you can begin cross-signing issuers now. If not, the option to cross-sign is available when you use this certificate. Cross-sign issuers'
|
||||
);
|
||||
assert.dom(SELECTORS.infoRowValue('Certificate')).exists();
|
||||
assert.dom(SELECTORS.infoRowValue('Issuer name')).exists();
|
||||
assert.dom(SELECTORS.infoRowValue('Issuing CA')).exists();
|
||||
assert.dom(`${SELECTORS.infoRowValue('Private key')} span`).hasText('internal');
|
||||
assert.dom(`${SELECTORS.infoRowValue('Private key type')} span`).hasText('internal');
|
||||
assert.dom(SELECTORS.infoRowValue('Serial number')).hasText(this.returnedData.serial_number);
|
||||
assert.dom(SELECTORS.infoRowValue('Key ID')).hasText(this.returnedData.key_id);
|
||||
|
||||
await click(SELECTORS.doneButton);
|
||||
assert.ok(this.onComplete.calledOnce, 'clicking done fires @onComplete from parent');
|
||||
});
|
||||
});
|
|
@ -10,7 +10,7 @@ import { hbs } from 'ember-cli-htmlbars';
|
|||
import { setupEngine } from 'ember-engines/test-support';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
module('Integration | Component | pki generate csr', function (hooks) {
|
||||
module('Integration | Component | ', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupEngine(hooks, 'pki');
|
||||
setupMirage(hooks);
|
||||
|
@ -47,7 +47,7 @@ module('Integration | Component | pki generate csr', function (hooks) {
|
|||
'commonName',
|
||||
'excludeCnFromSans',
|
||||
'format',
|
||||
'serialNumber',
|
||||
'subjectSerialNumber',
|
||||
'addBasicConstraints',
|
||||
];
|
||||
fields.forEach((key) => {
|
||||
|
|
|
@ -66,7 +66,7 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
|
|||
|
||||
await click(selectors.sanOptions);
|
||||
|
||||
const fields = ['excludeCnFromSans', 'serialNumber', 'altNames', 'ipSans', 'uriSans', 'otherSans'];
|
||||
const fields = ['excludeCnFromSans', 'subjectSerialNumber', 'altNames', 'ipSans', 'uriSans', 'otherSans'];
|
||||
assert.dom('[data-test-field]').exists({ count: 6 }, `Correct number of fields render`);
|
||||
fields.forEach((key) => {
|
||||
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for generate-root actionType`);
|
||||
|
|
|
@ -23,7 +23,13 @@ module('Integration | Component | page/pki-issuer-details', function (hooks) {
|
|||
});
|
||||
|
||||
test('it renders with correct toolbar by default', async function (assert) {
|
||||
await render(hbs`<Page::PkiIssuerDetails @issuer={{this.issuer}} />`, this.context);
|
||||
await render(
|
||||
hbs`
|
||||
<Page::PkiIssuerDetails @issuer={{this.issuer}} />
|
||||
<div id="modal-wormhole"></div>
|
||||
`,
|
||||
this.context
|
||||
);
|
||||
|
||||
assert.dom(SELECTORS.rotateRoot).doesNotExist();
|
||||
assert.dom(SELECTORS.crossSign).doesNotExist();
|
||||
|
@ -33,19 +39,29 @@ module('Integration | Component | page/pki-issuer-details', function (hooks) {
|
|||
});
|
||||
|
||||
test('it renders toolbar actions depending on passed capabilities', async function (assert) {
|
||||
this.set('isRotatable', true);
|
||||
this.set('canRotate', true);
|
||||
this.set('canCrossSign', true);
|
||||
this.set('canSignIntermediate', true);
|
||||
this.set('canConfigure', true);
|
||||
|
||||
await render(
|
||||
hbs`<Page::PkiIssuerDetails @issuer={{this.issuer}} @canRotate={{this.canRotate}} @canCrossSign={{this.canCrossSign}} @canSignIntermediate={{this.canSignIntermediate}} @canConfigure={{this.canConfigure}} />`,
|
||||
hbs`
|
||||
<Page::PkiIssuerDetails
|
||||
@issuer={{this.issuer}}
|
||||
@isRotatable={{this.isRotatable}}
|
||||
@canRotate={{this.canRotate}}
|
||||
@canCrossSign={{this.canCrossSign}}
|
||||
@canSignIntermediate={{this.canSignIntermediate}}
|
||||
@canConfigure={{this.canConfigure}}
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`,
|
||||
this.context
|
||||
);
|
||||
|
||||
// Add back when rotate root capability is added
|
||||
// assert.dom(SELECTORS.rotateRoot).hasText('Rotate this root');
|
||||
assert.dom(SELECTORS.crossSign).hasText('Cross-sign Issuer');
|
||||
assert.dom(SELECTORS.rotateRoot).hasText('Rotate this root');
|
||||
assert.dom(SELECTORS.crossSign).hasText('Cross-sign issuers');
|
||||
assert.dom(SELECTORS.signIntermediate).hasText('Sign Intermediate');
|
||||
assert.dom(SELECTORS.download).hasText('Download');
|
||||
assert.dom(SELECTORS.configure).hasText('Configure');
|
||||
|
|
|
@ -62,7 +62,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
|
|||
permitted_dns_domains: 'dnsname1.com, dsnname2.com',
|
||||
postal_code: '123456',
|
||||
province: 'Champagne',
|
||||
serial_number: 'cereal1292',
|
||||
subject_serial_number: 'cereal1292',
|
||||
signature_bits: '256',
|
||||
street_address: '234 sesame',
|
||||
ttl: '768h',
|
||||
|
@ -141,7 +141,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
|
|||
ou: null,
|
||||
postal_code: null,
|
||||
province: null,
|
||||
serial_number: null,
|
||||
subject_serial_number: null,
|
||||
street_address: null,
|
||||
uri_sans: null,
|
||||
},
|
||||
|
@ -164,7 +164,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
|
|||
ou: 'Finance',
|
||||
postal_code: '123456',
|
||||
province: 'Champagne',
|
||||
serial_number: 'cereal1292',
|
||||
subject_serial_number: 'cereal1292',
|
||||
street_address: '234 sesame',
|
||||
},
|
||||
},
|
||||
|
@ -251,7 +251,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
|
|||
parsing_errors: [{}, {}],
|
||||
postal_code: null,
|
||||
province: null,
|
||||
serial_number: null,
|
||||
subject_serial_number: null,
|
||||
signature_bits: '256',
|
||||
street_address: null,
|
||||
ttl: '87600h',
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Application from 'vault/adapters/application';
|
||||
import Adapter from 'ember-data/adapter';
|
||||
import ModelRegistry from 'ember-data/types/registries/model';
|
||||
|
||||
|
@ -10,5 +11,6 @@ import ModelRegistry from 'ember-data/types/registries/model';
|
|||
* Catch-all for ember-data.
|
||||
*/
|
||||
export default interface AdapterRegistry {
|
||||
application: Application;
|
||||
[key: keyof ModelRegistry]: Adapter;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ export default class PkiIssuerModel extends Model {
|
|||
get useOpenAPI(): boolean;
|
||||
get backend(): string;
|
||||
get issuerRef(): string;
|
||||
certificate: string;
|
||||
issuerId: string;
|
||||
issuerName: string;
|
||||
keyId: string;
|
||||
|
@ -31,7 +32,7 @@ export default class PkiIssuerModel extends Model {
|
|||
importedIssuers: string[];
|
||||
importedKeys: string[];
|
||||
formFields: FormField[];
|
||||
formFieldGroups: FormFieldGroups;
|
||||
formFieldGroups: FormFieldGroups[];
|
||||
allFields: FormField[];
|
||||
get canRotateIssuer(): boolean;
|
||||
get canCrossSign(): boolean;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export default function camelizeKeys(object: unknown): { [key: string]: unknown };
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
interface ParsedCertificateData {
|
||||
parsing_errors: Array<Error>;
|
||||
can_parse: boolean;
|
||||
|
||||
// certificate values
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
ou: string;
|
||||
organization: string;
|
||||
country: string;
|
||||
locality: string;
|
||||
province: string;
|
||||
street_address: string;
|
||||
postal_code: string;
|
||||
key_usage: string;
|
||||
other_sans: string;
|
||||
alt_names: string;
|
||||
uri_sans: string;
|
||||
ip_sans: string;
|
||||
permitted_dns_domains: string;
|
||||
max_path_length: number;
|
||||
exclude_cn_from_sans: boolean;
|
||||
signature_bits: string;
|
||||
use_pss: boolean;
|
||||
expiry_date: date; // remove along with old PKI work
|
||||
issue_date: date; // remove along with old PKI work
|
||||
not_valid_after: number;
|
||||
not_valid_before: number;
|
||||
ttl: Duration;
|
||||
}
|
||||
export function parseCertificate(certificateContent: string): ParsedCertificateData;
|
Loading…
Reference in New Issue