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:
claire bontempo 2023-03-31 15:47:23 -06:00 committed by GitHub
parent 0eac17a91f
commit 069b00b031
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1146 additions and 98 deletions

View File

@ -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');
}

View File

@ -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.',

View File

@ -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;

View File

@ -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;
}

View File

@ -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':

View File

@ -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;

View File

@ -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;
}

View File

@ -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',
];

View File

@ -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.

View File

@ -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}`);

View File

@ -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]) {

View File

@ -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}}

View File

@ -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>

View File

@ -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;
}

View File

@ -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"}}

View File

@ -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"}}

View File

@ -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"}}

View File

@ -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 youre 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}}

View File

@ -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 roots 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;
}
}
}

View File

@ -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">

View File

@ -59,7 +59,7 @@ export default class PkiGenerateCsrComponent extends Component<Args> {
'commonName',
'excludeCnFromSans',
'format',
'serialNumber',
'subjectSerialNumber',
'addBasicConstraints',
]);
}

View File

@ -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>

View File

@ -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,
];
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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}}

View File

@ -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)
],
};
}

View File

@ -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 () {

View File

@ -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 {

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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' },
];
}
}

View File

@ -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>

View File

@ -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}}
/>

View File

@ -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"}}
/>

BIN
ui/public/images/pki-rotate-root.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -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');
});
});
});

View File

@ -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}"]`,
};

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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 youre 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 youre 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');
});
});

View File

@ -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) => {

View File

@ -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`);

View File

@ -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');

View File

@ -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',

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export default function camelizeKeys(object: unknown): { [key: string]: unknown };

View File

@ -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;