UI: PKI Issuer details (#18495)
This commit is contained in:
parent
efd2799390
commit
6b3995dc3c
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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) => {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
/>
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]',
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue