Skip to content

Commit

Permalink
UI: PKI Issuer details (#18495)
Browse files Browse the repository at this point in the history
  • Loading branch information
hashishaw committed Dec 21, 2022
1 parent c2620e1 commit 2267b04
Show file tree
Hide file tree
Showing 13 changed files with 324 additions and 56 deletions.
11 changes: 8 additions & 3 deletions ui/app/adapters/pki/issuer.js
Expand Up @@ -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));
}
Expand Down
100 changes: 58 additions & 42 deletions ui/app/models/pki/issuer.js
@@ -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.',
},
],
};
@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('string') issuerId;
@attr('string', { displayType: 'masked' }) certificate;
@attr('string', { displayType: 'masked', label: 'CA Chain' }) caChain;
@attr('date', {
label: 'Issue date',
})
notValidBefore;

@withModelValidations(validations)
export default class PkiIssuerModel extends Model {
@attr('string', { readOnly: true }) backend;
@attr('string', {
label: 'Issuer name',
fieldValue: 'id',
label: 'Default key ID',
})
keyId;

@attr({
label: 'Subject Alternative Names',
})
name;
uriSans;

get useOpenAPI() {
return true;
@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
);
}
getHelpUrl(backend) {
return `/v1/${backend}/issuer/example?help=1`;
get canCrossSign() {
return this.crossSignPath.get('canUpdate') !== false;
}

@attr('boolean') isDefault;
@attr('string') issuerName;

// 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;
get canSignIntermediate() {
return this.signIntermediate.get('canUpdate') !== false;
}
get canConfigure() {
return this.issuerPath.get('canUpdate') !== false;
}
}
21 changes: 19 additions & 2 deletions ui/app/serializers/pki/issuer.js
@@ -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;
Expand Down
77 changes: 77 additions & 0 deletions ui/lib/pki/addon/components/page/pki-issuer-details.hbs
@@ -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>
4 changes: 1 addition & 3 deletions ui/lib/pki/addon/routes/issuers/index.js
@@ -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;
Expand All @@ -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) => {
Expand Down
10 changes: 8 additions & 2 deletions ui/lib/pki/addon/routes/issuers/issuer/details.js
@@ -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 });
}
}
22 changes: 22 additions & 0 deletions ui/lib/pki/addon/routes/issuers/issuer/index.js
@@ -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' },
];
}
}
10 changes: 7 additions & 3 deletions ui/lib/pki/addon/templates/issuers/index.hbs
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
19 changes: 18 additions & 1 deletion ui/lib/pki/addon/templates/issuers/issuer/details.hbs
@@ -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}}
/>
30 changes: 30 additions & 0 deletions ui/tests/acceptance/pki/pki-engine-workflow-test.js
Expand Up @@ -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');
});
});
});
11 changes: 11 additions & 0 deletions ui/tests/helpers/pki/pki-issuer-details.js
@@ -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]',
};

0 comments on commit 2267b04

Please sign in to comment.