UI: VAULT-8429 Update query and serializer so that it includes parsedCertificate (#20246)

Co-authored-by: Kianna Quach <kianna.quach@hashicorp.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Kianna 2023-04-28 08:05:12 -07:00 committed by GitHub
parent a592e3a023
commit 8cc9866f10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 226 additions and 53 deletions

View File

@ -5,6 +5,8 @@
import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { all } from 'rsvp';
import { verifyCertificates } from 'vault/utils/parse-pki-cert';
export default class PkiIssuerAdapter extends ApplicationAdapter {
namespace = 'v1';
@ -31,6 +33,18 @@ export default class PkiIssuerAdapter extends ApplicationAdapter {
}
}
async getIssuerMetadata(store, type, query, response, id) {
const keyInfo = response.data.key_info[id];
try {
const issuerRecord = await this.queryRecord(store, type, { id, backend: query.backend });
const { data } = issuerRecord;
const isRoot = await verifyCertificates(data.certificate, data.certificate);
return { ...keyInfo, ...data, isRoot };
} catch (e) {
return { ...keyInfo, issuer_id: id };
}
}
updateRecord(store, type, snapshot) {
const { issuerId } = snapshot.record;
const backend = this._getBackend(snapshot);
@ -40,11 +54,34 @@ export default class PkiIssuerAdapter extends ApplicationAdapter {
}
query(store, type, query) {
return this.ajax(this.urlForQuery(query.backend), 'GET', this.optionsForQuery());
const { backend, isListView } = query;
const url = this.urlForQuery(backend);
return this.ajax(url, 'GET', this.optionsForQuery()).then(async (res) => {
// To show issuer meta data tags, we have a flag called isListView and only want to
// grab each issuer data only if there are less than 10 issuers to avoid making too many requests
if (isListView && res.data.keys.length <= 10) {
const keyInfoArray = await all(
res.data.keys.map((id) => this.getIssuerMetadata(store, type, query, res, id))
);
const keyInfo = {};
res.data.keys.forEach((issuerId) => {
keyInfo[issuerId] = keyInfoArray.find((newKey) => newKey.issuer_id === issuerId);
});
res.data.key_info = keyInfo;
return res;
}
return res;
});
}
queryRecord(store, type, query) {
const { backend, id } = query;
return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id));
}

View File

@ -44,10 +44,12 @@ export default class PkiIssuerModel extends Model {
@attr('string', { label: 'Default key ID', detailLinkTo: 'keys.key.details' }) keyId;
@attr({ label: 'CA Chain', masked: true }) caChain;
@attr({ masked: true }) certificate;
@attr('string') serialNumber;
// parsed from certificate contents in serializer (see parse-pki-cert.js)
@attr parsedCertificate;
@attr('string') commonName;
@attr isRoot;
@attr subjectSerialNumber; // this is not the UUID serial number field randomly generated by Vault for leaf certificates
@attr({ label: 'Subject Alternative Names (SANs)' }) altNames;

View File

@ -16,6 +16,7 @@ export default class PkiIssuerSerializer extends ApplicationSerializer {
keyId: { serialize: false },
parsedCertificate: { serialize: false },
commonName: { serialize: false },
isRoot: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
@ -37,10 +38,12 @@ export default class PkiIssuerSerializer extends ApplicationSerializer {
normalizeItems(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((issuer_id) => ({
issuer_id,
...payload.data.key_info[issuer_id],
}));
return payload.data.keys.map((key) => {
return {
issuer_id: key,
...payload.data.key_info[key],
};
});
}
Object.assign(payload, payload.data);
delete payload.data;

View File

@ -0,0 +1,61 @@
{{#each @issuers as |pkiIssuer idx|}}
<LinkedBlock class="list-item-row" @params={{array "issuers.issuer.details" pkiIssuer.id}} @linkPrefix={{@mountPoint}}>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="certificate" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{pkiIssuer.issuerRef}}
{{#if pkiIssuer.issuerName}}
<span class="tag has-text-grey-dark">{{pkiIssuer.id}}</span>
{{/if}}
</span>
<div class="is-flex-row has-left-margin-l has-top-margin-xs">
{{#if pkiIssuer.isDefault}}
<span class="tag has-text-grey-dark" data-test-is-default={{idx}}>default issuer</span>
{{/if}}
{{#if (not (eq pkiIssuer.isRoot undefined))}}
<span class="tag has-text-grey-dark" data-test-is-root-tag={{idx}}>{{if
pkiIssuer.isRoot
"root"
"intermediate"
}}</span>
{{/if}}
{{#if pkiIssuer.serialNumber}}
<InfoTooltip>
Serial number
</InfoTooltip>
<span class="tag has-background-transparent" data-test-serial-number={{idx}}>{{pkiIssuer.serialNumber}}</span>
{{/if}}
{{#if pkiIssuer.keyId}}
<InfoTooltip>
Key ID
</InfoTooltip>
<span class="tag has-background-transparent" data-test-key-id={{idx}}>{{pkiIssuer.keyId}}</span>
{{/if}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu" aria-label="issuer config options">
<ul class="menu-list">
<li data-test-popup-menu-details>
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
Details
</LinkTo>
</li>
<li>
<LinkTo @route="issuers.issuer.edit" @model={{pkiIssuer.id}}>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}

View File

@ -13,7 +13,7 @@ export default class PkiIssuersListRoute extends Route {
model() {
return this.store
.query('pki/issuer', { backend: this.secretMountPath.currentPath })
.query('pki/issuer', { backend: this.secretMountPath.currentPath, isListView: true })
.then((issuersModel) => {
return { issuersModel, parentModel: this.modelFor('issuers') };
})

View File

@ -45,53 +45,9 @@
</BasicDropdown>
</ToolbarActions>
</Toolbar>
{{#if this.model.issuersModel.length}}
{{#each this.model.issuersModel as |pkiIssuer|}}
<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>
<Icon @name="certificate" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{pkiIssuer.issuerRef}}
</span>
<div class="is-flex-row has-left-margin-l has-top-margin-xs">
{{#if pkiIssuer.isDefault}}
<span class="tag has-text-grey-dark">default issuer</span>
{{/if}}
{{#if pkiIssuer.issuerName}}
<span class="tag has-text-grey-dark">{{pkiIssuer.id}}</span>
{{/if}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu" aria-label="issuer config options">
<ul class="menu-list">
<li data-test-popup-menu-details>
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
Details
</LinkTo>
</li>
<li>
<LinkTo @route="issuers.issuer.edit" @model={{pkiIssuer.id}}>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}
<Page::PkiIssuerList @issuers={{this.model.issuersModel}} @mountPoint={{this.mountPoint}} />
{{else}}
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<LinkTo @route="configuration.create">

View File

@ -340,9 +340,48 @@ module('Acceptance | pki workflow', 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 runCommands([
`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test" name="Hashicorp Test"`,
]);
await logout.visit();
});
test('lists the correct issuer metadata info', async function (assert) {
assert.expect(6);
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');
assert.dom('[data-test-is-root-tag="0"]').hasText('root');
assert.dom('[data-test-serial-number="0"]').exists({ count: 1 }, 'displays serial number tag');
assert.dom('[data-test-key-id="0"]').exists({ count: 1 }, 'displays key id tag');
});
test('lists the correct issuer metadata info when user has only read permission', async function (assert) {
assert.expect(2);
await authPage.login();
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;
const pki_issuer_denied_policy = `
path "${this.mountPath}/*" {
capabilities = ["create", "read", "update", "delete", "list"]
},
path "${this.mountPath}/issuer/${issuerId}" {
capabilities = ["deny"]
}
`;
this.token = await tokenWithPolicy('pki-issuer-denied-policy', pki_issuer_denied_policy);
await logout.visit();
await authPage.login(this.token);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await click(SELECTORS.issuersTab);
assert.dom('[data-test-serial-number="0"]').exists({ count: 1 }, 'displays serial number tag');
assert.dom('[data-test-key-id="0"]').exists({ count: 1 }, 'displays key id tag');
});
test('details view renders correct number of info items', async function (assert) {
assert.expect(13);
await authPage.login(this.pkiAdminToken);

View File

@ -0,0 +1,75 @@
import { module, test } from 'qunit';
import { render } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupRenderingTest } from 'vault/tests/helpers';
/**
* this test is for the page component only. A separate test is written for the form rendered
*/
module('Integration | Component | page/pki-issuer-list', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.engineId = 'pki';
});
test('it renders when issuer metadata tags when info is provided', async function (assert) {
this.store.createRecord('pki/issuer', {
issuerId: 'abcd-efgh',
issuerName: 'issuer-0',
common_name: 'common-name-issuer-0',
isRoot: true,
serialNumber: '74:2d:ed:f2:c4:3b:76:5e:6e:0d:f1:6a:c0:8b:6f:e3:3c:62:f9:03',
});
this.store.createRecord('pki/issuer', {
issuerId: 'ijkl-mnop',
issuerName: 'issuer-1',
isRoot: false,
parsedCertificate: {
common_name: 'common-name-issuer-1',
},
serialNumber: '74:2d:ed:f2:c4:3b:76:5e:6e:0d:f1:6a:c0:8b:6f:e3:3c:62:f9:03',
});
this.issuers = this.store.peekAll('pki/issuer');
await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, {
owner: this.engine,
});
this.issuers.forEach(async (issuer, idx) => {
assert
.dom(`[data-test-serial-number="${idx}"]`)
.hasText('74:2d:ed:f2:c4:3b:76:5e:6e:0d:f1:6a:c0:8b:6f:e3:3c:62:f9:03');
if (idx === 1) {
assert.dom(`[data-test-is-root-tag="${idx}"]`).hasText('intermediate');
} else {
assert.dom(`[data-test-is-root-tag="${idx}"]`).hasText('root');
}
});
});
test('it renders when issuer data even though issuer metadata isnt provided', async function (assert) {
this.store.createRecord('pki/issuer', {
issuerId: 'abcd-efgh',
issuerName: 'issuer-0',
isDefault: false,
});
this.store.createRecord('pki/issuer', {
issuerId: 'ijkl-mnop',
issuerName: 'issuer-1',
isDefault: true,
});
this.issuers = this.store.peekAll('pki/issuer');
await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, {
owner: this.engine,
});
assert.dom(`[data-test-is-default="1"]`).hasText('default issuer');
});
});