UI: PKI Issuer details (#18495)

This commit is contained in:
Chelsea Shaw 2022-12-21 10:30:24 -06:00 committed by GitHub
parent efd2799390
commit 6b3995dc3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 325 additions and 57 deletions

View File

@ -13,14 +13,19 @@ export default class PkiIssuerAdapter extends ApplicationAdapter {
}
urlForQuery(backend, id) {
let url = `${this.buildURL()}/${encodePath(backend)}/issuers`;
const baseUrl = `${this.buildURL()}/${encodePath(backend)}`;
if (id) {
url = url + '/' + encodePath(id);
return `${baseUrl}/issuer/${encodePath(id)}`;
} else {
return `${baseUrl}/issuers`;
}
return url;
}
query(store, type, query) {
return this.ajax(this.urlForQuery(query.backend), 'GET', this.optionsForQuery());
}
queryRecord(store, type, query) {
const { backend, id } = query;
return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
}

View File

@ -1,51 +1,67 @@
import Model, { attr } from '@ember-data/model';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
import PkiCertificateBaseModel from './certificate/base';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
};
@withModelValidations(validations)
export default class PkiIssuerModel extends Model {
@attr('string', { readOnly: true }) backend;
@attr('string', {
label: 'Issuer name',
fieldValue: 'id',
})
name;
get useOpenAPI() {
return true;
}
@withFormFields(null, [
{
default: [
'certificate',
'caChain',
'commonName',
'issuerName',
'notValidBefore',
'serialNumber',
'keyId',
'uriSans',
'notValidAfter',
],
},
{ 'Issuer URLs': ['issuingCertificates', 'crlDistributionPoints', 'ocspServers', 'deltaCrlUrls'] },
])
export default class PkiIssuerModel extends PkiCertificateBaseModel {
getHelpUrl(backend) {
return `/v1/${backend}/issuer/example?help=1`;
}
@attr('boolean') isDefault;
@attr('string') issuerName;
@attr('string') issuerId;
@attr('string', { displayType: 'masked' }) certificate;
@attr('string', { displayType: 'masked', label: 'CA Chain' }) caChain;
@attr('date', {
label: 'Issue date',
})
notValidBefore;
// Form Fields not hidden in toggle options
_attributeMeta = null;
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, [
'name',
'leafNotAfterBehavior',
'usage',
'manualChain',
'issuingCertifications',
'crlDistributionPoints',
'ocspServers',
'deltaCrlUrls', // new endpoint, mentioned in RFC, but need to confirm it's there.
]);
}
return this._attributeMeta;
@attr('string', {
label: 'Default key ID',
})
keyId;
@attr({
label: 'Subject Alternative Names',
})
uriSans;
@lazyCapabilities(apiPath`${'backend'}/issuer/${'issuerId'}`) issuerPath;
@lazyCapabilities(apiPath`${'backend'}/root/rotate/exported`) rotateExported;
@lazyCapabilities(apiPath`${'backend'}/root/rotate/internal`) rotateInternal;
@lazyCapabilities(apiPath`${'backend'}/root/rotate/existing`) rotateExisting;
@lazyCapabilities(apiPath`${'backend'}/intermediate/cross-sign`) crossSignPath;
@lazyCapabilities(apiPath`${'backend'}/issuer/${'issuerId'}/sign-intermediate`) signIntermediate;
get canRotateIssuer() {
return (
this.rotateExported.get('canUpdate') !== false ||
this.rotateExisting.get('canUpdate') !== false ||
this.rotateInternal.get('canUpdate') !== false
);
}
get canCrossSign() {
return this.crossSignPath.get('canUpdate') !== false;
}
get canSignIntermediate() {
return this.signIntermediate.get('canUpdate') !== false;
}
get canConfigure() {
return this.issuerPath.get('canUpdate') !== false;
}
}

View File

@ -1,11 +1,28 @@
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
import ApplicationSerializer from '../application';
export default class PkiIssuerSerializer extends ApplicationSerializer {
// rehydrate each issuer model so all model attributes are accessible from the LIST response
primaryKey = 'issuer_id';
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);
const data = { issuer_ref: payload.issuer_id, ...payload.data, ...parsedCert };
const json = super.normalizeResponse(store, primaryModelClass, { ...payload, data }, id, requestType);
return json;
}
return super.normalizeResponse(...arguments);
}
// rehydrate each issuers model so all model attributes are accessible from the LIST response
normalizeItems(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => ({ id: key, ...payload.data.key_info[key] }));
return payload.data.keys.map((issuer_id) => ({
issuer_id,
...payload.data.key_info[issuer_id],
}));
}
Object.assign(payload, payload.data);
delete payload.data;

View File

@ -0,0 +1,77 @@
<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
>
Cross-sign Issuer
</ToolbarLink>
{{/if}}
{{#if @canSignIntermediate}}
<ToolbarLink @route="issuers.issuer.sign" @type="pen-tool" @issuer={{@issuer.id}} data-test-pki-issuer-sign-int>
Sign Intermediate
</ToolbarLink>
{{/if}}
<DownloadButton
class="toolbar-link"
@filename={{@issuer.id}}
@data={{@issuer.certificate}}
@extension="pem"
data-test-issuer-download
>
Download
<Chevron @direction="down" @isButton={{true}} />
</DownloadButton>
{{#if @canConfigure}}
<ToolbarLink @route="issuers.issuer.edit" @issuer={{@issuer.id}} data-test-pki-issuer-configure>
Configure
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<main data-test-issuer-details>
{{#each @issuer.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
<div class="box is-sideless is-fullwidth is-shadowless" data-test-details-group={{group}}>
{{#if (not-eq group "default")}}
<h2 class="title is-5 has-margin-top" data-test-group-title>
{{group}}
</h2>
{{/if}}
{{#each fields as |attr|}}
{{#if (eq attr.options.displayType "masked")}}
<InfoTableRow @label={{or attr.options.label (humanize (dasherize attr.name))}} @value={{get @issuer attr.name}}>
<MaskedInput
@name={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{get @issuer attr.name}}
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
{{else if (eq attr.name "keyId")}}
<InfoTableRow @label={{or attr.options.label (humanize (dasherize attr.name))}} @value={{get @issuer attr.name}}>
<LinkTo @route="keys.key" @model={{get @issuer attr.name}}>{{get @issuer attr.name}}</LinkTo>
</InfoTableRow>
{{else}}
<InfoTableRow
@label={{or attr.options.label (humanize (dasherize attr.name))}}
@value={{get @issuer attr.name}}
@formatDate={{if (eq attr.type "date") "MMM d yyyy HH:mm:ss a zzzz"}}
@alwaysRender={{true}}
/>
{{/if}}
{{/each}}
</div>
{{/each-in}}
{{/each}}
</main>

View File

@ -1,7 +1,7 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PkiIssuersIndexRoute extends Route {
export default class PkiIssuersListRoute extends Route {
@service store;
@service secretMountPath;
@service pathHelp;
@ -12,8 +12,6 @@ export default class PkiIssuersIndexRoute extends Route {
}
model() {
// the pathHelp service is needed for adding openAPI to the model
this.pathHelp.getNewModel('pki/issuer', 'pki');
return this.store
.query('pki/issuer', { backend: this.secretMountPath.currentPath })
.then((issuersModel) => {

View File

@ -1,3 +1,9 @@
import Route from '@ember/routing/route';
import PkiIssuerIndexRoute from './index';
export default class PkiIssuerDetailsRoute extends Route {}
export default class PkiIssuerDetailsRoute extends PkiIssuerIndexRoute {
// Details route gets issuer data from PkiIssuerIndexRoute
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs.push({ label: resolvedModel.id });
}
}

View File

@ -0,0 +1,22 @@
import PkiIssuersListRoute from '../index';
// Single issuer index route extends issuers list route
export default class PkiIssuerIndexRoute extends PkiIssuersListRoute {
model() {
const { issuer_ref } = this.paramsFor('issuers/issuer');
return this.store.queryRecord('pki/issuer', {
backend: this.secretMountPath.currentPath,
id: issuer_ref,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const backend = this.secretMountPath.currentPath || 'pki';
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: backend, route: 'overview' },
{ label: 'issuers', route: 'issuers.index' },
];
}
}

View File

@ -39,7 +39,11 @@
</Toolbar>
{{#if this.model.issuersModel.length}}
{{#each this.model.issuersModel as |pkiIssuer|}}
<LinkedBlock class="list-item-row" @params={{array "roles.role.details" pkiIssuer.id}} @linkPrefix={{this.mountPoint}}>
<LinkedBlock
class="list-item-row"
@params={{array "issuers.issuer.details" pkiIssuer.id}}
@linkPrefix={{this.mountPoint}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
@ -63,12 +67,12 @@
<nav class="menu" aria-label="issuer config options">
<ul class="menu-list">
<li>
<LinkTo @route="roles.role.details" @model={{pkiIssuer.id}}>
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
Details
</LinkTo>
</li>
<li>
<LinkTo @route="roles.role.edit" @model={{pkiIssuer.id}}>
<LinkTo @route="issuers.issuer.edit" @model={{pkiIssuer.id}}>
Edit
</LinkTo>
</li>

View File

@ -1 +1,18 @@
route: issuers.issuer.details
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-pki-issuer-page-title>
<Icon @name="file-text" @size="24" class="has-text-grey-light" />
View issuer certificate
</h1>
</p.levelLeft>
</PageHeader>
<Page::PkiIssuerDetails
@issuer={{this.model}}
@canRotate={{this.model.canRotateIssuer}}
@canCrossSign={{this.model.canCrossSign}}
@canSignIntermediate={{this.model.canSignIntermediate}}
@canConfigure={{this.model.canConfigure}}
/>

View File

@ -230,4 +230,34 @@ module('Acceptance | pki workflow', function (hooks) {
assert.dom(SELECTORS.pageTitle).hasText(`PKI Role ${roleName}`);
});
});
module('issuers', function (hooks) {
hooks.beforeEach(async function () {
await authPage.login();
// Configure engine with a default issuer
await runCommands([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]);
await logout.visit();
});
test('details view renders correct number of info items', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(SELECTORS.issuersTab).exists('Issuers tab is present');
await click(SELECTORS.issuersTab);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/issuers`);
assert.dom('.linked-block').exists({ count: 1 }, 'One issuer is in list');
await click('.linked-block');
assert.ok(
currentURL().match(`/vault/secrets/${this.mountPath}/pki/issuers/.+/details`),
`/vault/secrets/${this.mountPath}/pki/issuers/my-issuer/details`
);
assert.dom(SELECTORS.issuerDetails.title).hasText('View issuer certificate');
assert
.dom(`${SELECTORS.issuerDetails.defaultGroup} ${SELECTORS.issuerDetails.row}`)
.exists({ count: 9 }, 'Renders 9 info table items under default group');
assert
.dom(`${SELECTORS.issuerDetails.urlsGroup} ${SELECTORS.issuerDetails.row}`)
.exists({ count: 4 }, 'Renders 4 info table items under URLs group');
assert.dom(SELECTORS.issuerDetails.groupTitle).exists({ count: 1 }, 'only 1 group title rendered');
});
});
});

View File

@ -0,0 +1,11 @@
export const SELECTORS = {
defaultGroup: '[data-test-details-group="default"]',
urlsGroup: '[data-test-details-group="Issuer URLs"]',
groupTitle: '[data-test-group-title]',
row: '[data-test-component="info-table-row"]',
rotateRoot: '[data-test-pki-issuer-rotate-root]',
crossSign: '[data-test-pki-issuer-cross-sign]',
signIntermediate: '[data-test-pki-issuer-sign-int]',
download: '[data-test-issuer-download]',
configure: '[data-test-pki-issuer-configure]',
};

View File

@ -1,5 +1,7 @@
import { SELECTORS as ROLEFORM } from './pki-role-form';
import { SELECTORS as GENERATECERT } from './pki-role-generate';
import { SELECTORS as ISSUERDETAILS } from './pki-issuer-details';
export const SELECTORS = {
breadcrumbContainer: '[data-test-breadcrumbs]',
breadcrumbs: '[data-test-breadcrumbs] li',
@ -23,4 +25,8 @@ export const SELECTORS = {
generateCertForm: {
...GENERATECERT,
},
issuerDetails: {
title: '[data-test-pki-issuer-page-title]',
...ISSUERDETAILS,
},
};

View File

@ -0,0 +1,59 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/pki-issuer-details';
module('Integration | Component | page/pki-issuer-details', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
hooks.beforeEach(async function () {
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.issuer = this.store.createRecord('pki/issuer', { issuerId: 'abcd-efgh' });
});
test('it renders with correct toolbar by default', async function (assert) {
await render(hbs`<Page::PkiIssuerDetails @issuer={{this.issuer}} />`, this.context);
assert.dom(SELECTORS.rotateRoot).doesNotExist();
assert.dom(SELECTORS.crossSign).doesNotExist();
assert.dom(SELECTORS.signIntermediate).doesNotExist();
assert.dom(SELECTORS.download).hasText('Download');
assert.dom(SELECTORS.configure).doesNotExist();
});
test('it renders toolbar actions depending on passed capabilities', async function (assert) {
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}} />`,
this.context
);
assert.dom(SELECTORS.rotateRoot).hasText('Rotate this root');
assert.dom(SELECTORS.crossSign).hasText('Cross-sign Issuer');
assert.dom(SELECTORS.signIntermediate).hasText('Sign Intermediate');
assert.dom(SELECTORS.download).hasText('Download');
assert.dom(SELECTORS.configure).hasText('Configure');
this.set('canRotate', false);
this.set('canCrossSign', false);
this.set('canSignIntermediate', false);
this.set('canConfigure', false);
await settled();
assert.dom(SELECTORS.rotateRoot).doesNotExist();
assert.dom(SELECTORS.crossSign).doesNotExist();
assert.dom(SELECTORS.signIntermediate).doesNotExist();
assert.dom(SELECTORS.download).hasText('Download');
assert.dom(SELECTORS.configure).doesNotExist();
});
});