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 b04751d224
commit 0496c6107b
50 changed files with 1143 additions and 98 deletions

View File

@@ -30,6 +30,8 @@ export default class PkiActionAdapter extends ApplicationAdapter {
: `${baseUrl}/intermediate/generate/${type}`; : `${baseUrl}/intermediate/generate/${type}`;
case 'sign-intermediate': case 'sign-intermediate':
return `${baseUrl}/issuer/${encodePath(issuerRef)}/sign-intermediate`; return `${baseUrl}/issuer/${encodePath(issuerRef)}/sign-intermediate`;
case 'rotate-root':
return `${baseUrl}/root/rotate/${type}`;
default: default:
assert('actionType must be one of import, generate-root, generate-csr or sign-intermediate'); assert('actionType must be one of import, generate-root, generate-csr or sign-intermediate');
} }

View File

@@ -16,10 +16,14 @@ const validations = {
issuerName: [ issuerName: [
{ {
validator(model) { 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; 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; @attr('string', { readOnly: true, masked: true }) certificate;
/* actionType generate-root */ /* 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', { @attr('string', {
possibleValues: ['exported', 'internal', 'existing', 'kms'], possibleValues: ['exported', 'internal', 'existing', 'kms'],
noDefault: true, noDefault: true,
@@ -149,7 +160,9 @@ export default class PkiActionModel extends Model {
subText: subText:
"Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.", "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', { @attr('boolean', {
subText: 'Whether to add a Basic Constraints extension with CA: true.', subText: 'Whether to add a Basic Constraints extension with CA: true.',

View File

@@ -25,7 +25,7 @@ const displayFields = [
'commonName', 'commonName',
'issuerName', 'issuerName',
'issuerId', 'issuerId',
'serialNumber', 'subjectSerialNumber',
'keyId', 'keyId',
'altNames', 'altNames',
'uriSans', 'uriSans',
@@ -53,8 +53,8 @@ export default class PkiIssuerModel extends Model {
// READ ONLY // READ ONLY
@attr isDefault; @attr isDefault;
@attr('string', { label: 'Issuer ID' }) issuerId; @attr('string', { label: 'Issuer ID', detailLinkTo: 'issuers.issuer.details' }) issuerId;
@attr('string', { label: 'Default key ID' }) keyId; @attr('string', { label: 'Default key ID', detailLinkTo: 'keys.key.details' }) keyId;
@attr({ label: 'CA Chain', masked: true }) caChain; @attr({ label: 'CA Chain', masked: true }) caChain;
@attr({ masked: true }) certificate; @attr({ masked: true }) certificate;
@@ -62,7 +62,7 @@ export default class PkiIssuerModel extends Model {
@attr commonName; @attr commonName;
@attr('number', { formatDate: true }) notValidAfter; @attr('number', { formatDate: true }) notValidAfter;
@attr('number', { formatDate: true }) notValidBefore; @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: 'Subject Alternative Names (SANs)' }) altNames;
@attr({ label: 'IP SANs' }) ipSans; @attr({ label: 'IP SANs' }) ipSans;
@attr({ label: 'URI SANs' }) uriSans; @attr({ label: 'URI SANs' }) uriSans;

View File

@@ -94,5 +94,5 @@ export default class PkiSignIntermediateModel extends PkiCertificateBaseModel {
subText: subText:
"Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.", "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 = { attrs = {
customTtl: { serialize: false }, customTtl: { serialize: false },
type: { serialize: false }, type: { serialize: false },
subjectSerialNumber: { serialize: false },
}; };
serialize(snapshot, requestType) { serialize(snapshot, requestType) {
@@ -18,6 +19,9 @@ export default class PkiActionSerializer extends ApplicationSerializer {
// requestType is a custom value specified from the pki/action adapter // requestType is a custom value specified from the pki/action adapter
const allowedPayloadAttributes = this._allowedParamsByType(requestType, snapshot.record.type); const allowedPayloadAttributes = this._allowedParamsByType(requestType, snapshot.record.type);
if (!allowedPayloadAttributes) return data; 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 = {}; const payload = {};
allowedPayloadAttributes.forEach((key) => { allowedPayloadAttributes.forEach((key) => {
@@ -63,6 +67,17 @@ export default class PkiActionSerializer extends ApplicationSerializer {
'private_key_format', 'private_key_format',
'ttl', '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': case 'generate-csr':
return [...commonProps, 'add_basic_constraints']; return [...commonProps, 'add_basic_constraints'];
case 'sign-intermediate': case 'sign-intermediate':

View File

@@ -4,6 +4,7 @@
*/ */
import { parseCertificate } from 'vault/utils/parse-pki-cert'; import { parseCertificate } from 'vault/utils/parse-pki-cert';
import { parsedParameters } from 'vault/utils/parse-pki-cert-oids';
import ApplicationSerializer from '../application'; import ApplicationSerializer from '../application';
export default class PkiIssuerSerializer extends ApplicationSerializer { export default class PkiIssuerSerializer extends ApplicationSerializer {
@@ -12,21 +13,7 @@ export default class PkiIssuerSerializer extends ApplicationSerializer {
constructor() { constructor() {
super(...arguments); super(...arguments);
// remove following attrs from serialization // remove following attrs from serialization
const attrs = [ const attrs = ['caChain', 'certificate', 'issuerId', 'keyId', ...parsedParameters];
'altNames',
'caChain',
'certificate',
'commonName',
'ipSans',
'issuerId',
'keyId',
'otherSans',
'notValidAfter',
'notValidBefore',
'serialNumber',
'signatureBits',
'uriSans',
];
this.attrs = attrs.reduce((attrObj, attr) => { this.attrs = attrs.reduce((attrObj, attr) => {
attrObj[attr] = { serialize: false }; attrObj[attr] = { serialize: false };
return attrObj; 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. * Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0 * SPDX-License-Identifier: MPL-2.0
*/ */
import camelizeKeys from 'vault/utils/camelize-object-keys';
//* lookup OIDs: http://oid-info.com/basic-search.htm //* lookup OIDs: http://oid-info.com/basic-search.htm
export const SUBJECT_OIDs = { export const SUBJECT_OIDs = {
common_name: '2.5.4.3', common_name: '2.5.4.3',
serial_number: '2.5.4.5', subject_serial_number: '2.5.4.5',
ou: '2.5.4.11', ou: '2.5.4.11',
organization: '2.5.4.10', organization: '2.5.4.10',
country: '2.5.4.6', 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.2.840.10045.4.3.4': '512', // ECDSA-SHA512
'1.3.101.112': '0', // Ed25519 '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. 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) { export function parseCertificate(certificateContent) {
let cert; let cert;
try { try {
const cert_base64 = certificateContent.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, ''); cert = jsonToCertObject(certificateContent);
const cert_der = fromBase64(cert_base64);
const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der));
cert = new Certificate({ schema: cert_asn1.result });
} catch (error) { } catch (error) {
console.debug('DEBUG: Converting Certificate', error); // eslint-disable-line console.debug('DEBUG: Converting Certificate', error); // eslint-disable-line
return { can_parse: false }; 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 //* PARSING HELPERS
/* /*
We wish to get each SUBJECT_OIDs (see utils/parse-pki-cert-oids.js) out of this certificate's subject. 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 { inject as service } from '@ember/service';
import errorMessage from 'vault/utils/error-message'; import errorMessage from 'vault/utils/error-message';
import timestamp from 'vault/utils/timestamp'; import timestamp from 'vault/utils/timestamp';
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
/** /**
* @module DownloadButton * @module DownloadButton
* DownloadButton components are an action button used to download data. Both the action text and icon are yielded. * 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} [filename] - name of file that prefixes the ISO timestamp generated at download
* @param {string} [data] - data to 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 {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 * @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 { export default class DownloadButton extends Component {
@service download; @service download;
@service flashMessages; @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() { get filename() {
const ts = timestamp.now().toISOString(); const ts = timestamp.now().toISOString();
return this.args.filename ? this.args.filename + '-' + ts : ts; return this.args.filename ? this.args.filename + '-' + ts : ts;
@@ -46,7 +58,7 @@ export default class DownloadButton extends Component {
if (this.args.stringify) { if (this.args.stringify) {
return JSON.stringify(this.args.data, null, 2); return JSON.stringify(this.args.data, null, 2);
} }
return this.args.data; return this.fetchedData || this.args.data;
} }
get extension() { get extension() {
@@ -54,7 +66,10 @@ export default class DownloadButton extends Component {
} }
@action @action
handleDownload() { async handleDownload() {
if (this.args.fetchData) {
this.fetchedData = await this.args.fetchData();
}
try { try {
this.download.miscExtension(this.filename, this.content, this.extension); this.download.miscExtension(this.filename, this.content, this.extension);
this.flashMessages.info(`Downloading ${this.filename}`); this.flashMessages.info(`Downloading ${this.filename}`);

View File

@@ -37,6 +37,11 @@ export const MESSAGE_TYPES = {
glyph: 'loading', glyph: 'loading',
text: 'Loading', text: 'Loading',
}, },
rotation: {
class: 'is-info',
glyphClass: 'has-text-grey',
glyph: 'rotate-cw',
},
}; };
export function messageTypes([type]) { export function messageTypes([type]) {

View File

@@ -9,7 +9,9 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
{{#unless @config.id}} {{#if @config.id}}
<Toolbar />
{{else}}
<div class="box is-bottomless is-fullwidth is-marginless"> <div class="box is-bottomless is-fullwidth is-marginless">
<div class="columns"> <div class="columns">
{{#each this.configTypes as |option|}} {{#each this.configTypes as |option|}}
@@ -40,7 +42,7 @@
{{/each}} {{/each}}
</div> </div>
</div> </div>
{{/unless}} {{/if}}
{{#if (eq @config.actionType "import")}} {{#if (eq @config.actionType "import")}}
<PkiImportPemBundle <PkiImportPemBundle
@model={{@config}} @model={{@config}}
@@ -50,6 +52,15 @@
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}} @adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}}
/> />
{{else if (eq @config.actionType "generate-root")}} {{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 <PkiGenerateRoot
@model={{@config}} @model={{@config}}
@urls={{@urls}} @urls={{@urls}}

View File

@@ -1,18 +1,19 @@
<Toolbar> <Toolbar>
<ToolbarActions> <ToolbarActions>
{{!-- {{#if @canRotate}} {{#if (and @isRotatable @canRotate)}}
<ToolbarLink @route="issuers.generate-root" @type="rotate-cw" @issuer={{@issuer.id}} data-test-pki-issuer-rotate-root> <button
Rotate this root type="button"
</ToolbarLink> class="toolbar-link"
{{/if}} --}} {{on "click" (fn (mut this.showRotationModal) true)}}
{{#if @canCrossSign}} data-test-pki-issuer-rotate-root
<ToolbarLink
@route="issuers.issuer.cross-sign"
@type="pen-tool"
@issuer={{@issuer.id}}
data-test-pki-issuer-cross-sign
> >
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> </ToolbarLink>
{{/if}} {{/if}}
{{#if @canSignIntermediate}} {{#if @canSignIntermediate}}
@@ -67,7 +68,7 @@
</BasicDropdown> </BasicDropdown>
{{#if @canConfigure}} {{#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 Configure
</ToolbarLink> </ToolbarLink>
{{/if}} {{/if}}
@@ -83,7 +84,6 @@
You may also want to configure its usage and other behaviors. You may also want to configure its usage and other behaviors.
</p> </p>
{{/if}} {{/if}}
<main data-test-issuer-details> <main data-test-issuer-details>
{{#each @issuer.formFieldGroups as |fieldGroup|}} {{#each @issuer.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}} {{#each-in fieldGroup as |group fields|}}
@@ -124,5 +124,48 @@
</div> </div>
{{/each-in}} {{/each-in}}
{{/each}} {{/each}}
</main>
</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> </p.levelLeft>
</PageHeader> </PageHeader>
{{#if @model.id}}
<Toolbar />
{{/if}}
<PkiGenerateCsr <PkiGenerateCsr
@model={{@model}} @model={{@model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}} @onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}

View File

@@ -9,6 +9,18 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </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 <PkiGenerateRoot
@model={{@model}} @model={{@model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}} @onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}

View File

@@ -9,6 +9,9 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
{{#if @model.id}}
<Toolbar />
{{/if}}
<PkiImportPemBundle <PkiImportPemBundle
@model={{@model}} @model={{@model}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}} @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}} {{#if @model.id}}
{{! Model only has ID once form has been submitted and saved }} {{! Model only has ID once form has been submitted and saved }}
<Toolbar />
<main data-test-generate-csr-result> <main data-test-generate-csr-result>
<div class="box is-sideless is-fullwidth is-shadowless"> <div class="box is-sideless is-fullwidth is-shadowless">
<AlertBanner @title="Next steps" @type="warning"> <AlertBanner @title="Next steps" @type="warning">

View File

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

View File

@@ -1,21 +1,13 @@
{{! Show results if model has an ID, which is only generated after save }} {{! Show results if model has an ID, which is only generated after save }}
{{#if @model.id}} {{#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"> <main class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.returnedFields as |field|}} {{#each this.returnedFields as |field|}}
{{#let (find-by "name" field @model.allFields) as |attr|}} {{#let (find-by "name" field @model.allFields) as |attr|}}
{{#if attr.options.detailLinkTo}} {{#if attr.options.detailLinkTo}}
<InfoTableRow <InfoTableRow
@label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}} @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> <LinkTo @route={{attr.options.detailLinkTo}} @model={{get @model attr.name}}>{{get @model attr.name}}</LinkTo>
</InfoTableRow> </InfoTableRow>

View File

@@ -14,6 +14,7 @@ import PkiActionModel from 'vault/models/pki/action';
import PkiUrlsModel from 'vault/models/pki/urls'; import PkiUrlsModel from 'vault/models/pki/urls';
import FlashMessageService from 'ember-cli-flash/services/flash-messages'; import FlashMessageService from 'ember-cli-flash/services/flash-messages';
import errorMessage from 'vault/utils/error-message'; import errorMessage from 'vault/utils/error-message';
import { parsedParameters } from 'vault/utils/parse-pki-cert-oids';
interface AdapterOptions { interface AdapterOptions {
actionType: string; actionType: string;
@@ -26,6 +27,7 @@ interface Args {
onComplete: CallableFunction; onComplete: CallableFunction;
onSave?: CallableFunction; onSave?: CallableFunction;
adapterOptions: AdapterOptions; adapterOptions: AdapterOptions;
hideAlertBanner: boolean;
} }
/** /**
@@ -71,13 +73,13 @@ export default class PkiGenerateRootComponent extends Component<Args> {
get returnedFields() { get returnedFields() {
return [ return [
'certificate', 'certificate',
'expiration',
'issuerId', 'issuerId',
'issuerName', 'issuerName',
'issuingCa', 'issuingCa',
'keyId',
'keyName', 'keyName',
'keyId',
'serialNumber', '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 // excludeCnFromSans and serialNumber are present in default fields for generate-csr -- only include for other types
if (this.args.model.actionType !== 'generate-csr') { 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; return groups;
} }

View File

@@ -1,5 +1,4 @@
{{#if this.importedResponse}} {{#if this.importedResponse}}
<Toolbar />
<div class="is-flex-start has-top-margin-xs"> <div class="is-flex-start has-top-margin-xs">
<div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs"> <div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs">
<h2> <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', 'province',
'streetAddress', 'streetAddress',
'postalCode', '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('edit');
this.route('sign'); this.route('sign');
this.route('cross-sign'); this.route('cross-sign');
this.route('rotate-root');
}); });
}); });
this.route('certificates', function () { this.route('certificates', function () {

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: MPL-2.0 * SPDX-License-Identifier: MPL-2.0
*/ */
import PkiIssuersListRoute from '../index'; import PkiIssuersListRoute from '.';
// Single issuer index route extends issuers list route // Single issuer index route extends issuers list route
export default class PkiIssuerIndexRoute extends PkiIssuersListRoute { export default class PkiIssuerIndexRoute extends PkiIssuersListRoute {

View File

@@ -3,11 +3,11 @@
* SPDX-License-Identifier: MPL-2.0 * SPDX-License-Identifier: MPL-2.0
*/ */
import PkiIssuerIndexRoute from './index'; import PkiIssuerRoute from '../issuer';
import { withConfirmLeave } from 'core/decorators/confirm-leave'; import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave() @withConfirmLeave()
export default class PkiIssuerCrossSignRoute extends PkiIssuerIndexRoute { export default class PkiIssuerCrossSignRoute extends PkiIssuerRoute {
setupController(controller, resolvedModel) { setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel); super.setupController(controller, resolvedModel);
controller.breadcrumbs.push( controller.breadcrumbs.push(

View File

@@ -3,17 +3,23 @@
* SPDX-License-Identifier: MPL-2.0 * 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 { setupController(controller, resolvedModel) {
// Details route gets issuer data from PkiIssuerIndexRoute
async setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel); super.setupController(controller, resolvedModel);
controller.breadcrumbs.push({ label: resolvedModel.id }); controller.breadcrumbs.push({ label: resolvedModel.issuer.id });
const pem = await this.fetchCertByFormat(resolvedModel.id, 'pem');
const der = await this.fetchCertByFormat(resolvedModel.id, 'der');
controller.pem = pem;
controller.der = der;
} }
/** /**
@@ -33,4 +39,9 @@ export default class PkiIssuerDetailsRoute extends PkiIssuerIndexRoute {
return null; 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'; import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave() @withConfirmLeave()
export default class PkiIssuerDetailRoute extends Route { export default class PkiIssuerEditRoute extends Route {
@service store; @service store;
@service secretMountPath; @service secretMountPath;
@service pathHelp; @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> <PopupMenu>
<nav class="menu" aria-label="issuer config options"> <nav class="menu" aria-label="issuer config options">
<ul class="menu-list"> <ul class="menu-list">
<li> <li data-test-popup-menu-details>
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}> <LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
Details Details
</LinkTo> </LinkTo>

View File

@@ -11,11 +11,12 @@
</PageHeader> </PageHeader>
<Page::PkiIssuerDetails <Page::PkiIssuerDetails
@issuer={{this.model}} @issuer={{this.model.issuer}}
@pem={{this.pem}} @pem={{this.model.pem}}
@der={{this.der}} @der={{this.model.der}}
@canRotate={{this.model.canRotateIssuer}} @isRotatable={{this.model.isRotatable}}
@canCrossSign={{this.model.canCrossSign}} @canRotate={{this.model.issuer.canRotateIssuer}}
@canSignIntermediate={{this.model.canSignIntermediate}} @canCrossSign={{this.model.issuer.canCrossSign}}
@canConfigure={{this.model.canConfigure}} @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"}}
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -363,5 +363,40 @@ module('Acceptance | pki workflow', function (hooks) {
.exists({ count: 3 }, 'Renders 3 info table items under URLs group'); .exists({ count: 3 }, 'Renders 3 info table items under URLs group');
assert.dom(SELECTORS.issuerDetails.groupTitle).exists({ count: 1 }, 'only 1 group title rendered'); 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]', signIntermediate: '[data-test-pki-issuer-sign-int]',
download: '[data-test-issuer-download]', download: '[data-test-issuer-download]',
configure: '[data-test-pki-issuer-configure]', 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}"]`, valueByName: (name) => `[data-test-value-div="${name}"]`,
}; };

View File

@@ -50,6 +50,8 @@ export const SELECTORS = {
generateIssuerDropdown: '[data-test-issuer-generate-dropdown]', generateIssuerDropdown: '[data-test-issuer-generate-dropdown]',
generateIssuerRoot: '[data-test-generate-issuer="root"]', generateIssuerRoot: '[data-test-generate-issuer="root"]',
generateIssuerIntermediate: '[data-test-generate-issuer="intermediate"]', generateIssuerIntermediate: '[data-test-generate-issuer="intermediate"]',
issuerPopupMenu: '[data-test-popup-menu-trigger]',
issuerPopupDetails: '[data-test-popup-menu-details] a',
issuerDetails: { issuerDetails: {
title: '[data-test-pki-issuer-page-title]', title: '[data-test-pki-issuer-page-title]',
...ISSUERDETAILS, ...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 { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/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); setupRenderingTest(hooks);
setupEngine(hooks, 'pki'); setupEngine(hooks, 'pki');
setupMirage(hooks); setupMirage(hooks);
@@ -47,7 +47,7 @@ module('Integration | Component | pki generate csr', function (hooks) {
'commonName', 'commonName',
'excludeCnFromSans', 'excludeCnFromSans',
'format', 'format',
'serialNumber', 'subjectSerialNumber',
'addBasicConstraints', 'addBasicConstraints',
]; ];
fields.forEach((key) => { fields.forEach((key) => {

View File

@@ -66,7 +66,7 @@ module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
await click(selectors.sanOptions); 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`); assert.dom('[data-test-field]').exists({ count: 6 }, `Correct number of fields render`);
fields.forEach((key) => { fields.forEach((key) => {
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for generate-root actionType`); 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) { 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.rotateRoot).doesNotExist();
assert.dom(SELECTORS.crossSign).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) { test('it renders toolbar actions depending on passed capabilities', async function (assert) {
this.set('isRotatable', true);
this.set('canRotate', true); this.set('canRotate', true);
this.set('canCrossSign', true); this.set('canCrossSign', true);
this.set('canSignIntermediate', true); this.set('canSignIntermediate', true);
this.set('canConfigure', true); this.set('canConfigure', true);
await render( 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 this.context
); );
// Add back when rotate root capability is added assert.dom(SELECTORS.rotateRoot).hasText('Rotate this root');
// assert.dom(SELECTORS.rotateRoot).hasText('Rotate this root'); assert.dom(SELECTORS.crossSign).hasText('Cross-sign issuers');
assert.dom(SELECTORS.crossSign).hasText('Cross-sign Issuer');
assert.dom(SELECTORS.signIntermediate).hasText('Sign Intermediate'); assert.dom(SELECTORS.signIntermediate).hasText('Sign Intermediate');
assert.dom(SELECTORS.download).hasText('Download'); assert.dom(SELECTORS.download).hasText('Download');
assert.dom(SELECTORS.configure).hasText('Configure'); 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', permitted_dns_domains: 'dnsname1.com, dsnname2.com',
postal_code: '123456', postal_code: '123456',
province: 'Champagne', province: 'Champagne',
serial_number: 'cereal1292', subject_serial_number: 'cereal1292',
signature_bits: '256', signature_bits: '256',
street_address: '234 sesame', street_address: '234 sesame',
ttl: '768h', ttl: '768h',
@@ -141,7 +141,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
ou: null, ou: null,
postal_code: null, postal_code: null,
province: null, province: null,
serial_number: null, subject_serial_number: null,
street_address: null, street_address: null,
uri_sans: null, uri_sans: null,
}, },
@@ -164,7 +164,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
ou: 'Finance', ou: 'Finance',
postal_code: '123456', postal_code: '123456',
province: 'Champagne', province: 'Champagne',
serial_number: 'cereal1292', subject_serial_number: 'cereal1292',
street_address: '234 sesame', street_address: '234 sesame',
}, },
}, },
@@ -251,7 +251,7 @@ module('Integration | Util | parse pki certificate', function (hooks) {
parsing_errors: [{}, {}], parsing_errors: [{}, {}],
postal_code: null, postal_code: null,
province: null, province: null,
serial_number: null, subject_serial_number: null,
signature_bits: '256', signature_bits: '256',
street_address: null, street_address: null,
ttl: '87600h', ttl: '87600h',

View File

@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MPL-2.0 * SPDX-License-Identifier: MPL-2.0
*/ */
import Application from 'vault/adapters/application';
import Adapter from 'ember-data/adapter'; import Adapter from 'ember-data/adapter';
import ModelRegistry from 'ember-data/types/registries/model'; 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. * Catch-all for ember-data.
*/ */
export default interface AdapterRegistry { export default interface AdapterRegistry {
application: Application;
[key: keyof ModelRegistry]: Adapter; [key: keyof ModelRegistry]: Adapter;
} }

View File

@@ -10,6 +10,7 @@ export default class PkiIssuerModel extends Model {
get useOpenAPI(): boolean; get useOpenAPI(): boolean;
get backend(): string; get backend(): string;
get issuerRef(): string; get issuerRef(): string;
certificate: string;
issuerId: string; issuerId: string;
issuerName: string; issuerName: string;
keyId: string; keyId: string;
@@ -31,7 +32,7 @@ export default class PkiIssuerModel extends Model {
importedIssuers: string[]; importedIssuers: string[];
importedKeys: string[]; importedKeys: string[];
formFields: FormField[]; formFields: FormField[];
formFieldGroups: FormFieldGroups; formFieldGroups: FormFieldGroups[];
allFields: FormField[]; allFields: FormField[];
get canRotateIssuer(): boolean; get canRotateIssuer(): boolean;
get canCrossSign(): 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;