UI: add enterprise only pki/config/crl parameters to edit configuration form (#20479)

* update version service

* render enterprise groups

* render enterprise params

* move group headers to within loop

* cleanup template

* update form tests

* change version service references to hasFeature to hasControlGroups getter

* add params to details view

* update version service test
This commit is contained in:
claire bontempo 2023-05-03 09:48:08 -07:00 committed by GitHub
parent 3eb5fb3eb7
commit 5768ae4f9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 260 additions and 98 deletions

View File

@ -7,7 +7,16 @@ import Model, { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
@withFormFields(['expiry', 'autoRebuildGracePeriod', 'deltaRebuildInterval', 'ocspExpiry'])
const formFieldGroups = [
{
'Certificate Revocation List (CRL)': ['expiry', 'autoRebuildGracePeriod', 'deltaRebuildInterval'],
},
{
'Online Certificate Status Protocol (OCSP)': ['ocspExpiry'],
},
{ 'Unified Revocation': ['crossClusterRevocation', 'unifiedCrl', 'unifiedCrlOnExistingPaths'] },
];
@withFormFields(null, formFieldGroups)
export default class PkiCrlModel extends Model {
// This model uses the backend value as the model ID
@ -17,6 +26,7 @@ export default class PkiCrlModel extends Model {
labelDisabled: 'Auto-rebuild off',
mapToBoolean: 'autoRebuild',
isOppositeValue: false,
editType: 'ttl',
helperTextEnabled: 'Vault will rebuild the CRL in the below grace period before expiration',
helperTextDisabled: 'Vault will not automatically rebuild the CRL',
})
@ -28,6 +38,7 @@ export default class PkiCrlModel extends Model {
labelDisabled: 'Delta CRL building off',
mapToBoolean: 'enableDelta',
isOppositeValue: false,
editType: 'ttl',
helperTextEnabled: 'Vault will rebuild the delta CRL at the interval below:',
helperTextDisabled: 'Vault will not rebuild the delta CRL at an interval',
})
@ -39,6 +50,7 @@ export default class PkiCrlModel extends Model {
labelDisabled: 'No expiry',
mapToBoolean: 'disable',
isOppositeValue: true,
editType: 'ttl',
helperTextDisabled: 'The CRL will not be built.',
helperTextEnabled: 'The CRL will expire after:',
})
@ -50,21 +62,37 @@ export default class PkiCrlModel extends Model {
labelDisabled: 'OCSP responder APIs disabled',
mapToBoolean: 'ocspDisable',
isOppositeValue: true,
editType: 'ttl',
helperTextEnabled: "Requests about a certificate's status will be valid for:",
helperTextDisabled: 'Requests cannot be made to check if an individual certificate is valid.',
})
ocspExpiry;
// TODO follow-on ticket to add enterprise only attributes:
/*
@attr('boolean') crossClusterRevocation;
@attr('boolean') unifiedCrl;
@attr('boolean') unifiedCrlOnExistingPaths;
*/
// enterprise only params
@attr('boolean', {
label: 'Cross-cluster revocation',
helpText:
'Enables cross-cluster revocation request queues. When a serial not issued on this local cluster is passed to the /revoke endpoint, it is replicated across clusters and revoked by the issuing cluster if it is online.',
})
crossClusterRevocation;
@attr('boolean', {
label: 'Unified CRL',
helpText:
'Enables unified CRL and OCSP building. This synchronizes all revocations between clusters; a single, unified CRL will be built on the active node of the primary performance replication (PR) cluster.',
})
unifiedCrl;
@attr('boolean', {
label: 'Unified CRL on existing paths',
helpText:
'If enabled, existing CRL and OCSP paths will return the unified CRL instead of a response based on cluster-local data.',
})
unifiedCrlOnExistingPaths;
@lazyCapabilities(apiPath`${'id'}/config/crl`, 'id') crlPath;
get canSet() {
return this.crlPath.get('canCreate') !== false;
return this.crlPath.get('canUpdate') !== false;
}
}

View File

@ -18,9 +18,7 @@ export default Route.extend(UnloadModel, {
},
model(params) {
return this.version.hasFeature('Control Groups')
? this.store.findRecord('control-group', params.accessor)
: null;
return this.version.hasControlGroups ? this.store.findRecord('control-group', params.accessor) : null;
},
actions: {

View File

@ -19,7 +19,7 @@ export default Route.extend(UnloadModel, {
model() {
const type = 'control-group-config';
return this.version.hasFeature('Control Groups')
return this.version.hasControlGroups
? this.store.findRecord(type, 'config').catch((e) => {
// if you haven't saved a config, the API 404s, so create one here to edit and return it
if (e.httpStatus === 404) {

View File

@ -18,6 +18,6 @@ export default Route.extend(UnloadModel, {
},
model() {
return this.version.hasFeature('Control Groups') ? this.store.createRecord('control-group') : null;
return this.version.hasControlGroups ? this.store.createRecord('control-group') : null;
},
});

View File

@ -3,81 +3,71 @@
* SPDX-License-Identifier: MPL-2.0
*/
import { readOnly, match, not } from '@ember/object/computed';
import Service, { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { task } from 'ember-concurrency';
import { keepLatestTask, task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
const hasFeatureMethod = (context, featureKey) => {
const features = context.get('features');
if (!features) {
return false;
export default class VersionService extends Service {
@service store;
@tracked features = [];
@tracked version = null;
get hasPerfReplication() {
return this.features.includes('Performance Replication');
}
return features.includes(featureKey);
};
const hasFeature = (featureKey) => {
return computed('features', 'features.[]', function () {
return hasFeatureMethod(this, featureKey);
});
};
export default Service.extend({
_features: null,
features: readOnly('_features'),
version: null,
store: service(),
hasPerfReplication: hasFeature('Performance Replication'),
get hasDRReplication() {
return this.features.includes('DR Replication');
}
hasDRReplication: hasFeature('DR Replication'),
get hasSentinel() {
return this.features.includes('Sentinel');
}
hasSentinel: hasFeature('Sentinel'),
hasNamespaces: hasFeature('Namespaces'),
get hasNamespaces() {
return this.features.includes('Namespaces');
}
isEnterprise: match('version', /\+.+$/),
get hasControlGroups() {
return this.features.includes('Control Groups');
}
isOSS: not('isEnterprise'),
get isEnterprise() {
if (!this.version) return false;
return this.version.includes('+');
}
setVersion(resp) {
this.set('version', resp.version);
},
get isOSS() {
return !this.isEnterprise;
}
hasFeature(feature) {
return hasFeatureMethod(this, feature);
},
setFeatures(resp) {
if (!resp.features) {
return;
}
this.set('_features', resp.features);
},
getVersion: task(function* () {
if (this.version) {
return;
}
@task
*getVersion() {
if (this.version) return;
const response = yield this.store.adapterFor('cluster').health();
this.setVersion(response);
this.version = response.version;
return;
}),
}
getFeatures: task(function* () {
@keepLatestTask
*getFeatures() {
if (this.features?.length || this.isOSS) {
return;
}
try {
const response = yield this.store.adapterFor('cluster').features();
this.setFeatures(response);
this.features = response.features;
return;
} catch (err) {
// if we fail here, we're likely in DR Secondary mode and don't need to worry about it
}
}).keepLatest(),
}
fetchVersion: function () {
fetchVersion() {
return this.getVersion.perform();
},
fetchFeatures: function () {
}
fetchFeatures() {
return this.getFeatures.perform();
},
});
}
}

View File

@ -69,6 +69,15 @@
{{#unless @crl.ocspDisable}}
<InfoTableRow @label="Interval" @value={{@crl.ocspExpiry}} />
{{/unless}}
{{#if this.isEnterprise}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-xl has-border-bottom-light has-bottom-padding-s">
Unified Revocation
</h2>
<InfoTableRow @label="Cross-cluster revocation" @value={{@crl.crossClusterRevocation}} />
<InfoTableRow @label="Unified CRL" @value={{@crl.unifiedCrl}} />
<InfoTableRow @label="Unified CRL on existing paths" @value={{@crl.unifiedCrlOnExistingPaths}} />
{{/if}}
{{/if}}
{{else}}
<Toolbar>

View File

@ -13,6 +13,7 @@ import { tracked } from '@glimmer/tracking';
import RouterService from '@ember/routing/router-service';
import FlashMessageService from 'vault/services/flash-messages';
import Store from '@ember-data/store';
import VersionService from 'vault/services/version';
interface Args {
currentPath: string;
@ -22,9 +23,13 @@ export default class PkiConfigurationDetails extends Component<Args> {
@service declare readonly store: Store;
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly version: VersionService;
@tracked showDeleteAllIssuers = false;
get isEnterprise() {
return this.version.isEnterprise;
}
@action
async deleteAllIssuers() {
try {

View File

@ -23,32 +23,40 @@
</fieldset>
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-crl-edit-section>
<h2 class="title is-size-5 has-border-bottom-light page-header">
Certificate Revocation List (CRL)
</h2>
{{#if @crl.canSet}}
{{#each @crl.formFields as |attr|}}
{{#if (eq attr.name "ocspExpiry")}}
<h2 class="title is-size-5 has-border-bottom-light page-header">
Online Certificate Status Protocol (OCSP)
</h2>
{{/if}}
{{#if (or (includes attr.name this.alwaysRender) (not @crl.disable))}}
{{#let (get @crl attr.options.mapToBoolean) as |booleanValue|}}
<div class="field">
<TtlPicker
data-test-input={{attr.name}}
@onChange={{fn this.handleTtl attr}}
@label={{attr.options.label}}
@labelDisabled={{attr.options.labelDisabled}}
@helperTextDisabled={{attr.options.helperTextDisabled}}
@helperTextEnabled={{attr.options.helperTextEnabled}}
@initialEnabled={{if attr.options.isOppositeValue (not booleanValue) booleanValue}}
@initialValue={{get @crl attr.name}}
/>
</div>
{{/let}}
{{/if}}
{{#each @crl.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (or (not-eq group "Unified Revocation") this.isEnterprise)}}
<h2 class="title is-size-5 has-border-bottom-light page-header" data-test-crl-header={{group}}>
{{group}}
</h2>
{{/if}}
{{#each fields as |attr|}}
{{#if (eq attr.options.editType "ttl")}}
{{#if (or (includes attr.name (array "expiry" "ocspExpiry")) (not @crl.disable))}}
{{#let (get @crl attr.options.mapToBoolean) as |enabled|}}
{{! 'enabled' is the pki/crl model's boolean attr that corresponds to the duration set by the ttl }}
<div class="field">
<TtlPicker
data-test-input={{attr.name}}
@onChange={{fn this.handleTtl attr}}
@label={{attr.options.label}}
@labelDisabled={{attr.options.labelDisabled}}
@helperTextDisabled={{attr.options.helperTextDisabled}}
@helperTextEnabled={{attr.options.helperTextEnabled}}
@initialEnabled={{if attr.options.isOppositeValue (not enabled) enabled}}
@initialValue={{get @crl attr.name}}
/>
</div>
{{/let}}
{{/if}}
{{else}}
{{#if this.isEnterprise}}
<FormField @attr={{attr}} @model={{@crl}} />
{{/if}}
{{/if}}
{{/each}}
{{/each-in}}
{{/each}}
{{else}}
<EmptyState

View File

@ -11,6 +11,7 @@ import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import RouterService from '@ember/routing/router-service';
import FlashMessageService from 'vault/services/flash-messages';
import VersionService from 'vault/services/version';
import { FormField, TtlEvent } from 'vault/app-types';
import PkiCrlModel from 'vault/models/pki/crl';
import PkiUrlsModel from 'vault/models/pki/urls';
@ -35,12 +36,13 @@ interface PkiCrlBooleans {
export default class PkiConfigurationEditComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly version: VersionService;
@tracked invalidFormAlert = '';
@tracked errorBanner = '';
get alwaysRender() {
return ['expiry', 'ocspExpiry'];
get isEnterprise() {
return this.version.isEnterprise;
}
@task

View File

@ -16,4 +16,6 @@ export const SELECTORS = {
cancelButton: '[data-test-configuration-edit-cancel]',
validationAlert: '[data-test-configuration-edit-validation-alert]',
deleteButton: (attr) => `[data-test-input="${attr}"] [data-test-string-list-button="delete"]`,
groupHeader: (group) => `[data-test-crl-header="${group}"]`,
checkboxInput: (attr) => `[data-test-input="${attr}"]`,
};

View File

@ -35,6 +35,9 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
deltaRebuildInterval: '15m',
ocspExpiry: '77h',
ocspDisable: false,
crossClusterRevocation: true,
unifiedCrl: true,
unifiedCrlOnExistingPaths: true,
});
this.mountConfig = {
id: 'pki-test',
@ -142,6 +145,33 @@ module('Integration | Component | Page::PkiConfigurationDetails', function (hook
assert.dom(SELECTORS.rowValue('Delta rebuild interval')).doesNotExist();
});
test('it renders enterprise params in crl section', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1+ent';
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @mountConfig={{this.mountConfig}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rowValue('Cross-cluster revocation')).hasText('Yes');
assert.dom(SELECTORS.rowIcon('Cross-cluster revocation', 'check-circle'));
assert.dom(SELECTORS.rowValue('Unified CRL')).hasText('Yes');
assert.dom(SELECTORS.rowIcon('Unified CRL', 'check-circle'));
assert.dom(SELECTORS.rowValue('Unified CRL on existing paths')).hasText('Yes');
assert.dom(SELECTORS.rowIcon('Unified CRL on existing paths', 'check-circle'));
});
test('it does not render enterprise params in crl section', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1';
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @mountConfig={{this.mountConfig}} @hasConfig={{true}} />,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.rowValue('Cross-cluster revocation')).doesNotExist();
assert.dom(SELECTORS.rowValue('Unified CRL')).doesNotExist();
assert.dom(SELECTORS.rowValue('Unified CRL on existing paths')).doesNotExist();
});
test('shows the correct information on mount configuration section', async function (assert) {
await render(
hbs`<Page::PkiConfigurationDetails @urls={{this.urls}} @crl={{this.crl}} @mountConfig={{this.mountConfig}} @hasConfig={{true}} />,`,

View File

@ -208,4 +208,94 @@ module('Integration | Component | page/pki-configuration-edit', function (hooks)
await click(SELECTORS.saveButton);
});
test('it renders enterprise only params', async function (assert) {
assert.expect(6);
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1+ent';
this.server.post(`/${this.backend}/config/crl`, (schema, req) => {
assert.ok(true, 'request made to save crl config');
assert.propEqual(
JSON.parse(req.requestBody),
{
auto_rebuild: false,
auto_rebuild_grace_period: '12h',
delta_rebuild_interval: '15m',
disable: false,
enable_delta: false,
expiry: '72h',
ocsp_disable: false,
ocsp_expiry: '12h',
cross_cluster_revocation: true,
unified_crl: true,
unified_crl_on_existing_paths: true,
},
'crl payload includes enterprise params'
);
});
this.server.post(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request made to save urls config');
});
await render(
hbs`
<Page::PkiConfigurationEdit
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.dom(SELECTORS.groupHeader('Certificate Revocation List (CRL)')).exists();
assert.dom(SELECTORS.groupHeader('Online Certificate Status Protocol (OCSP)')).exists();
assert.dom(SELECTORS.groupHeader('Unified Revocation')).exists();
await click(SELECTORS.checkboxInput('crossClusterRevocation'));
await click(SELECTORS.checkboxInput('unifiedCrl'));
await click(SELECTORS.checkboxInput('unifiedCrlOnExistingPaths'));
await click(SELECTORS.saveButton);
});
test('it renders does not render enterprise only params for OSS', async function (assert) {
assert.expect(9);
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1';
this.server.post(`/${this.backend}/config/crl`, (schema, req) => {
assert.ok(true, 'request made to save crl config');
assert.propEqual(
JSON.parse(req.requestBody),
{
auto_rebuild: false,
auto_rebuild_grace_period: '12h',
delta_rebuild_interval: '15m',
disable: false,
enable_delta: false,
expiry: '72h',
ocsp_disable: false,
ocsp_expiry: '12h',
},
'crl payload does not include enterprise params'
);
});
this.server.post(`/${this.backend}/config/urls`, () => {
assert.ok(true, 'request made to save urls config');
});
await render(
hbs`
<Page::PkiConfigurationEdit
@urls={{this.urls}}
@crl={{this.crl}}
@backend={{this.backend}}
/>
`,
this.context
);
assert.dom(SELECTORS.checkboxInput('crossClusterRevocation')).doesNotExist();
assert.dom(SELECTORS.checkboxInput('unifiedCrl')).doesNotExist();
assert.dom(SELECTORS.checkboxInput('unifiedCrlOnExistingPaths')).doesNotExist();
assert.dom(SELECTORS.groupHeader('Certificate Revocation List (CRL)')).exists();
assert.dom(SELECTORS.groupHeader('Online Certificate Status Protocol (OCSP)')).exists();
assert.dom(SELECTORS.groupHeader('Unified Revocation')).doesNotExist();
await click(SELECTORS.saveButton);
});
});

View File

@ -18,14 +18,14 @@ module('Unit | Service | version', function (hooks) {
test('setting version computes isEnterprise properly', function (assert) {
const service = this.owner.lookup('service:version');
service.set('version', '0.9.5+prem');
service.set('version', '0.9.5+ent');
assert.false(service.get('isOSS'));
assert.true(service.get('isEnterprise'));
});
test('setting version with hsm ending computes isEnterprise properly', function (assert) {
const service = this.owner.lookup('service:version');
service.set('version', '0.9.5+prem.hsm');
service.set('version', '0.9.5+ent.hsm');
assert.false(service.get('isOSS'));
assert.true(service.get('isEnterprise'));
});
@ -33,14 +33,14 @@ module('Unit | Service | version', function (hooks) {
test('hasPerfReplication', function (assert) {
const service = this.owner.lookup('service:version');
assert.false(service.get('hasPerfReplication'));
service.set('_features', ['Performance Replication']);
service.set('features', ['Performance Replication']);
assert.true(service.get('hasPerfReplication'));
});
test('hasDRReplication', function (assert) {
const service = this.owner.lookup('service:version');
assert.false(service.get('hasDRReplication'));
service.set('_features', ['DR Replication']);
service.set('features', ['DR Replication']);
assert.true(service.get('hasDRReplication'));
});
});