PKI Certificate Details (#18737)

* adds pki certificate details page component

* adds tests for pki base adapter

* adds more comments

* updates remaining pki/certificate model references to pki/certificate/base
This commit is contained in:
Jordan Reimer
2023-01-17 17:52:47 -07:00
committed by GitHub
parent 72e515b43f
commit 9d46671659
19 changed files with 401 additions and 172 deletions

View File

@@ -1,3 +0,0 @@
import PkiCertAdapter from './cert';
export default class PkiCertificateAdapter extends PkiCertAdapter {}

View File

@@ -4,10 +4,46 @@ import ApplicationAdapter from '../../application';
export default class PkiCertificateBaseAdapter extends ApplicationAdapter {
namespace = 'v1';
deleteRecord(store, type, snapshot) {
getURL(backend, id) {
const uri = `${this.buildURL()}/${encodePath(backend)}`;
return id ? `${uri}/cert/${id}` : `${uri}/certs`;
}
fetchByQuery(query) {
const { backend, id } = query;
const data = !id ? { list: true } : {};
return this.ajax(this.getURL(backend, id), 'GET', { data }).then((resp) => {
resp.data.backend = backend;
if (id) {
resp.data.id = id;
resp.data.serial_number = id;
}
return resp;
});
}
query(store, type, query) {
return this.fetchByQuery(query);
}
queryRecord(store, type, query) {
return this.fetchByQuery(query);
}
// the only way to update a record is by revoking it which will set the revocationTime property
updateRecord(store, type, snapshot) {
const { backend, serialNumber, certificate } = snapshot.record;
// Revoke certificate requires either serial_number or certificate
const data = serialNumber ? { serial_number: serialNumber } : { certificate };
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/revoke`, 'POST', { data });
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/revoke`, 'POST', { data }).then(
(response) => {
return {
data: {
...this.serialize(snapshot),
...response.data,
},
};
}
);
}
}

View File

@@ -11,7 +11,15 @@ import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
* attributes and adapter methods.
*/
const certDisplayFields = ['certificate', 'commonName', 'serialNumber', 'notValidAfter', 'notValidBefore'];
const certDisplayFields = [
'certificate',
'commonName',
'revocationTime',
'issueDate',
'serialNumber',
'notValidBefore',
'notValidAfter',
];
@withFormFields(certDisplayFields)
export default class PkiCertificateBaseModel extends Model {
@@ -31,16 +39,18 @@ export default class PkiCertificateBaseModel extends Model {
// Attrs that come back from API POST request
@attr() caChain;
@attr('string') certificate;
@attr('string', { masked: true }) certificate;
@attr('number') expiration;
@attr('number', { formatDate: true }) revocationTime;
@attr('string') issuingCa;
@attr('string') privateKey;
@attr('string') privateKeyType;
@attr('string') serialNumber;
// Parsed from cert in serializer
@attr('date') notValidAfter;
@attr('date') notValidBefore;
@attr('number', { formatDate: true }) issueDate;
@attr('number', { formatDate: true }) notValidAfter;
@attr('number', { formatDate: true }) notValidBefore;
// For importing
@attr('string') pemBundle;

View File

@@ -0,0 +1,31 @@
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
import ApplicationSerializer from '../../application';
export default class PkiCertificateBaseSerializer extends ApplicationSerializer {
primaryKey = 'serial_number';
attrs = {
role: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.data.certificate) {
// Parse certificate back from the API and add to payload
const parsedCert = parseCertificate(payload.data.certificate);
// convert issueDate to same format as other date values
// this can be moved into the parseCertificate helper once the old pki implementation is removed
if (parsedCert.issue_date) {
parsedCert.issue_date = parsedCert.issue_date.valueOf();
}
const json = super.normalizeResponse(
store,
primaryModelClass,
{ ...payload, ...parsedCert },
id,
requestType
);
return json;
}
return super.normalizeResponse(...arguments);
}
}

View File

@@ -1,25 +1,3 @@
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
import ApplicationSerializer from '../../application';
import PkiCertificateBaseSerializer from './base';
export default class PkiCertificateGenerateSerializer extends ApplicationSerializer {
primaryKey = 'serial_number';
attrs = {
role: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'createRecord' && payload.data.certificate) {
// Parse certificate back from the API and add to payload
const parsedCert = parseCertificate(payload.data.certificate);
const json = super.normalizeResponse(
store,
primaryModelClass,
{ ...payload, ...parsedCert },
id,
requestType
);
return json;
}
return super.normalizeResponse(...arguments);
}
}
export default class PkiCertificateGenerateSerializer extends PkiCertificateBaseSerializer {}

View File

@@ -1,25 +1,3 @@
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
import ApplicationSerializer from '../../application';
import PkiCertificateBaseSerializer from './base';
export default class PkiCertificateSignSerializer extends ApplicationSerializer {
primaryKey = 'serial_number';
attrs = {
type: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'createRecord' && payload.data.certificate) {
// Parse certificate back from the API and add to payload
const parsedCert = parseCertificate(payload.data.certificate);
const json = super.normalizeResponse(
store,
primaryModelClass,
{ ...payload, ...parsedCert },
id,
requestType
);
return json;
}
return super.normalizeResponse(...arguments);
}
}
export default class PkiCertificateGenerateSerializer extends PkiCertificateBaseSerializer {}

View File

@@ -0,0 +1,55 @@
<Toolbar>
<ToolbarActions>
<button type="button" class="toolbar-link" {{on "click" this.downloadCert}} data-test-pki-cert-download-button>
Download
<Chevron @direction="down" @isButton={{true}} />
</button>
{{#if @model.canRevoke}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{fn (perform this.revoke)}}
@confirmTitle="Revoke certificate?"
@confirmButtonText="Revoke"
data-test-pki-cert-revoke-button
>
Revoke certificate
</ConfirmAction>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @model.formFields as |field|}}
{{#if (eq field.name "certificate")}}
<InfoTableRow @label="Certificate">
<MaskedInput @value={{@model.certificate}} @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else if (eq field.name "serialNumber")}}
<InfoTableRow @label="Serial number">
<code class="has-text-black">{{@model.serialNumber}}</code>
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{capitalize (humanize (dasherize field.name))}}
{{! formatDate fields can be 0 which will cause them to always render -- pass null instead }}
@value={{or (get @model field.name) null}}
@formatDate={{if field.options.formatDate "MMM dd yyyy hh:mm:ss a"}}
@alwaysRender={{false}}
/>
{{/if}}
{{/each}}
{{#if @onBack}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="button"
class="button"
disabled={{this.revoke.isRunning}}
{{on "click" @onBack}}
data-test-pki-cert-details-back
>
Back
</button>
</div>
</div>
{{/if}}

View File

@@ -0,0 +1,48 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
import FlashMessageService from 'vault/services/flash-messages';
import DownloadService from 'vault/services/download';
import PkiCertificateBaseModel from 'vault/models/pki/certificate/base';
interface Args {
model: PkiCertificateBaseModel;
onRevoke?: CallableFunction;
onBack?: CallableFunction;
}
export default class PkiCertificateDetailsComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly download: DownloadService;
@action
downloadCert() {
try {
const formattedSerial = this.args.model.serialNumber?.replace(/(\s|:)+/g, '-');
this.download.pem(formattedSerial, this.args.model.certificate);
this.flashMessages.info('Your download has started.');
} catch (err) {
this.flashMessages.danger(errorMessage(err, 'Unable to prepare certificate for download.'));
}
}
@task
@waitFor
*revoke() {
try {
// the adapter updateRecord method calls the revoke endpoint since it is the only way to update a cert
yield this.args.model.save();
this.flashMessages.success('The certificate has been revoked.');
if (this.args.onRevoke) {
this.args.onRevoke();
}
} catch (error) {
this.flashMessages.danger(
errorMessage(error, 'Could not revoke certificate. See Vault logs for details.')
);
}
}
}

View File

@@ -60,7 +60,7 @@
<SearchSelect
class="is-flex-1"
@selectLimit="1"
@models={{array "pki/certificate"}}
@models={{array "pki/certificate/base"}}
@backend={{@engine.id}}
@placeholder="33:a3:..."
@disallowNewItems={{true}}

View File

@@ -1,50 +1,5 @@
{{#if @model.serialNumber}}
<Toolbar>
<ToolbarActions>
<button type="button" class="toolbar-link" {{on "click" this.downloadCert}} data-test-pki-cert-download-button>
Download
<Chevron @direction="down" @isButton={{true}} />
</button>
{{#if @model.canRevoke}}
<button
type="button"
class="toolbar-link"
{{on "click" (perform this.revoke)}}
disabled={{this.revoke.isRunning}}
data-test-pki-cert-revoke-button
>
Revoke certificate
<Chevron @direction="right" @isButton={{true}} />
</button>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @model.formFields as |attr|}}
{{#if (eq attr.name "certificate")}}
<InfoTableRow @label="Certificate" @value={{@model.certificate}}>
<MaskedInput @value={{@model.certificate}} @name="Certificate" @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{get @model attr.name}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy HH:mm:ss a zzzz"}}
/>
{{/if}}
{{/each}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-pki-generate-back
>
Back
</button>
</div>
</div>
<Page::PkiCertificateDetails @model={{@model}} @onRevoke={{this.cancel}} @onBack={{this.cancel}} />
{{else}}
<form {{on "submit" (perform this.save)}} data-test-pki-generate-cert-form>
<div class="box is-bottomless is-fullwidth is-marginless">

View File

@@ -24,10 +24,6 @@ export default class PkiRoleGenerate extends Component<Args> {
@tracked errorBanner = '';
transitionToRole() {
this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.details');
}
get verb() {
return this.args.type === 'sign' ? 'sign' : 'generate';
}
@@ -45,29 +41,8 @@ export default class PkiRoleGenerate extends Component<Args> {
}
}
@task
*revoke() {
try {
yield this.args.model.destroyRecord();
this.flashMessages.success('The certificate has been revoked.');
this.transitionToRole();
} catch (err) {
this.errorBanner = errorMessage(err, 'Could not revoke certificate. See Vault logs for details.');
}
}
@action downloadCert() {
try {
const formattedSerial = this.args.model.serialNumber?.replace(/(\s|:)+/g, '-');
this.download.pem(formattedSerial, this.args.model.certificate);
this.flashMessages.info('Your download has started.');
} catch (err) {
this.flashMessages.danger(errorMessage(err, 'Unable to prepare certificate for download.'));
}
}
@action cancel() {
this.args.model.unloadRecord();
this.transitionToRole();
this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.details');
}
}

View File

@@ -1,3 +1,22 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PkiCertificateDetailsRoute extends Route {}
export default class PkiCertificateDetailsRoute extends Route {
@service store;
@service secretMountPath;
model() {
const id = this.paramsFor('certificates/certificate').serial;
return this.store.queryRecord('pki/certificate/base', { backend: this.secretMountPath.currentPath, id });
}
setupController(controller, model) {
super.setupController(controller, model);
const backend = this.secretMountPath.currentPath || 'pki';
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: backend, route: 'overview' },
{ label: 'certificates', route: 'certificates.index' },
{ label: model.id },
];
}
}

View File

@@ -5,16 +5,10 @@ import { hash } from 'rsvp';
export default class PkiCertificatesIndexRoute extends PkiOverviewRoute {
@service store;
@service secretMountPath;
@service pathHelp;
beforeModel() {
// Must call this promise before the model hook otherwise it doesn't add OpenApi to record.
return this.pathHelp.getNewModel('pki/certificate', this.secretMountPath.currentPath);
}
async fetchCertificates() {
try {
return await this.store.query('pki/certificate', { backend: this.secretMountPath.currentPath });
return await this.store.query('pki/certificate/base', { backend: this.secretMountPath.currentPath });
} catch (e) {
if (e.httpStatus === 404) {
return { parentModel: this.modelFor('certificates') };

View File

@@ -1 +1,12 @@
route: certificate.details
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="certificate" @size="24" class="has-text-grey-light" />
View certificate
</h1>
</p.levelLeft>
</PageHeader>
<Page::PkiCertificateDetails @model={{this.model}} />

View File

@@ -0,0 +1,116 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import sinon from 'sinon';
module('Integration | Component | pki | Page::PkiCertificateDetails', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
const downloadService = this.owner.lookup('service:download');
this.downloadSpy = sinon.stub(downloadService, 'pem');
const routerService = this.owner.lookup('service:router');
this.routerSpy = sinon.stub(routerService, 'transitionTo');
this.owner.lookup('service:secretMountPath').update('pki');
const store = this.owner.lookup('service:store');
const id = '4d:b6:ed:90:d6:b0:d4:bb:8e:5d:73:6a:6f:32:dc:8c:71:7c:db:5f';
store.pushPayload('pki/certificate/base', {
modelName: 'pki/certificate/base',
data: {
certificate: '-----BEGIN CERTIFICATE-----',
common_name: 'example.com Intermediate Authority',
issue_date: 1673540867000,
serial_number: id,
not_valid_after: 1831220897000,
not_valid_before: 1673540867000,
},
});
this.model = store.peekRecord('pki/certificate/base', id);
this.server.post('/sys/capabilities-self', () => ({
data: {
capabilities: ['root'],
'pki/revoke': ['root'],
},
}));
});
test('it should render actions and fields', async function (assert) {
assert.expect(6);
this.server.post('/pki/revoke', (schema, req) => {
const data = JSON.parse(req.requestBody);
assert.strictEqual(
data.serial_number,
this.model.serialNumber,
'Revoke request made with serial number'
);
return {
data: {
revocation_time: 1673972804,
revocation_time_rfc3339: '2023-01-17T16:26:44.960933411Z',
},
};
});
await render(hbs`<Page::PkiCertificateDetails @model={{this.model}} />`, { owner: this.engine });
assert
.dom('[data-test-component="info-table-row"]')
.exists({ count: 6 }, 'Correct number of fields render when certificate has not been revoked');
assert
.dom('[data-test-value-div="Certificate"] [data-test-masked-input]')
.exists('Masked input renders for certificate');
assert.dom('[data-test-value-div="Serial number"] code').exists('Serial number renders as monospace');
await click('[data-test-pki-cert-download-button]');
const { serialNumber, certificate } = this.model;
assert.ok(
this.downloadSpy.calledWith(serialNumber.replace(/(\s|:)+/g, '-'), certificate),
'Download pem method called with correct args'
);
await click('[data-test-confirm-action-trigger]');
await click('[data-test-confirm-button]');
assert.dom('[data-test-value-div="Revocation time"]').exists('Revocation time is displayed');
});
test('it should render back button', async function (assert) {
assert.expect(1);
this.cancel = () => assert.ok('onBack action is triggered');
await render(hbs`<Page::PkiCertificateDetails @model={{this.model}} @onBack={{this.cancel}} />`, {
owner: this.engine,
});
await click('[data-test-pki-cert-details-back]');
});
test('it should send action on revoke if provided', async function (assert) {
assert.expect(1);
this.server.post('/pki/revoke', () => ({
data: {
revocation_time: 1673972804,
revocation_time_rfc3339: '2023-01-17T16:26:44.960933411Z',
},
}));
this.revoked = () => assert.ok('onRevoke action is triggered');
await render(hbs`<Page::PkiCertificateDetails @model={{this.model}} @onRevoke={{this.revoked}} />`, {
owner: this.engine,
});
await click('[data-test-confirm-action-trigger]');
await click('[data-test-confirm-button]');
});
});

View File

@@ -21,8 +21,8 @@ module('Integration | Component | Page::PkiOverview', function (hooks) {
this.store.createRecord('pki/role', { name: 'role-0' });
this.store.createRecord('pki/role', { name: 'role-1' });
this.store.createRecord('pki/role', { name: 'role-2' });
this.store.createRecord('pki/certificate', { serialNumber: '22:2222:22222:2222' });
this.store.createRecord('pki/certificate', { serialNumber: '33:3333:33333:3333' });
this.store.createRecord('pki/certificate/base', { serialNumber: '22:2222:22222:2222' });
this.store.createRecord('pki/certificate/base', { serialNumber: '33:3333:33333:3333' });
this.issuers = this.store.peekAll('pki/issuer');
this.roles = this.store.peekAll('pki/role');

View File

@@ -0,0 +1,57 @@
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Adapter | pki/certificate/base', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.owner.lookup('service:secretMountPath').update('pki-test');
});
test('it should make request to correct endpoint on queryRecord', async function (assert) {
assert.expect(1);
this.server.get('/pki-test/cert/1234', () => {
assert.ok(true, 'Request made to correct endpoint on queryRecord');
return { data: {} };
});
await this.store.queryRecord('pki/certificate/base', { backend: 'pki-test', id: '1234' });
});
test('it should make request to correct endpoint on query', async function (assert) {
assert.expect(1);
this.server.get('/pki-test/certs', (schema, req) => {
assert.strictEqual(req.queryParams.list, 'true', 'Request made to correct endpoint on query');
return { data: { keys: [] } };
});
await this.store.query('pki/certificate/base', { backend: 'pki-test' });
});
test('it should make request to correct endpoint on update', async function (assert) {
assert.expect(1);
this.store.pushPayload('pki/certificate/base', {
modelName: 'pki/certificate/base',
data: {
serial_number: '1234',
},
});
this.server.post('pki-test/revoke', (schema, req) => {
assert.deepEqual(
JSON.parse(req.requestBody),
{ serial_number: '1234' },
'Request made to correct endpoint on update'
);
return { data: {} };
});
await this.store.peekRecord('pki/certificate/base', '1234').save();
});
});

View File

@@ -35,20 +35,4 @@ module('Unit | Adapter | pki/certificate/generate', function (hooks) {
const model = await this.store.createRecord('pki/certificate/generate', generateData);
await model.save();
});
test('it should make request to correct endpoint on delete', async function (assert) {
assert.expect(2);
this.store.pushPayload('pki/certificate/generate', {
modelName: 'pki/certificate/generate',
...this.data,
});
this.server.post(`${this.backend}/revoke`, (schema, req) => {
assert.deepEqual(JSON.parse(req.requestBody), { serial_number: 'my-serial-number' });
assert.ok(true, 'request made to correct endpoint on delete');
return { data: {} };
});
const model = await this.store.peekRecord('pki/certificate/generate', this.data.serial_number);
await model.destroyRecord();
});
});

View File

@@ -47,19 +47,4 @@ module('Unit | Adapter | pki/certificate/sign', function (hooks) {
await this.store.createRecord('pki/certificate/sign', generateData).save();
});
test('it should make request to correct endpoint on delete', async function (assert) {
assert.expect(2);
this.store.pushPayload('pki/certificate/sign', {
modelName: 'pki/certificate/sign',
...this.data,
});
this.server.post(`${this.backend}/revoke`, (schema, req) => {
assert.deepEqual(JSON.parse(req.requestBody), { serial_number: 'my-serial-number' });
assert.ok(true, 'request made to correct endpoint on delete');
return { data: {} };
});
await this.store.peekRecord('pki/certificate/sign', this.data.serial_number).destroyRecord();
});
});